mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: the locale runtime the card ships is the code the tests prove (#354)
The production LANGUAGE_RUNTIME was a handwritten twin of the tested LanguageRuntime class (germanDictionary/Pending/Failed): equivalent on the day it was written, invisible to every i18n-runtime test afterwards. The registry now exports one page-scoped `new LanguageRuntime(LANGUAGE_REGISTRY, …)` instance — the whole existing suite starts proving the object production actually runs, and a contract unit (instanceof + source free of the old field names) keeps the duplicate from returning. The class gains an optional `loadFailed(code)` hook — fired once when a dictionary load settles into English fallback — and the registry fans it out through `subscribeLanguageLoadFailures`. Only the View card subscribes (it alone owns toast infrastructure): a failed language pack now shows the new `toast.locale_load_failed` message (en/ru/de) instead of a console-only warning; space card and both GUI editors keep the console warning as before. Proofs: contract unit, hook unit, subscription unit; smoke_german_locale extended — the both-attempts-failed scenario now asserts the visible toast; two new registry mutants (handwritten-twin returns, toast dropped). Issue: #354 User-Visible: yes
This commit is contained in:
+11
-1
@@ -232,7 +232,7 @@ import {
|
||||
} from './coordinate-canonicalization';
|
||||
import { enqueueSerializedWrite } from './serialized-write-queue';
|
||||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||
import { LANGUAGE_RUNTIME } from './i18n/registry';
|
||||
import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry';
|
||||
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
|
||||
import { CommandStack } from './command-stack';
|
||||
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
||||
@@ -2189,6 +2189,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _continuity = this._newContinuityController();
|
||||
private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = [];
|
||||
private _continuityUnsub?: () => void;
|
||||
private _languageFailureUnsub?: () => void;
|
||||
private _continuityEpoch = 0;
|
||||
private _continuityDataReady = true;
|
||||
private _continuityPaintToken = -1;
|
||||
@@ -2474,6 +2475,13 @@ export class HouseplanCard extends LitElement {
|
||||
if (resolvedBlend !== undefined) this._glowScreenBlend = resolvedBlend;
|
||||
this._continuityUnsub?.();
|
||||
this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility);
|
||||
// #354: only the View card owns toast infrastructure, so it alone turns a
|
||||
// locale-load failure into a visible message; other runtime surfaces keep
|
||||
// the console warning from the shared LanguageRuntime.
|
||||
this._languageFailureUnsub?.();
|
||||
this._languageFailureUnsub = subscribeLanguageLoadFailures(() => {
|
||||
this._showToast(this._t('toast.locale_load_failed'));
|
||||
});
|
||||
super.connectedCallback();
|
||||
this._pointerModality.connect(this.ownerDocument.defaultView);
|
||||
const PointerHoverObserver = this.ownerDocument.defaultView?.MutationObserver;
|
||||
@@ -2549,6 +2557,8 @@ export class HouseplanCard extends LitElement {
|
||||
window.removeEventListener('popstate', this._onLocationChanged);
|
||||
this._continuityUnsub?.();
|
||||
this._continuityUnsub = undefined;
|
||||
this._languageFailureUnsub?.();
|
||||
this._languageFailureUnsub = undefined;
|
||||
this._motionMedia?.removeEventListener?.('change', this._onMotionChange);
|
||||
this._motionMedia = undefined;
|
||||
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
|
||||
|
||||
@@ -622,6 +622,7 @@
|
||||
"room.label_scale": "Metrikgröße",
|
||||
"preview.room_name": "Wohnzimmer",
|
||||
"toast.cfg_reload_failed": "Der Plan konnte nicht vom Server neu geladen werden: {err}",
|
||||
"toast.locale_load_failed": "Das Sprachpaket konnte nicht geladen werden. Bis zum Neuladen der Seite wird Englisch verwendet.",
|
||||
"room.settings_short": "Raumeinstellungen",
|
||||
"room.unnamed": "Unbenannter Raum",
|
||||
"marker.use_climate_temp": "Geräte-Temperatur im Raum berücksichtigen",
|
||||
|
||||
@@ -622,6 +622,7 @@
|
||||
"room.label_scale": "Metrics size",
|
||||
"preview.room_name": "Living room",
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
||||
"toast.locale_load_failed": "Could not load the language pack. English is used until the page reloads.",
|
||||
"room.settings_short": "Room settings",
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.use_climate_temp": "Include the device temperature in the room",
|
||||
|
||||
@@ -37,6 +37,8 @@ export class LanguageRuntime {
|
||||
private readonly entries: readonly RuntimeLanguageEntry[],
|
||||
private readonly expectedFingerprint: string,
|
||||
private readonly warn: (message: string, error: unknown) => void = console.warn,
|
||||
/** Optional #354 hook: a dictionary load settled into English fallback. */
|
||||
private readonly loadFailed?: (code: string) => void,
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
if (entry.dictionary) this.dictionaries.set(entry.code, entry.dictionary);
|
||||
@@ -84,6 +86,7 @@ export class LanguageRuntime {
|
||||
}
|
||||
this.failed.add(entry.code);
|
||||
this.warn(`[houseplan] unable to load ${entry.code} locale; using English`, lastError);
|
||||
this.loadFailed?.(entry.code);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-31
@@ -1,9 +1,10 @@
|
||||
import en from './en.json' with { type: 'json' };
|
||||
import ru from './ru.json' with { type: 'json' };
|
||||
import type {
|
||||
LanguageRuntimeContract,
|
||||
LazyLanguageModule,
|
||||
LocaleDictionary,
|
||||
import {
|
||||
LanguageRuntime,
|
||||
type LanguageRuntimeContract,
|
||||
type LazyLanguageModule,
|
||||
type LocaleDictionary,
|
||||
} from './language-runtime';
|
||||
|
||||
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
@@ -40,37 +41,35 @@ export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
|
||||
export const FALLBACK_LANGUAGE_CODE: Lang = 'en';
|
||||
export const FALLBACK_DICTIONARY = en;
|
||||
|
||||
/** One page-scoped cache: multiple cards share the same locale request. */
|
||||
let germanDictionary: LocaleDictionary | undefined;
|
||||
let germanPending: Promise<void> | undefined;
|
||||
let germanFailed = false;
|
||||
/**
|
||||
* #354: locale-load failures surface once through this page-scoped listener
|
||||
* list. Only the View card subscribes and toasts; every other runtime surface
|
||||
* (space card, both GUI editors) stays with the console warning below.
|
||||
*/
|
||||
const languageLoadFailureListeners = new Set<(code: string) => void>();
|
||||
|
||||
async function settleGerman(): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (const attempt of [0, 1] as const) {
|
||||
try {
|
||||
const loaded = await loadGerman(attempt);
|
||||
if (loaded.fingerprint !== BUILD_FINGERPRINT) throw new Error('locale fingerprint mismatch');
|
||||
germanDictionary = loaded.dictionary;
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
germanFailed = true;
|
||||
console.warn('[houseplan] unable to load de locale; using English', lastError);
|
||||
export function subscribeLanguageLoadFailures(
|
||||
listener: (code: string) => void,
|
||||
): () => void {
|
||||
languageLoadFailureListeners.add(listener);
|
||||
return () => languageLoadFailureListeners.delete(listener);
|
||||
}
|
||||
|
||||
export const LANGUAGE_RUNTIME: LanguageRuntimeContract = {
|
||||
state: (code) => code !== 'de' || germanDictionary
|
||||
? 'ready' : germanFailed ? 'fallback' : 'pending',
|
||||
dictionary: (code) => code === 'de'
|
||||
? germanDictionary : code === 'ru' ? ru : code === 'en' ? en : undefined,
|
||||
ensure: (code) => {
|
||||
if (code !== 'de' || germanDictionary || germanFailed) return Promise.resolve();
|
||||
return germanPending ??= settleGerman().finally(() => { germanPending = undefined; });
|
||||
/**
|
||||
* The production runtime IS the tested class (#354): the previous handwritten
|
||||
* object duplicated its logic, so the whole i18n-runtime test suite proved
|
||||
* properties of code the card never ran. One page-scoped instance is shared
|
||||
* by every card and editor; the warn hook keeps the console line and fans the
|
||||
* failure out to subscribers.
|
||||
*/
|
||||
export const LANGUAGE_RUNTIME: LanguageRuntimeContract = new LanguageRuntime(
|
||||
LANGUAGE_REGISTRY,
|
||||
BUILD_FINGERPRINT,
|
||||
console.warn,
|
||||
(code) => {
|
||||
for (const listener of languageLoadFailureListeners) listener(code);
|
||||
},
|
||||
};
|
||||
);
|
||||
|
||||
/** Return a loaded dictionary, falling back synchronously to English. */
|
||||
export function dictionaryFor(value: unknown): LocaleDictionary {
|
||||
|
||||
@@ -622,6 +622,7 @@
|
||||
"room.label_scale": "Размер подписей",
|
||||
"preview.room_name": "Гостиная",
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||
"toast.locale_load_failed": "Не удалось загрузить языковой пакет. До перезагрузки страницы используется английский.",
|
||||
"room.settings_short": "Настройки комнаты",
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.use_climate_temp": "Учитывать температуру устройства в комнате",
|
||||
|
||||
Reference in New Issue
Block a user