Speak the editor in each person's Home Assistant language: every text in the editor section of en.json, the mockup in the screens' language, and one Language & region card for all screens

- vue-i18n 11 with the Composition API: English comes with the page, any other language loads the first time it is
  needed from screen_manager/translations, and a text a language lacks shows in English. The build keeps only _meta
  and editor of each file (vite.config.ts), never the screens' or the add-on's texts.
- The editor follows the language of the user's Home Assistant profile (<html lang> of Home Assistant's page), else
  the browser's; plural forms follow the file's _meta.plural, with the screens' rules.
- Every request tells the add-on the editor's language (X-ESP-Screens-Language), so its own words can follow it.
- The mockup draws the screens' words (Unavailable, Page 2 ›, the date, "5 min ago") in the screens' language, with
  their clock and number format.
- Settings → Language & region: the screen language, time format and number format for every screen, with the user's
  own profile offered when it differs. The per-screen clock choice goes; the top bar points there. A screen that
  still needs the new language says so in its update badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:40:29 +02:00
parent c2cf3391d6
commit 1dc2eab0cf
58 changed files with 2573 additions and 672 deletions
+4 -3
View File
@@ -67,7 +67,8 @@ describe("TileCard", () => {
it("draws the large value, the wide card's toggle and an unavailable entity in grey", () => {
state.liveStates["sensor.t"] = { state: "1249", word: null, a: { unit_of_measurement: "W" } };
const big = placed({ entity: "sensor.t", name: "Power", slot: 0, options: { display: "watch" } });
expect(big.find(".big").text()).toBe("1249W");
// Numbers as the screens write them (app 0.2.90): "1,234.5" until the add-on names another format.
expect(big.find(".big").text()).toBe("1,249W");
state.liveStates["light.a"] = { state: "off", word: "Off", a: {} };
const wide = placed({ entity: "light.a", name: "", slot: 2, options: { size: "wide" } });
expect(wide.classes()).toContain("wide");
@@ -82,12 +83,12 @@ describe("TileCard", () => {
const plain = placed({ entity: "sensor.t", name: "Power", slot: 0 });
expect(plain.find(".head > .ic").exists()).toBe(true);
expect(plain.find(".head > .tx > .nm").text()).toBe("Power");
expect(plain.find(".head > .tx > .st").text()).toBe("1249 W");
expect(plain.find(".head > .tx > .st").text()).toBe("1,249 W");
// A watch card keeps the name next to the icon and puts the big value underneath.
const watch = placed({ entity: "sensor.t", name: "Power", slot: 1, options: { display: "watch" } });
expect(watch.find(".head").classes()).toContain("top");
expect(watch.find(".head .big").exists()).toBe(false);
expect(watch.find(".head + .big").text()).toBe("1249W");
expect(watch.find(".head + .big").text()).toBe("1,249W");
state.liveStates["light.a"] = { state: "on", word: "On", a: { brightness: 255 } };
const lamp = placed({ entity: "light.a", name: "", slot: 2, options: { inline: "slider" } });
expect(lamp.find(".head + .mini-slider").exists()).toBe(true);
+225
View File
@@ -0,0 +1,225 @@
// The editor's texts (app 0.2.90): English from en.json with the page, another language when it is needed, the plural
// rules of the screens, and which language the page, the add-on's answers and the mockup speak.
import { mount } from "@vue/test-utils";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import en from "../../screen_manager/translations/en.json";
import { send } from "../src/api";
import TileCard from "../src/components/TileCard.vue";
import {
addLanguage, andList, haProfile, i18n, languages, loadLanguage, matchLanguage, numberText, pickLanguage, PLURAL_RULES, requestedLanguage,
setEditorLanguage, t,
} from "../src/i18n";
import { agoText, dateText } from "../src/model/topbar";
import { state } from "../src/store";
const meta = (plural: string) => ({ name: "Test", english: "Test", script: "latin", plural, checked: false });
// Every text of a section as [key, text]; a list's items get .0, .1, ... as in tools/i18n.py.
function flat(node: unknown, path: string): [string, unknown][] {
if (node && typeof node === "object") return Object.entries(node).flatMap(([key, value]) => flat(value, `${path}.${key}`));
return [[path, node]];
}
const english = flat(en.editor, "editor");
afterEach(() => {
i18n.global.locale.value = "en";
vi.unstubAllGlobals();
});
describe("English", () => {
it("comes with the page, as the language itself and the editor's texts only", () => {
// The build keeps the screens' and the add-on's sections out (vite.config.ts); the tests import it the same way.
expect(Object.keys(en)).toEqual(["_meta", "editor"]);
expect(en._meta.plural).toBe("one_other");
expect(languages()[0]).toBe("en");
});
it("has a readable text for every key, and no text vue-i18n reads as something else", () => {
for (const [key, text] of english) {
expect(typeof text, key).toBe("string");
expect(t(key), key).not.toBe(key);
// @ links another text and | separates plural forms: a text uses them only as that.
expect(text as string, key).not.toMatch(/@|\$|\|\|/);
if ((text as string).includes("|")) expect(text as string, key).toMatch(/ \| /);
}
});
it("has every key the code asks for, and no key the code doesn't use", () => {
const root = join(__dirname, "..", "src");
const files = (dir: string): string[] => readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
return statSync(path).isDirectory() ? files(path) : /\.(ts|vue)$/.test(name) ? [path] : [];
});
const code = files(root).map((path) => readFileSync(path, "utf8")).join("\n");
const keys = new Set([...code.matchAll(/["'`](editor\.[a-z0-9_.]+[a-z0-9_])["'`]/g)].map((m) => m[1]));
// A key built at run time, such as `editor.library.filters.${value}`, counts for every text under its start.
const starts = new Set([...code.matchAll(/`(editor\.[a-z0-9_.]+)\$\{/g)].map((m) => m[1]));
const known = new Set(english.map(([key]) => key));
for (const key of keys) expect(known.has(key) || english.some(([other]) => other.startsWith(`${key}.`)), key).toBe(true);
for (const start of starts) expect(english.some(([key]) => key.startsWith(start)), start).toBe(true);
const unused = english.map(([key]) => key).filter((key) => !keys.has(key) && ![...starts].some((start) => key.startsWith(start)));
expect(unused).toEqual([]);
});
});
describe("choosing a language", () => {
const available = ["en", "nl", "pt-BR", "zh-Hans"];
it("takes the same code, else its base language, else English", () => {
expect(matchLanguage("nl", available)).toBe("nl");
expect(matchLanguage("PT-br", available)).toBe("pt-BR");
expect(matchLanguage("zh-hans", available)).toBe("zh-Hans");
expect(matchLanguage("nl-BE", available)).toBe("nl");
expect(matchLanguage("fr", available)).toBeUndefined();
expect(pickLanguage("fr", available)).toBe("en");
expect(pickLanguage("pt", available)).toBe("en");
expect(pickLanguage(null, available)).toBe("en");
expect(pickLanguage("", available)).toBe("en");
});
it("reads Home Assistant's <html lang> inside its iframe, else the browser's language", () => {
const frame = document.createElement("iframe");
document.body.append(frame);
const inner = frame.contentWindow!;
document.documentElement.lang = "nl";
expect(requestedLanguage(inner)).toBe("nl");
document.documentElement.lang = "";
expect(requestedLanguage(inner)).toBe(inner.navigator.language);
expect(requestedLanguage(window)).toBe(navigator.language);
// A parent on another origin throws on its document.
const foreign = { get parent(): Window { throw new Error("cross-origin"); }, navigator: { language: "de-DE" } } as unknown as Window;
expect(requestedLanguage(foreign)).toBe("de-DE");
frame.remove();
});
it("sets the page's own language, and keeps English for a language it doesn't have", async () => {
addLanguage("nl", { _meta: meta("one_other"), editor: { common: { close: "Sluiten" } } });
expect(await setEditorLanguage("nl")).toBe("nl");
expect(document.documentElement.lang).toBe("nl");
expect(t("editor.common.close")).toBe("Sluiten");
// A text Dutch doesn't have yet shows in English.
expect(t("editor.common.cancel")).toBe("Cancel");
expect(await setEditorLanguage("xx")).toBe("en");
expect(document.documentElement.lang).toBe("en");
expect(await loadLanguage("xx")).toBe(false);
});
});
describe("plural forms", () => {
it("follow the rules of the screens", () => {
const forms = (rule: string, numbers: number[]) => numbers.map(PLURAL_RULES[rule]);
expect(forms("one_other", [0, 1, 2, 11])).toEqual([1, 0, 1, 1]);
expect(forms("one_upto_1", [0, 1, 2, 11])).toEqual([0, 0, 1, 1]);
expect(forms("slavic_pl", [1, 2, 4, 5, 12, 14, 21, 22, 25, 102])).toEqual([0, 1, 1, 2, 2, 2, 2, 1, 2, 1]);
expect(forms("east_slavic", [1, 2, 5, 11, 12, 21, 22, 25, 111])).toEqual([0, 1, 2, 2, 2, 0, 1, 2, 2]);
expect(forms("none", [0, 1, 2, 5])).toEqual([0, 0, 0, 0]);
});
it("pick the form of a text by its language's rule, with {n}, and the last form when a text has fewer", () => {
expect(t("editor.library.entities", 1)).toBe("1 entity");
expect(t("editor.library.entities", 5)).toBe("5 entities");
addLanguage("pl", {
_meta: meta("slavic_pl"),
editor: { library: { entities: "{n} encja | {n} encje | {n} encji" }, common: { results: "{n} wynik | {n} wyników" } },
});
const pl = (key: string, n: number) => t(key, n, { locale: "pl" });
expect([1, 3, 5, 22].map((n) => pl("editor.library.entities", n))).toEqual(["1 encja", "3 encje", "5 encji", "22 encje"]);
expect(pl("editor.common.results", 3)).toBe("3 wyników");
// Chinese has one form: every number takes it.
addLanguage("zh-Hans", { _meta: meta("none"), editor: { library: { entities: "{n} 个实体" } } });
expect(t("editor.library.entities", 7, { locale: "zh-Hans" })).toBe("7 个实体");
});
it("leave out a text that is empty or that vue-i18n can't read, and show English for it", () => {
addLanguage("fr", {
_meta: meta("one_upto_1"),
editor: { common: { close: "Fermer", cancel: " ", back: "Retour @ la page" }, library: { entities: "{n} entité | {n} entités" } },
});
const fr = (key: string, n?: number) => (n === undefined ? t(key, {}, { locale: "fr" }) : t(key, n, { locale: "fr" }));
expect(fr("editor.common.close")).toBe("Fermer");
expect(fr("editor.common.cancel")).toBe("Cancel");
expect(fr("editor.common.back")).toBe("← Back");
expect([0, 1, 2].map((n) => fr("editor.library.entities", n))).toEqual(["0 entité", "1 entité", "2 entités"]);
});
});
describe("the add-on hears the editor's language", () => {
it("in a header on every request", async () => {
const fetch = vi.fn(async () => new Response("", { status: 200 }));
vi.stubGlobal("fetch", fetch);
await send("screens/x/identify", "POST");
addLanguage("nl", { _meta: meta("one_other"), editor: {} });
i18n.global.locale.value = "nl";
await send("inventory", "GET");
const headers = fetch.mock.calls.map((call: any[]) => call[1].headers["X-ESP-Screens-Language"]);
expect(headers).toEqual(["en", "nl"]);
});
it("and a failed answer without the add-on's own words says so in the editor's language", async () => {
addLanguage("nl", { _meta: meta("one_other"), editor: { api: { failed: "Dat lukte niet." } } });
i18n.global.locale.value = "nl";
vi.stubGlobal("fetch", vi.fn(async () => new Response("<html>502</html>", { status: 502 })));
await expect(send("screens/x", "PUT", {})).rejects.toThrow("Dat lukte niet.");
});
});
describe("the mockup speaks the screens' language", () => {
it("draws the screens' words in their language while the editor keeps its own", async () => {
addLanguage("de", {
_meta: meta("one_other"),
editor: {
mockup: {
unavailable: "Nicht verfügbar", page_link: "Seite {page} ›",
time: { hours_ago: "Vor 1 Stunde | Vor {n} Stunden", just_now: "Gerade eben" },
date: { weekdays_min: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], top_bar: "{weekday}., {day}. {month}" },
},
tile_card: { remove: "Kachel entfernen" },
},
});
state.inventory = { screens: [], entities: [{ id: "light.b", name: "Lamp B", state: "unavailable" }], language: { setting: "de", effective: "de", ha: "en", languages: [] } } as any;
state.layout = { title: "Living room", tiles: [] };
state.liveStates = {};
await Promise.resolve();
const gone = { entity: "light.b", name: "", slot: 0 };
state.layout.tiles.push(gone);
const card = mount(TileCard, { props: { tile: gone, slot: 0 } });
expect(card.find(".st").text()).toBe("Nicht verfügbar");
// The editor's own words stay in the editor's language.
expect(card.find(".remove").attributes("title")).toBe("Remove tile");
const page = { entity: "screen.page_2", name: "Go to page 2", slot: 1 };
state.layout.tiles.push(page);
expect(mount(TileCard, { props: { tile: page, slot: 1 } }).find(".goto").text()).toBe("Seite 2 ›");
expect(agoText(1_000_000 - 7200, 1_000_000, "de")).toBe("Vor 2 Stunden");
expect(agoText(1_000_000 - 7200, 1_000_000)).toBe("2 hours ago");
expect(dateText(new Date(2026, 8, 15), "de")).toBe("Di., 15. Sept.");
// A word German doesn't have yet is English, as on the screen.
expect(agoText(1_000_000 - 90000, 1_000_000, "de")).toBe("Yesterday");
});
});
describe("numbers, lists and the profile", () => {
it("writes numbers as the screens do", () => {
expect(["1249", "21.4", "-1234567.25", "0", "999"].map((n) => numberText(n, "point"))).toEqual(["1,249", "21.4", "-1,234,567.25", "0", "999"]);
expect(["1249", "21.4", "-1234567.25"].map((n) => numberText(n, "comma"))).toEqual(["1.249", "21,4", "-1.234.567,25"]);
expect(["1249", "21.4"].map((n) => numberText(n, "space"))).toEqual(["1 249", "21,4"]);
// Anything that isn't a plain number stays as it is.
expect(["on", "1e5", "12:30", ""].map((n) => numberText(n, "comma"))).toEqual(["on", "1e5", "12:30", ""]);
expect(numberText(1234.5, "comma")).toBe("1.234,5");
});
it("writes a list the way the language does, the English one without a comma before and", () => {
expect(andList([1, 2, 3])).toBe("1, 2 and 3");
expect(andList([2, 3], "nl")).toBe("2 en 3");
});
it("reads the clock and number format of the user's Home Assistant profile, where it names one", () => {
const frame = document.createElement("iframe");
document.body.append(frame);
const inner = frame.contentWindow!;
const ha = document.createElement("home-assistant") as any;
document.body.append(ha);
ha.hass = { locale: { language: "nl", time_format: "24", number_format: "decimal_comma" } };
expect(haProfile(inner)).toEqual({ clock: "24", numbers: "comma" });
ha.hass = { locale: { time_format: "12", number_format: "comma_decimal" } };
expect(haProfile(inner)).toEqual({ clock: "12", numbers: "point" });
ha.hass = { locale: { time_format: "language", number_format: "space_comma" } };
expect(haProfile(inner)).toEqual({ numbers: "space" });
ha.hass = { locale: { time_format: "system", number_format: "none" } };
expect(haProfile(inner)).toEqual({});
// Outside Home Assistant's page there is no profile.
expect(haProfile(window)).toEqual({});
ha.remove();
frame.remove();
});
});
+147
View File
@@ -0,0 +1,147 @@
// Language & region (app 0.2.90): one place for every screen's language, clock and numbers, the update it takes, and
// the top bar that points there.
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSettingsView from "../src/components/AppSettingsView.vue";
import Sidebar from "../src/components/Sidebar.vue";
import TopbarInspector from "../src/components/TopbarInspector.vue";
import { state } from "../src/store";
import type { Languages } from "../src/types";
const GUIDE = "https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/TRANSLATING.md";
function language(extra: Partial<Languages> = {}): Languages {
return {
setting: "auto", effective: "nl", ha: "nl", clock: "auto", clock_effective: "24", numbers: "auto", numbers_effective: "comma",
languages: [
{ code: "en", name: "English (US)", english: "English (US)", checked: true },
{ code: "en-GB", name: "English (UK)", english: "English (UK)", checked: true },
{ code: "nl", name: "Nederlands", english: "Dutch", checked: true },
{ code: "de", name: "Deutsch", english: "German", checked: false },
],
...extra,
};
}
// The add-on: PUT api/language answers with the new state, or refuses; the inventory keeps what it has.
function addOn(answer: (body: any) => Response) {
const fetch = vi.fn(async (url: string, options: RequestInit = {}) => {
if (url === "api/language") return answer(JSON.parse(String(options.body)));
if (url.startsWith("api/inventory")) return new Response(JSON.stringify(state.inventory), { status: 200 });
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetch);
return fetch;
}
const options = (view: ReturnType<typeof mount>, id: string) => view.findAll(`#${id} option`).map((o) => o.text());
beforeEach(() => {
vi.unstubAllGlobals();
state.inventory = { screens: [], entities: [], updates: { target: "0.2.80", pending: 0 }, language: language() } as any;
state.toast = null;
});
describe("the Language & region card", () => {
it("is missing while the add-on has no languages", () => {
delete state.inventory.language;
expect(mount(AppSettingsView).find("#language").exists()).toBe(false);
});
it("offers Home Assistant's language by its own name, every language, and marks the unchecked ones", () => {
const view = mount(AppSettingsView);
expect(view.find("#language h2").text()).toBe("Language & region");
expect(options(view, "screen-language")).toEqual([
"Home Assistant's language (Nederlands)", "English (US)", "English (UK)", "Nederlands", "Deutsch · not checked yet, help welcome",
]);
expect((view.find("#screen-language").element as HTMLSelectElement).value).toBe("auto");
expect(view.find("#language-ha-missing").exists()).toBe(false);
expect(view.find("#language-help").exists()).toBe(false);
expect(view.find("#language").text()).toContain("The editor itself follows the language of your Home Assistant profile.");
expect(options(view, "time-format")).toEqual(["Automatic, follows the language (24 hour)", "24 hour", "12 hour"]);
expect(options(view, "number-format")).toEqual(["Automatic, follows the language (1.234,5)", "1,234.5", "1.234,5", "1 234,5"]);
});
it("says when Home Assistant's language isn't there yet, and asks for help with an unchecked one", () => {
state.inventory.language = language({ ha: "fy", effective: "en" });
let view = mount(AppSettingsView);
expect(options(view, "screen-language")[0]).toBe("Home Assistant's language");
expect(view.find("#language-ha-missing").text()).toBe("The screens use English until Home Assistant's language is available.");
// A regional variant takes its base language, as the editor does.
state.inventory.language = language({ ha: "nl-BE" });
expect(options(mount(AppSettingsView), "screen-language")[0]).toBe("Home Assistant's language (Nederlands)");
state.inventory.language = language({ setting: "de", effective: "de" });
view = mount(AppSettingsView);
expect(view.find("#language-help").text()).toBe("Deutsch is not checked yet, help welcome: how to translate");
expect(view.find("#language-help a").attributes("href")).toBe(GUIDE);
});
it("saves a choice, and every screen takes the language with its next update", async () => {
const fetch = addOn((body) => new Response(JSON.stringify({ language: language({ setting: body.setting, effective: body.setting }) }), { status: 200 }));
const view = mount(AppSettingsView);
await view.find("#screen-language").setValue("de");
await flushPromises();
const put = fetch.mock.calls.find(([url]) => url === "api/language")!;
expect(put[1]!.method).toBe("PUT");
expect(JSON.parse(String(put[1]!.body))).toEqual({ setting: "de" });
expect(state.inventory.language!.setting).toBe("de");
expect(state.toast?.message).toBe("Saved. Every screen takes the new language with its next update.");
// The screens' update state comes with the inventory again.
expect(fetch.mock.calls.some(([url]) => url === "api/inventory")).toBe(true);
await view.find("#time-format").setValue("12");
await flushPromises();
expect(JSON.parse(String(fetch.mock.calls.filter(([url]) => url === "api/language").pop()![1]!.body))).toEqual({ clock: "12" });
expect(state.toast?.message).toBe("Saved.");
});
it("shows the add-on's refusal and keeps the stored choice", async () => {
addOn(() => new Response(JSON.stringify({ error: "That language isn't there." }), { status: 400 }));
const view = mount(AppSettingsView);
await view.find("#number-format").setValue("space");
await flushPromises();
expect(state.toast?.message).toBe("That language isn't there.");
expect((view.find("#number-format").element as HTMLSelectElement).value).toBe("auto");
expect(state.inventory.language!.numbers).toBe("auto");
});
it("offers the clock and numbers of the user's Home Assistant profile when they differ", async () => {
const hass = { locale: { language: "nl", time_format: "12", number_format: "comma_decimal" } };
const parent = { document: { documentElement: { lang: "nl" }, querySelector: (tag: string) => (tag === "home-assistant" ? { hass } : null) } };
vi.stubGlobal("parent", parent);
const fetch = addOn((body) => new Response(JSON.stringify({ language: language({ clock: body.clock, clock_effective: body.clock, numbers: body.numbers, numbers_effective: body.numbers }) }), { status: 200 }));
let view = mount(AppSettingsView);
expect(view.find("#language-profile small").text()).toBe("Your Home Assistant profile: 12 hour · 1,234.5");
await view.find("#language-profile button").trigger("click");
await flushPromises();
expect(JSON.parse(String(fetch.mock.calls.find(([url]) => url === "api/language")![1]!.body))).toEqual({ clock: "12", numbers: "point" });
expect(view.find("#language-profile").exists()).toBe(false);
// A profile that follows the language names nothing.
hass.locale = { language: "nl", time_format: "language", number_format: "language" };
view = mount(AppSettingsView);
expect(view.find("#language-profile").exists()).toBe(false);
});
});
describe("the update a new language takes", () => {
const screen = (update: object, firmware = "0.2.80") => ({ id: "living", name: "Living room", online: true, firmware, layout: { title: "", tiles: [] }, update } as any);
it("says the new language when the firmware stays the same", () => {
state.inventory.screens = [screen({ language: true, target: "0.2.80", profile: "living.yaml", host: "10.0.0.2" })];
const sidebar = mount(Sidebar);
expect(sidebar.find(".pill").text()).toBe("Update");
expect(sidebar.find(".screen-update small").text()).toBe("New language: Nederlands");
expect(sidebar.find(".whatsnew").exists()).toBe(false);
});
it("puts it first in What's new when the firmware is new too", () => {
state.inventory.screens = [screen({ available: true, language: true, target: "0.2.81", profile: "living.yaml" })];
state.inventory.updates = { target: "0.2.81", pending: 1 };
state.inventory.changelog = [{ app: "0.2.91", firmware: "0.2.81", lines: ["Faster."] }];
const sidebar = mount(Sidebar);
expect(sidebar.find(".screen-update small").text()).toBe("Update 0.2.81");
expect(sidebar.findAll(".whatsnew li").map((li) => li.text())).toEqual(["New language: Nederlands", "Faster."]);
});
});
describe("the top bar's clock", () => {
it("points to Language & region instead of a clock of its own", () => {
state.layout = { title: "Living room", tiles: [], header: { items: [{ type: "clock" }] } };
let drawer = mount(TopbarInspector, { props: { index: 0 } });
expect(drawer.find(".seg").exists()).toBe(false);
expect(drawer.find("#topbar-clock").text()).toBe("24 hour clock · change under Settings → Language & region");
expect(drawer.find("#topbar-clock a").attributes("href")).toBe("#settings");
state.inventory.language = language({ clock_effective: "12" });
drawer = mount(TopbarInspector, { props: { index: 0 } });
expect(drawer.find("#topbar-clock").text()).toBe("12 hour clock · change under Settings → Language & region");
});
});
+5
View File
@@ -0,0 +1,5 @@
// Every mounted component gets the editor's texts, as main.ts gives the page (app 0.2.90).
import { config } from "@vue/test-utils";
import { i18n } from "../src/i18n";
config.global.plugins = [i18n];
+3 -2
View File
@@ -1,7 +1,7 @@
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, PHASES, removeTile,
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, phaseText, removeTile,
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
} from "../src/store";
import type { Inventory, Screen } from "../src/types";
@@ -141,7 +141,8 @@ describe("updates with content", () => {
expect(updateProgress(living)).toBeNull();
living.update!.state = "running";
living.update!.phase = "install";
expect(updateProgress(living)).toEqual({ percent: 12, text: PHASES.install });
expect(updateProgress(living)).toEqual({ percent: 12, text: phaseText("install") });
expect(phaseText("install")).toBe("Building and installing…");
state.firmwareJob = { job: { stage: "compile" }, logs: [] };
expect(updateProgress(living)!.percent).toBe(40);
state.firmwareJob = { job: { stage: "upload" }, logs: [] };