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
5 changes: 2 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,14 @@ Fatal errors include YAML syntax errors (the file cannot be parsed at all), and

## Interface language

Chinese tables follow in stacked translation PRs. The language choices are selectable now; areas without Chinese data display their English fallback.

`behavior.language` accepts `auto` (the default), `en`, `zh-Hans` (Simplified Chinese),
and `zh-Hant` (Traditional Chinese). Common spellings such as `zh-CN`, `zh_CN`,
`zh`, and `ZH-HANS` are accepted and saved canonically. Unknown values warn and
fall back to `auto` without aborting configuration loading. Change it live in Settings; the terminal and
browser/desktop frontends share the same table. The language picker displays `auto` as
**Auto (system)** / **自动(跟随系统)** / **自動(跟隨系統)**, while the saved value stays
`auto`. CLI output and logs remain English.
`auto`. CLI output and logs remain English, and strings without a Chinese translation yet
display in English.

`auto` reads the first nonempty `LC_ALL`, `LC_MESSAGES`, or `LANG`. Without one,
macOS uses the first preferred `AppleLanguages` entry and Windows uses its UI
Expand Down
154 changes: 154 additions & 0 deletions gui/e2e/localization.shot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect, test, type WebSocketRoute } from "@playwright/test";
import type { ClientMessage } from "../src/bindings/ClientMessage";
import type { ServerMessage } from "../src/bindings/ServerMessage";
import strings from "../src/bindings/strings.json" with { type: "json" };
import { openWith, shot } from "./bridge";
import {
DEFAULT_LOOK,
hello,
onboarding,
playing,
library,
discover,
settings,
} from "./fixtures";

function settingsFor(
language: "en" | "zh-Hans" | "zh-Hant",
rev: number,
): ServerMessage {
const table =
language === "zh-Hans"
? strings.ZH_HANS
: language === "zh-Hant"
? strings.ZH_HANT
: strings.EN;
return {
kind: "settings",
rev,
payload: {
language,
strings: table,
visualizer: DEFAULT_LOOK,
rows: [
{
id: "behavior.language",
name:
language === "en"
? "Language"
: language === "zh-Hans"
? "语言"
: "語言",
description: "",
group: "interface",
value: {
kind: "choice",
value: language,
options: ["auto", "en", "zh-Hans", "zh-Hant"],
labels: [
table.settings.option_auto,
table.settings.option_en,
table.settings.option_zh_hans,
table.settings.option_zh_hant,
],
},
button: null,
},
],
},
};
}

test("the Hello table localizes onboarding before App or Settings exists", async ({
page,
}) => {
if (hello.kind !== "hello") throw new Error("hello fixture");
await openWith(page, [
{
...hello,
payload: {
...hello.payload,
language: "zh-Hant",
strings: strings.ZH_HANT,
},
},
onboarding,
]);
await expect(page.getByRole("heading", { name: "選擇音源" })).toBeVisible();
await expect(page.getByRole("button", { name: "繼續" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "zh-Hant");
await shot(page, "onboarding-zh-Hant");
});

test("a Settings push updates mounted memoized pages and keeps input and raw choices", async ({
page,
}) => {
let socket: WebSocketRoute | undefined;
const sent: ClientMessage[] = [];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.routeWebSocket("**/ws", (ws) => {
socket = ws;
ws.onMessage((message) =>
sent.push(JSON.parse(String(message)) as ClientMessage),
);
for (const message of [
...playing,
...library,
...discover,
...settings,
settingsFor("en", 2),
])
ws.send(JSON.stringify(message));
});
await page.goto("/#code=shot");
await page
.getByRole("searchbox", { name: "Search", exact: true })
.fill("{name} 五月天");
await page.getByRole("button", { name: "Discover", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Discover", exact: true }),
).toBeVisible();
socket!.send(JSON.stringify(settingsFor("zh-Hans", 3)));
await expect(
page.getByRole("heading", { name: "发现", exact: true }),
).toBeVisible();
await expect(
page.getByRole("searchbox", { name: "搜索", exact: true }),
).toHaveValue("{name} 五月天");
await expect(
page.getByRole("button", { name: "随机播放", exact: true }),
).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "zh-Hans");
await shot(page, "discover-zh-Hans");
// A stale Settings message cannot roll the language or the table back.
socket!.send(JSON.stringify(settingsFor("en", 1)));
await expect(
page.getByRole("heading", { name: "发现", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "设置", exact: true }).click();
await expect(
page.getByRole("heading", { name: "设置", exact: true }),
).toBeVisible();
socket!.send(JSON.stringify(settingsFor("zh-Hant", 4)));
await expect(
page.getByRole("heading", { name: "設定", exact: true }),
).toBeVisible();
await expect(
page.getByRole("searchbox", { name: "搜尋", exact: true }),
).toHaveValue("{name} 五月天");
await page
.getByRole("combobox", { name: "語言", exact: true })
.selectOption("en");
await expect
.poll(() => sent)
.toContainEqual({
type: "action",
action: {
SetSetting: { id: "behavior.language", value: { Text: "en" } },
},
});
await expect(page.locator("html")).toHaveAttribute("lang", "zh-Hant");
await shot(page, "settings-zh-Hant");
expect(errors).toEqual([]);
});
Loading
Loading