Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
isValidBcp47Tag,
languageForManuallyEnteredTag,
UNLISTED_LANGUAGE,
defaultDisplayName,
} from "./languageTagUtils";
import { demarcateResults } from "./matchingSubstringDemarcation";

import { defaultSearchResultModifier } from "./searchResultModifiers";
import { parseLangtagFromLangChooser } from "./searchForLanguage";
Expand All @@ -38,6 +40,7 @@ import {
SERBIAN_LANGUAGE,
ARABIC_MACROLANGUAGE,
AYMARA_MACROLANGUAGE,
createTestLanguageEntry,
} from "./testUtils";

describe("Tag creation", () => {
Expand Down Expand Up @@ -849,3 +852,103 @@ describe("formatting dialect codes", () => {
);
});
});

describe("defaultDisplayName", () => {
// Helper for the fixtures, whose scripts are listed in an order we don't want tests to depend on
function scriptOf(language: ILanguage, scriptCode: string): IScript {
const script = language.scripts.find((s) => s.code === scriptCode);
expect(script, `${scriptCode} script of ${language.exonym}`).toBeDefined();
return script as IScript;
}

it("should return empty string when there is no language", () => {
expect(defaultDisplayName(undefined)).toEqual("");
expect(
defaultDisplayName(undefined, { code: "Latn", name: "Latin" })
).toEqual("");
});

it("should use the autonym when no script is given", () => {
expect(defaultDisplayName(BOSNIAN_LANGUAGE)).toEqual("Bosanski jezik");
expect(defaultDisplayName(SERBIAN_LANGUAGE)).toEqual("српски");
});

it("should fall back to the exonym when there is no script and no autonym", () => {
expect(defaultDisplayName(ENGLISH_LANGUAGE)).toEqual("English");
expect(defaultDisplayName(NORTHERN_UZBEK_LANGUAGE)).toEqual(
"Northern Uzbek"
);
});

// This precedence is the whole point of passing the script: choosing a different script for a
// language is what changes the name we display for it, e.g. Serbian shows as "српски" in
// Cyrillic but "srpski" in Latin.
it("should prefer the script's languageNameInScript over the autonym", () => {
expect(
defaultDisplayName(SERBIAN_LANGUAGE, scriptOf(SERBIAN_LANGUAGE, "Latn"))
).toEqual("srpski");
expect(
defaultDisplayName(SERBIAN_LANGUAGE, scriptOf(SERBIAN_LANGUAGE, "Cyrl"))
).toEqual("српски");
expect(
defaultDisplayName(BOSNIAN_LANGUAGE, scriptOf(BOSNIAN_LANGUAGE, "Cyrl"))
).toEqual("босански");
});

it("should fall back to the autonym for a script with no languageNameInScript", () => {
// Norwegian's Braille and Runic entries carry no languageNameInScript
expect(
defaultDisplayName(
NORWEGIAN_MACROLANGUAGE,
scriptOf(NORWEGIAN_MACROLANGUAGE, "Brai")
)
).toEqual("Norsk");
expect(
defaultDisplayName(BOSNIAN_LANGUAGE, scriptOf(BOSNIAN_LANGUAGE, "Arab"))
).toEqual("Bosanski jezik");
});

it("should fall back to the exonym for a script with no languageNameInScript on a language with no autonym", () => {
expect(
defaultDisplayName(ENGLISH_LANGUAGE, scriptOf(ENGLISH_LANGUAGE, "Latn"))
).toEqual("English");
});

it("should return empty string for the unlisted language, even with a script", () => {
expect(defaultDisplayName(UNLISTED_LANGUAGE)).toEqual("");
expect(
defaultDisplayName(UNLISTED_LANGUAGE, { code: "Latn", name: "Latin" })
).toEqual("");
});

it("should return empty string for a manually entered tag language", () => {
const manualLanguage = languageForManuallyEnteredTag("qxy-Latn-ZZ");
expect(defaultDisplayName(manualLanguage)).toEqual("");
expect(
defaultDisplayName(manualLanguage, { code: "Latn", name: "Latin" })
).toEqual("");
});

it("should strip the match demarcation that search results carry", () => {
// Languages coming out of a search have the matched substring marked for bolding,
// e.g. searching "san" marks Bosnian's autonym as "Bo[san]ski jezik"
const [demarcatedBosnian] = demarcateResults([BOSNIAN_LANGUAGE], "san");
expect(demarcatedBosnian.autonym).toEqual("Bo[san]ski jezik");
expect(defaultDisplayName(demarcatedBosnian)).toEqual("Bosanski jezik");

expect(
defaultDisplayName(demarcatedBosnian, {
code: "Cyrl",
name: "Cyrillic",
languageNameInScript: "бо[сан]ски",
})
).toEqual("босански");

const demarcatedExonymOnlyLanguage = createTestLanguageEntry({
exonym: "E[ngl]ish",
iso639_3_code: "eng",
languageSubtag: "en",
});
expect(defaultDisplayName(demarcatedExonymOnlyLanguage)).toEqual("English");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ export async function loadLanguageChooser(page) {
await page.goto("/", { waitUntil: "load" });
}

// Loads src/demos/HostIntegrationDemo.tsx, which renders LanguageChooser with host-supplied
// actionButtons and no rightPanelComponent -- the arrangement Bloom ships -- and puts everything
// onSelectionChange reports on screen. The chooser itself is the same component here, so the
// card/search helpers below work in this demo too.
export async function loadHostIntegrationDemo(page) {
await page.goto("/?demo=host-integration", { waitUntil: "load" });
}

export async function createPageAndLoadHostIntegrationDemo(browser) {
const page = await browser.newPage();
await loadHostIntegrationDemo(page);
return page;
}

export function scriptCardTestId(scriptCode: string) {
return `script-card-${scriptCode}`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { test, expect, Page } from "@playwright/test";
import {
clickLanguageCard,
createPageAndLoadHostIntegrationDemo,
loadHostIntegrationDemo,
scriptCardTestId,
search,
} from "./e2eHelpers";

let page: Page;

// What the chooser tells a host application, which is the half of the contract no other e2e file
// touches: they all drive the LanguageChooserDialog wrapper, whose own OK button stands in for the
// host. Bloom instead supplies its own actionButtons, passes no rightPanelComponent, and learns
// about the selection solely through onSelectionChange -- so these tests assert on what that
// callback reports, via src/demos/HostIntegrationDemo.tsx. They deliberately do not re-test search
// or card behavior, which the other files already cover.
test.describe("What onSelectionChange reports to the host", () => {
test.beforeAll(async ({ browser }) => {
page = await createPageAndLoadHostIntegrationDemo(browser);
});

// These tests change the selection, and there is no host UI here to reset it, so reload between
// them rather than trying to click our way back to a pristine state.
test.beforeEach(async () => {
await loadHostIntegrationDemo(page);
});

test("chooser renders and is usable with no rightPanelComponent supplied", async () => {
// The right pane itself still exists (it holds the display name bar, tag preview and the
// host's action buttons); it's the client-supplied slot within it that is empty.
await expect(page.locator("#right-panel-component-container")).toBeEmpty();

await search(page, "russian");
await clickLanguageCard(page, "rus");

// The parts of the right pane the component owns still work
await expect(page.locator("#language-name-bar")).toHaveValue(
"русский язык"
);
await expect(page.getByTestId("right-panel-langtag-preview")).toContainText(
"ru"
);
});

test("onSelectionChange reports the tag and orthography once a language and script are chosen", async () => {
const reportedTag = page.getByTestId("host-integration-reported-tag");
const reportedScript = page.getByTestId("host-integration-reported-script");

// Nothing is reported before there is a complete selection
await expect(page.getByTestId("host-integration-report-count")).toHaveText(
"0"
);

await search(page, "chechen");
await clickLanguageCard(page, "che");
// Chechen has several scripts, so the selection is not complete until one is picked
await expect(reportedTag).toBeEmpty();

await page.getByTestId(scriptCardTestId("Cyrl")).click();
await expect(reportedTag).toHaveText("ce");
await expect(
page.getByTestId("host-integration-reported-language")
).toHaveText("ce");
await expect(reportedScript).toHaveText("Cyrl");

// Switching script re-reports, which is how the host learns the tag changed
await page.getByTestId(scriptCardTestId("Arab")).click();
await expect(reportedTag).toHaveText("ce-Arab");
await expect(reportedScript).toHaveText("Arab");
});

test("onSelectionChange reports an undefined selection when the selection is cleared", async () => {
const reportCount = page.getByTestId("host-integration-report-count");
const reportedTag = page.getByTestId("host-integration-reported-tag");

await search(page, "russian");
await clickLanguageCard(page, "rus"); // Russian has a single script, so this alone is complete
await expect(reportedTag).toHaveText("ru");
const countWhenSelected = await reportCount.textContent();

// Re-clicking the selected card unselects it
await clickLanguageCard(page, "rus");
// The host must be told the selection went away, not merely left holding the stale one, so
// check that a report actually happened rather than only that the fields went empty.
await expect(reportCount).not.toHaveText(countWhenSelected as string);
await expect(reportedTag).toBeEmpty();
await expect(
page.getByTestId("host-integration-reported-language")
).toBeEmpty();
});

test("host-supplied action button enables and disables off the reported selection", async () => {
const okButton = page.getByTestId("host-integration-ok-button");
await expect(okButton).toBeDisabled();

await search(page, "russian");
await clickLanguageCard(page, "rus");
await expect(okButton).toBeEnabled();

await clickLanguageCard(page, "rus");
await expect(okButton).toBeDisabled();
});

// A plain selection carries NO custom display name -- the chooser only fills that in once the
// user edits the name field. A host that reads only customDisplayName therefore gets nothing for
// the ordinary case and has to fall back to defaultDisplayName, which is what Bloom does.
test("a selection the user did not rename reports a default name but no custom one", async () => {
await search(page, "arabic");
await clickLanguageCard(page, "arb");
await page.getByTestId(scriptCardTestId("Arab")).click();

await expect(page.getByTestId("host-integration-reported-tag")).toHaveText(
"arb"
);
await expect(
page.getByTestId("host-integration-reported-display-name")
).toBeEmpty();
await expect(
page.getByTestId("host-integration-default-display-name")
).not.toBeEmpty();
// ...so the name a host would actually use comes from the default, not the custom field.
await expect(
page.getByTestId("host-integration-name-a-host-would-use")
).not.toBeEmpty();
});

// A host has to be able to tell "this script reads right-to-left", "this one reads
// left-to-right", and "this script does not say" apart -- Bloom stores the difference (its IsRtl
// is nullable) and conflating the last two is what BL-13982 was about.
test("the script's reading direction is reported, and distinguishes unknown from false", async () => {
const direction = page.getByTestId("host-integration-script-is-rtl");
await expect(direction).toBeEmpty(); // nothing selected yet

await search(page, "chechen");
await clickLanguageCard(page, "che");
await page.getByTestId(scriptCardTestId("Cyrl")).click();
await expect(direction).toHaveText("false");

await page.getByTestId(scriptCardTestId("Arab")).click();
await expect(direction).toHaveText("true");
});

test("the country Bloom would store is derived from the reported tag", async () => {
await expect(page.getByTestId("host-integration-country")).toBeEmpty();

await search(page, "russian");
await clickLanguageCard(page, "rus");
await expect(page.getByTestId("host-integration-country")).not.toBeEmpty();
});

test("the host action button commits what it was told", async () => {
await expect(page.getByTestId("host-integration-submitted")).toBeEmpty();

await search(page, "russian");
await clickLanguageCard(page, "rus");
await page.getByTestId("host-integration-ok-button").click();

await expect(page.getByTestId("host-integration-submitted")).toContainText(
"ru"
);
});

test("edits to the display name are reported to the host", async () => {
// Bloom reads customDetails.customDisplayName off the reported selection to name the language,
// so the report has to keep up with the display name field, not just the tag.
await search(page, "russian");
await clickLanguageCard(page, "rus");
await expect(page.getByTestId("host-integration-reported-tag")).toHaveText(
"ru"
);

await page.locator("#language-name-bar").fill("Ruso");
await expect(
page.getByTestId("host-integration-reported-display-name")
).toHaveText("Ruso");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HostIntegrationDemo } from "./HostIntegrationDemo";

const meta: Meta<typeof HostIntegrationDemo> = {
title: "Demos/Host Integration Demo",
component: HostIntegrationDemo,
};

export default meta;
type Story = StoryObj<typeof HostIntegrationDemo>;

// Nothing selected yet, so the readout across the top starts empty with a report count of 0.
// Pick a language and a script and watch what the host is told, and when.
export const Primary: Story = {
args: {},
render: (args, context) => (
<HostIntegrationDemo {...args} uiLanguage={context.parameters.uiLanguage} />
),
};

// Opened with a selection already in hand, the way a host reopens the chooser on a language the
// user picked earlier. Shows that the host is told about that starting selection rather than
// having to remember it.
export const ReopenedWithASelection: Story = {
args: {
initialLanguageTag: "uz-Cyrl",
initialCustomDisplayName: "ÖzbekCustomizedName",
},
render: (args, context) => (
<HostIntegrationDemo {...args} uiLanguage={context.parameters.uiLanguage} />
),
};
Loading
Loading