Repository navigation
Add Simplified Chinese to the admin dashboard #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c007518
091145a
4ac4cb8
88beabb
21bda93
42e4d3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,15 @@ describe("fmtNumber", () => { | |
| expect(out).toMatch(/1.234.567/); // any single separator character | ||
| }); | ||
|
|
||
| it("groups thousands for zh", () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This asserts Assert something the fallback cannot satisfy, such as
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 88beabb. The case asserts |
||
| // Chinese groups with commas like en, so the output alone cannot tell a | ||
| // resolved zh locale from the en-US fallback the runtime uses when it has | ||
| // no zh ICU data. Assert the locale the formatter actually resolved, then | ||
| // the digits it produced. | ||
| expect(new Intl.NumberFormat("zh").resolvedOptions().locale).toMatch(/^zh/); | ||
| expect(fmtNumber(1234567, "zh")).toBe("1,234,567"); | ||
| }); | ||
|
|
||
| it("formats zero and negative values", () => { | ||
| expect(fmtNumber(0, "en")).toBe("0"); | ||
| expect(fmtNumber(-12345, "en")).toBe("-12,345"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,6 @@ import { | |
| SUPPORTED_LANGUAGES, | ||
| } from "../../i18n"; | ||
| import en from "../../i18n/en"; | ||
| import id from "../../i18n/id"; | ||
| import sv from "../../i18n/sv"; | ||
|
|
||
| function flattenKeys(obj: unknown, prefix = ""): string[] { | ||
| if (obj === null || typeof obj !== "object") return [prefix]; | ||
|
|
@@ -25,12 +23,21 @@ function placeholders(value: string): string[] { | |
| return [...(value.match(/\{[a-zA-Z0-9_]+\}/g) ?? [])].sort(); | ||
| } | ||
|
|
||
| /** | ||
| * Every supported language except the fallback. Derived once here: the | ||
| * placeholder and key-parity suites both iterate it, so a change to the | ||
| * default language or to the exclusion rule lands in one place instead of | ||
| * two copies that drift apart. | ||
| */ | ||
| const translated = SUPPORTED_LANGUAGES.filter((lang) => lang !== DEFAULT_LANGUAGE); | ||
|
|
||
| describe("i18n", () => { | ||
| describe("isSupportedLanguage", () => { | ||
| it("returns true for supported languages", () => { | ||
| expect(isSupportedLanguage("en")).toBe(true); | ||
| expect(isSupportedLanguage("id")).toBe(true); | ||
| expect(isSupportedLanguage("sv")).toBe(true); | ||
| expect(isSupportedLanguage("zh")).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false for unsupported languages", () => { | ||
|
|
@@ -56,6 +63,11 @@ describe("i18n", () => { | |
| expect(t["nav.dashboard"]).toBe("Översikt"); | ||
| }); | ||
|
|
||
| it("returns Simplified Chinese translations for 'zh'", () => { | ||
| const t = getTranslations("zh"); | ||
| expect(t["nav.dashboard"]).toBe("仪表盘"); | ||
| }); | ||
|
|
||
| it("falls back to English for unsupported language", () => { | ||
| const t = getTranslations("fr"); | ||
| expect(t["nav.dashboard"]).toBe("Dashboard"); | ||
|
|
@@ -65,10 +77,12 @@ describe("i18n", () => { | |
| describe("placeholder parity", () => { | ||
| // Translations is typed off en, so a missing key fails the build. Nothing | ||
| // types the placeholders inside a value, so a locale can keep the key and | ||
| // silently drop the interpolation the key exists for. | ||
| it.each([["id", id], ["sv", sv]] as const)("keeps every %s placeholder that en declares", (_name, locale) => { | ||
| // silently drop the interpolation the key exists for. Driven off | ||
| // SUPPORTED_LANGUAGES so a new locale is covered the day it is registered. | ||
| it.each(translated)("keeps every %s placeholder that en declares", (lang) => { | ||
| const locale = getTranslations(lang); | ||
| for (const key of Object.keys(en) as (keyof typeof en)[]) { | ||
| expect(placeholders(locale[key])).toEqual(placeholders(en[key])); | ||
| expect(placeholders(locale[key]), `${lang} ${key}`).toEqual(placeholders(en[key])); | ||
| } | ||
| }); | ||
| }); | ||
|
|
@@ -140,20 +154,18 @@ describe("i18n", () => { | |
| }); | ||
|
|
||
| it("lists all supported languages", () => { | ||
| expect(SUPPORTED_LANGUAGES).toContain("en"); | ||
| expect(SUPPORTED_LANGUAGES).toContain("id"); | ||
| expect(SUPPORTED_LANGUAGES).toContain("sv"); | ||
| // The exact set, not one containment per language: a locale dropped from | ||
| // the array used to leave this test green, because its sibling parity | ||
| // suites iterate the array itself and would simply run one case fewer. | ||
| expect([...SUPPORTED_LANGUAGES]).toEqual(["en", "id", "sv", "zh"]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("key parity across locales", () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 88beabb. It asserts |
||
| const enKeys = new Set(flattenKeys(en)); | ||
|
|
||
| it.each([ | ||
| ["id", id], | ||
| ["sv", sv], | ||
| ])("locale %s has the same key set as en", (_name, locale) => { | ||
| const localeKeys = new Set(flattenKeys(locale)); | ||
| it.each(translated)("locale %s has the same key set as en", (lang) => { | ||
| const localeKeys = new Set(flattenKeys(getTranslations(lang))); | ||
| const missing = [...enKeys].filter((k) => !localeKeys.has(k)); | ||
| const extra = [...localeKeys].filter((k) => !enKeys.has(k)); | ||
| expect({ missing, extra }).toEqual({ missing: [], extra: [] }); | ||
|
|
@@ -174,21 +186,30 @@ describe("i18n", () => { | |
| expect(translations["lang.en"]).toBe("English"); | ||
| expect(translations["lang.id"]).toBe("Bahasa Indonesia"); | ||
| expect(translations["lang.sv"]).toBe("Svenska"); | ||
| expect(translations["lang.zh"]).toBe("简体中文"); | ||
| } | ||
| }); | ||
|
|
||
| it("localized language names differ per locale", () => { | ||
| const en = getTranslations("en"); | ||
| const id = getTranslations("id"); | ||
| const sv = getTranslations("sv"); | ||
| const zh = getTranslations("zh"); | ||
|
|
||
| expect(en["langLocal.id"]).toBe("Indonesian"); | ||
| expect(id["langLocal.id"]).toBe("Indonesia"); | ||
| expect(sv["langLocal.id"]).toBe("Indonesiska"); | ||
| expect(zh["langLocal.id"]).toBe("印度尼西亚语"); | ||
|
|
||
| expect(en["langLocal.sv"]).toBe("Swedish"); | ||
| expect(id["langLocal.sv"]).toBe("Swedia"); | ||
| expect(sv["langLocal.sv"]).toBe("Svenska"); | ||
| expect(zh["langLocal.sv"]).toBe("瑞典语"); | ||
|
|
||
| expect(en["langLocal.zh"]).toBe("Chinese (Simplified)"); | ||
| expect(id["langLocal.zh"]).toBe("Tionghoa (Sederhana)"); | ||
| expect(sv["langLocal.zh"]).toBe("Kinesiska (förenklad)"); | ||
| expect(zh["langLocal.zh"]).toBe("简体中文"); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new picker option is pinned with a render assertion only. No Playwright step selects
zhand asserts the reloaded page renders in Chinese, and the PR ran onlyyarn test.CLAUDE.md, Testing: "New admin controls (chip, sort, paginator, selector, row action) get an e2e step that clicks them and asserts the URL and the marked state, alongside the vitest render assertions." The selector gains a new offered choice here and
e2e/has no language-picker step at all (only theperf.spechtml langtruthiness check). A deadonchange, a reload that lands on English, or CJK text clipped in the fixed-width chart labels would ship unseen.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right that the picker has no browser coverage, and the gap is older than this PR.
#language-picker(src/pages/settings.tsx:45) shipped 2026-03-31 in 91c1aaa, the original en/id/sv commit, which touched noe2e/file because the suite did not exist yet.e2e/arrived 2026-08-27 in 317339a, the same commit that added the "new admin controls get an e2e step" line to CLAUDE.md. No language has a step today, not only the new one: the onlylangassertion ine2e/isperf.spec.ts:219,expect(a11y.lang).toBeTruthy(), and no commit has ever added a step that drives the picker.So this PR adds a fourth option to a control that was already uncovered, and it does not reduce coverage. I am not adding the step here.
Driving the picker end to end is e2e-suite work rather than locale work. The spec has to select each offered language, follow the cookie and the
PUT /_/admin/api/preferencesand the reload thatsetLanguage(src/client.ts:151) performs, and assert both the rendered language and the marked option. That is a larger change than the locale that prompted it, and the shape is identical for en, id and sv, so it belongs in a change that covers the picker as a control. The same reasoning is why I read the rule as aimed at new controls: this PR adds an option to an existing one.The part of your comment that is a defect rather than a coverage gap is the axis labels.
fmtLabelcomposes a LatinMon D/Mon YYstring out of themonth.*keys, so zh renders7月 16and7月 26where the locale wants7月16日and2026年7月. That wants a locale-awareIntl.DateTimeFormat, the callfmtBucketDatealready makes for tooltips, and I will take it as its own change.