mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
refactor: centralize i18n language registry
Issue: #62 User-Visible: no
This commit is contained in:
+31
-2
@@ -9,6 +9,35 @@ User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Translations
|
||||
|
||||
A shipped UI language has three matching parts:
|
||||
|
||||
1. `src/i18n/<code>.json` for the card;
|
||||
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
|
||||
integration;
|
||||
3. one static entry (dictionary import, code and native label) in
|
||||
`src/i18n/registry.ts`.
|
||||
|
||||
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
|
||||
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
|
||||
case-insensitive and also accepts `_` from legacy locale sources.
|
||||
|
||||
The registry drives language resolution, the visual-editor selector and parity
|
||||
tests. The tests reject missing or extra locale files; frontend dictionaries
|
||||
also fail on mismatched keys, empty values and changed placeholders.
|
||||
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
|
||||
or remove them.
|
||||
|
||||
The current `subst()` helper does not implement plural rules. Phrase strings so
|
||||
their grammar does not depend on the numeric value (for example, use a neutral
|
||||
label followed by `{n}` rather than an English singular/plural pair).
|
||||
|
||||
Adding a UI locale does not automatically create another full documentation
|
||||
set; maintain the existing English and Russian documentation according to the
|
||||
project's normal rules. Right-to-left layout is a separate product project,
|
||||
because the plan canvas and editors cannot be mirrored by translations alone.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
@@ -45,8 +74,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
|
||||
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
|
||||
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
|
||||
- Every UI string goes through `src/i18n/<lang>.json`; follow the
|
||||
[Translations](#translations) flow for registry and backend parity.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+140
-140
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceFingerprint": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"imageSha256": "827a5f0cd6f1c122a63a51d83569a2dbd87b70b3753266a3eb0a9f2986b1b911"
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
|
||||
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
|
||||
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,10 @@ option с этим кодом и не меняет config при редакти
|
||||
|
||||
Gate проверяет:
|
||||
|
||||
- коды уникальны, lowercase и нормализованы;
|
||||
- коды являются BCP 47 tags и уникальны после case-insensitive нормализации
|
||||
(`_` считается эквивалентом `-`); canonical spelling registry сохраняется в
|
||||
selector и точных именах frontend/backend файлов (`pt-BR`, а не принудительно
|
||||
`pt-br`);
|
||||
- `en` существует и является fallback;
|
||||
- у каждой записи есть непустые `nativeLabel` и dictionary;
|
||||
- каждый `src/i18n/<code>.json` зарегистрирован и у каждой записи есть ровно
|
||||
@@ -230,7 +233,8 @@ Schema и persisted config не меняются. Миграции нет.
|
||||
|
||||
### Unit
|
||||
|
||||
- registry uniqueness, code format, English presence/fallback;
|
||||
- registry uniqueness после нормализации, BCP 47 code format, English
|
||||
presence/fallback;
|
||||
- exact/primary/fallback locale matrix, case and `_` normalization;
|
||||
- invalid explicit config;
|
||||
- registry-driven dictionary/file/backend parity;
|
||||
|
||||
+2
-5
@@ -1,6 +1,7 @@
|
||||
/** Card configuration editor (Lovelace GUI). */
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { langOf, t, type Lang } from './i18n';
|
||||
import { languageOptions } from './i18n/registry';
|
||||
import { invalidDefaultFloor } from './card-editor-validation';
|
||||
|
||||
class HouseplanCardEditor extends LitElement {
|
||||
@@ -97,11 +98,7 @@ class HouseplanCardEditor extends LitElement {
|
||||
selector: {
|
||||
select: {
|
||||
mode: 'dropdown',
|
||||
options: [
|
||||
{ value: '', label: t(L, 'editor.lang_auto') },
|
||||
{ value: 'en', label: t(L, 'editor.lang_en') },
|
||||
{ value: 'ru', label: t(L, 'editor.lang_ru') },
|
||||
],
|
||||
options: languageOptions(t(L, 'editor.lang_auto'), this._config?.language),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+23
-15
@@ -1,34 +1,42 @@
|
||||
/**
|
||||
* Card UI localization. Dictionaries live in src/i18n/<lang>.json so that new
|
||||
* languages can be contributed without touching TypeScript. The language is
|
||||
* resolved from the card config (`language: en|ru`) or, by default, from the
|
||||
* HA user profile (hass.locale.language); anything that is not a known
|
||||
* language falls back to English.
|
||||
* Card UI localization. Shipped languages live in the typed registry; see the
|
||||
* translation contribution flow in CONTRIBUTING.md. The language is resolved
|
||||
* from the card config or, by default, from the HA user profile. Unknown
|
||||
* languages fall back to English synchronously.
|
||||
*/
|
||||
import { subst } from './logic';
|
||||
import en from './i18n/en.json';
|
||||
import ru from './i18n/ru.json';
|
||||
import {
|
||||
FALLBACK_DICTIONARY,
|
||||
FALLBACK_LANGUAGE_CODE,
|
||||
LANGUAGE_REGISTRY,
|
||||
languageEntry,
|
||||
resolveLanguageCode,
|
||||
type Lang,
|
||||
} from './i18n/registry';
|
||||
|
||||
export type Lang = 'en' | 'ru';
|
||||
type Key = keyof typeof en;
|
||||
type Key = keyof typeof FALLBACK_DICTIONARY;
|
||||
|
||||
const DICTS: Record<Lang, Record<string, string>> = { en, ru };
|
||||
export type { Lang } from './i18n/registry';
|
||||
|
||||
/** Resolve the UI language: explicit config option wins, then the HA profile. */
|
||||
export function langOf(hass: any, configLang?: string | null): Lang {
|
||||
if (configLang && configLang in DICTS) return configLang as Lang;
|
||||
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
|
||||
return l.startsWith('ru') ? 'ru' : 'en';
|
||||
return resolveLanguageCode(
|
||||
configLang,
|
||||
hass?.locale?.language || hass?.language,
|
||||
LANGUAGE_REGISTRY.map(({ code }) => code),
|
||||
FALLBACK_LANGUAGE_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
/** Translate a key with optional {placeholder} substitution. */
|
||||
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
|
||||
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
|
||||
const dictionary = languageEntry(lang)?.dictionary;
|
||||
return subst(dictionary?.[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
|
||||
}
|
||||
|
||||
/** Whether a localized value exists and contains useful text after fallback. */
|
||||
export function hasTranslation(lang: Lang, key: string): boolean {
|
||||
const value = DICTS[lang][key] ?? DICTS.en[key];
|
||||
const value = languageEntry(lang)?.dictionary[key] ?? FALLBACK_DICTIONARY[key];
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import en from './en.json' with { type: 'json' };
|
||||
import ru from './ru.json' with { type: 'json' };
|
||||
|
||||
export interface LanguageEntry {
|
||||
code: string;
|
||||
nativeLabel: string;
|
||||
dictionary: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single runtime registry of shipped UI languages.
|
||||
*
|
||||
* Keep English first: it is the synchronous fallback. Adding a locale means
|
||||
* adding its frontend/backend JSON files and one static entry in this module.
|
||||
*/
|
||||
export const LANGUAGE_REGISTRY = [
|
||||
{ code: 'en', nativeLabel: 'English', dictionary: en },
|
||||
{ code: 'ru', nativeLabel: 'Русский', dictionary: ru },
|
||||
] as const satisfies readonly LanguageEntry[];
|
||||
|
||||
export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
|
||||
|
||||
export const FALLBACK_LANGUAGE_CODE: Lang = 'en';
|
||||
export const FALLBACK_DICTIONARY = en;
|
||||
|
||||
const LANGUAGE_BY_CODE = new Map<string, LanguageEntry>(
|
||||
LANGUAGE_REGISTRY.map((entry) => [entry.code, entry]),
|
||||
);
|
||||
|
||||
/** Normalize HA locale tags for registry lookup. */
|
||||
export function normalizeLanguageTag(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value.trim().replaceAll('_', '-').toLowerCase()
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Return the canonical registry entry for a locale code, if it is shipped. */
|
||||
export function languageEntry(value: unknown): LanguageEntry | undefined {
|
||||
return LANGUAGE_BY_CODE.get(normalizeLanguageTag(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an explicit setting and HA locale against any registry-shaped code
|
||||
* list. The generic form keeps the fallback rules testable with future-locale
|
||||
* fixtures without registering a fake production dictionary.
|
||||
*/
|
||||
export function resolveLanguageCode<T extends string>(
|
||||
explicitLanguage: unknown,
|
||||
haLanguage: unknown,
|
||||
supportedCodes: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
const supported = new Map<string, T>(
|
||||
supportedCodes.map((code) => [normalizeLanguageTag(code), code]),
|
||||
);
|
||||
const explicit = supported.get(normalizeLanguageTag(explicitLanguage));
|
||||
if (explicit) return explicit;
|
||||
|
||||
const locale = normalizeLanguageTag(haLanguage);
|
||||
const exact = supported.get(locale);
|
||||
if (exact) return exact;
|
||||
const primary = supported.get(locale.split('-')[0] || '');
|
||||
return primary ?? fallback;
|
||||
}
|
||||
|
||||
export interface LanguageOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Build visual-editor options and preserve an unknown persisted raw value. */
|
||||
export function languageOptions(
|
||||
autoLabel: string,
|
||||
currentLanguage?: unknown,
|
||||
): LanguageOption[] {
|
||||
const options: LanguageOption[] = [
|
||||
{ value: '', label: autoLabel },
|
||||
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({
|
||||
value: code,
|
||||
label: nativeLabel,
|
||||
})),
|
||||
];
|
||||
const raw = typeof currentLanguage === 'string' ? currentLanguage : '';
|
||||
if (raw && !options.some((option) => option.value === raw)) {
|
||||
options.push({ value: raw, label: languageEntry(raw)?.nativeLabel ?? raw });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
+2
-1
@@ -273,7 +273,8 @@ export interface CardConfig {
|
||||
show_temperature?: boolean;
|
||||
live_states?: boolean;
|
||||
show_signal?: boolean;
|
||||
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
|
||||
/** Registry language code, or empty/absent for the Home Assistant profile. */
|
||||
language?: string;
|
||||
/** @deprecated Ignored since v1.38.1 — per-marker `tap_action` only.
|
||||
* Kept so old YAML does not break; no runtime action reads this field. */
|
||||
tap_action?: string;
|
||||
|
||||
+104
-14
@@ -1,21 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
|
||||
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url)));
|
||||
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url)));
|
||||
import {
|
||||
FALLBACK_LANGUAGE_CODE,
|
||||
LANGUAGE_REGISTRY,
|
||||
languageOptions,
|
||||
normalizeLanguageTag,
|
||||
resolveLanguageCode,
|
||||
} from '../test-build/i18n/registry.js';
|
||||
import { langOf } from '../test-build/i18n.js';
|
||||
|
||||
const dictionaries = new Map(
|
||||
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
|
||||
);
|
||||
const en = dictionaries.get('en');
|
||||
const ru = dictionaries.get('ru');
|
||||
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('i18n: en and ru dictionaries carry the same key set', () => {
|
||||
test('i18n: registry codes and English fallback are valid', () => {
|
||||
const codes = LANGUAGE_REGISTRY.map(({ code }) => code);
|
||||
const normalizedCodes = codes.map(normalizeLanguageTag);
|
||||
assert.equal(FALLBACK_LANGUAGE_CODE, 'en');
|
||||
assert.ok(dictionaries.has(FALLBACK_LANGUAGE_CODE));
|
||||
assert.equal(new Set(codes).size, codes.length, 'registry codes must be unique');
|
||||
assert.equal(
|
||||
new Set(normalizedCodes).size,
|
||||
normalizedCodes.length,
|
||||
'registry codes must be unique after locale normalization',
|
||||
);
|
||||
for (const { code, nativeLabel, dictionary } of LANGUAGE_REGISTRY) {
|
||||
assert.equal(code, code.trim(), `${code} has surrounding whitespace`);
|
||||
assert.doesNotMatch(code, /_/u, `${code} must use BCP 47 hyphens`);
|
||||
assert.match(code, /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u);
|
||||
assert.ok(nativeLabel.trim(), `${code} has no native label`);
|
||||
assert.ok(dictionary && typeof dictionary === 'object', `${code} has no dictionary`);
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: registry matches frontend and backend locale files', () => {
|
||||
const registryCodes = LANGUAGE_REGISTRY.map(({ code }) => code).sort();
|
||||
const localeCodes = (url) => readdirSync(url)
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.map((name) => name.slice(0, -'.json'.length))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
localeCodes(new URL('../src/i18n/', import.meta.url)),
|
||||
registryCodes,
|
||||
'frontend locale files and registry differ',
|
||||
);
|
||||
assert.deepEqual(
|
||||
localeCodes(new URL('../custom_components/houseplan/translations/', import.meta.url)),
|
||||
registryCodes,
|
||||
'backend locale files and registry differ',
|
||||
);
|
||||
});
|
||||
|
||||
test('i18n: every registered dictionary carries the English key set', () => {
|
||||
const enKeys = Object.keys(en).sort();
|
||||
const ruKeys = Object.keys(ru).sort();
|
||||
assert.deepEqual(enKeys, ruKeys);
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
assert.deepEqual(Object.keys(dictionary).sort(), enKeys, `${code} key set differs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: no empty values', () => {
|
||||
for (const [lang, d] of [['en', en], ['ru', ru]]) {
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
assert.ok(typeof v === 'string' && v.length > 0, `${lang}:${k} is empty`);
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
for (const [k, v] of Object.entries(dictionary)) {
|
||||
assert.ok(typeof v === 'string' && v.length > 0, `${code}:${k} is empty`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -23,10 +74,49 @@ test('i18n: no empty values', () => {
|
||||
test('i18n: placeholders match between languages', () => {
|
||||
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
|
||||
for (const k of Object.keys(en)) {
|
||||
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
assert.equal(ph(en[k]), ph(dictionary[k]), `placeholder mismatch in ${code}:${k}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
|
||||
const supported = ['en', 'ru', 'pt-BR'];
|
||||
const cases = [
|
||||
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
|
||||
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
|
||||
{ explicit: 'unknown', ha: 'ru_RU', expected: 'ru' },
|
||||
{ explicit: '', ha: 'pt-br', expected: 'pt-BR' },
|
||||
{ explicit: null, ha: 'ru-RU', expected: 'ru' },
|
||||
{ explicit: 'ru-RU', ha: 'en-GB', expected: 'en' },
|
||||
{ explicit: undefined, ha: 'pt-PT', expected: 'en' },
|
||||
{ explicit: undefined, ha: 'de-DE', expected: 'en' },
|
||||
];
|
||||
for (const { explicit, ha, expected } of cases) {
|
||||
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: langOf wires card config and both HA locale shapes to the registry', () => {
|
||||
assert.equal(langOf({ locale: { language: 'ru_RU' }, language: 'en' }), 'ru');
|
||||
assert.equal(langOf({ locale: { language: 'en-GB' }, language: 'ru' }, 'RU'), 'ru');
|
||||
assert.equal(langOf({ language: 'ru-RU' }, 'unknown'), 'ru');
|
||||
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'en');
|
||||
});
|
||||
|
||||
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
|
||||
assert.deepEqual(languageOptions('Auto'), [
|
||||
{ value: '', label: 'Auto' },
|
||||
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({ value: code, label: nativeLabel })),
|
||||
]);
|
||||
assert.deepEqual(languageOptions('Auto', 'ru'), languageOptions('Auto'));
|
||||
assert.deepEqual(languageOptions('Auto', 'de').at(-1), { value: 'de', label: 'de' });
|
||||
assert.deepEqual(
|
||||
languageOptions('Auto', ' RU ').at(-1),
|
||||
{ value: ' RU ', label: 'Русский' },
|
||||
);
|
||||
});
|
||||
|
||||
test('issue 251 unavailable controls toast has exact singular and plural copy', () => {
|
||||
assert.equal(
|
||||
en['toast.toggle_target_unavailable'],
|
||||
@@ -139,9 +229,9 @@ test('i18n: every literal help call has body and full aria keys in both language
|
||||
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
|
||||
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
|
||||
for (const key of helpKeys) {
|
||||
for (const [lang, dictionary] of [['en', en], ['ru', ru]]) {
|
||||
assert.ok(dictionary[key]?.trim(), `${lang}:${key} is missing or empty`);
|
||||
assert.ok(dictionary[`${key}.aria`]?.trim(), `${lang}:${key}.aria is missing or empty`);
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
assert.ok(dictionary[key]?.trim(), `${code}:${key} is missing or empty`);
|
||||
assert.ok(dictionary[`${key}.aria`]?.trim(), `${code}:${key}.aria is missing or empty`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -171,7 +261,7 @@ test('help hosts do not duplicate their explanation in a title attribute', () =>
|
||||
});
|
||||
|
||||
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
|
||||
for (const [lang, d] of [['en', en], ['ru', ru]]) {
|
||||
for (const { code: lang, dictionary: d } of LANGUAGE_REGISTRY) {
|
||||
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
|
||||
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"tsBuildInfoFile": "test-build/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
|
||||
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
|
||||
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
|
||||
"src/ha-binding-status.ts",
|
||||
"src/integration-provider.ts", "src/vacuum.ts",
|
||||
|
||||
Reference in New Issue
Block a user