mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
- ru/de/fr трёх словарей пространств — девять ленивых чанков (по одному на пару «пространство × язык»), английский статически как слой отката; рантайм — тот же LanguageRuntime (две попытки, отпечаток сборки, fallback, общий тост отказа), src/i18n/namespace-language.ts. - Загрузчики рантаймов онбординга и редактора отдают поверхность в рендер только после того, как словарь текущего языка осел; гейт хоста учитывает словари загруженной на нём поверхности (смена языка на лету — ветка warm). Оверлей Zigbee ждёт topology до первой отрисовки. - bundle-manifest: граф lazyNamespaceLocaleFiles, девять retry-токенов со строгим счётом «ровно один». bundle-budget: потолок онбординга 28 500 (замер 27 470), редактор 245 400 → 228 900 (замер 227 869), проверки ленивости и владения по содержимому. - Смок smoke_lazy_admin_locale (AC4–AC7); регресс-смоки, переключавшие язык синхронно, ждут гейт вместо ослабления проверок; пять мутантов. - monolith-baseline bundleBytes 2 500 387 → 2 508 676: +10 чанков (rollup intro и export на каждый) и записи манифеста; gzip каждой поверхности при этом уменьшился. Issue: #627 User-Visible: no
252 lines
10 KiB
JavaScript
252 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
LanguageRuntime, languageRenderGate,
|
|
} from '../test-build/i18n/language-runtime.js';
|
|
|
|
const deferred = () => {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
test('lazy locale runtime deduplicates hosts and accepts the matching build', async () => {
|
|
const load = deferred();
|
|
let calls = 0;
|
|
const runtime = new LanguageRuntime([
|
|
{ code: 'en', dictionary: { save: 'Save' } },
|
|
{ code: 'de', loadDictionary: async () => { calls++; return load.promise; } },
|
|
], 'same-build');
|
|
|
|
const first = runtime.ensure('de');
|
|
const second = runtime.ensure('de');
|
|
assert.equal(first, second);
|
|
assert.equal(calls, 1);
|
|
assert.equal(runtime.state('de'), 'pending');
|
|
load.resolve({ dictionary: { save: 'Speichern' }, fingerprint: 'same-build' });
|
|
await first;
|
|
assert.equal(runtime.state('de'), 'ready');
|
|
assert.equal(runtime.dictionary('de').save, 'Speichern');
|
|
await runtime.ensure('de');
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test('lazy locale runtime retries once, warns once and settles on English fallback', async () => {
|
|
const attempts = [];
|
|
const warnings = [];
|
|
const runtime = new LanguageRuntime([
|
|
{
|
|
code: 'de',
|
|
loadDictionary: async (attempt) => {
|
|
attempts.push(attempt);
|
|
throw new Error(`failure ${attempt}`);
|
|
},
|
|
},
|
|
], 'build', (message, error) => warnings.push([message, error]));
|
|
|
|
await runtime.ensure('de');
|
|
assert.deepEqual(attempts, [0, 1]);
|
|
assert.equal(runtime.state('de'), 'fallback');
|
|
assert.equal(runtime.dictionary('de'), undefined);
|
|
assert.equal(warnings.length, 1);
|
|
await runtime.ensure('de');
|
|
assert.equal(warnings.length, 1);
|
|
assert.deepEqual(attempts, [0, 1]);
|
|
});
|
|
|
|
test('lazy locale runtime rejects a stale fingerprint before accepting retry', async () => {
|
|
const runtime = new LanguageRuntime([{
|
|
code: 'de',
|
|
loadDictionary: async (attempt) => ({
|
|
dictionary: { save: attempt ? 'Speichern' : 'Stale' },
|
|
fingerprint: attempt ? 'current' : 'old',
|
|
}),
|
|
}], 'current');
|
|
await runtime.ensure('de');
|
|
assert.equal(runtime.dictionary('de').save, 'Speichern');
|
|
});
|
|
|
|
class FakeHost {
|
|
controllers = [];
|
|
inert = false;
|
|
isConnected = true;
|
|
attrs = new Map();
|
|
updates = 0;
|
|
addController(controller) { this.controllers.push(controller); }
|
|
requestUpdate() { this.updates++; }
|
|
hasAttribute(name) { return this.attrs.has(name); }
|
|
setAttribute(name, value) { this.attrs.set(name, value); }
|
|
removeAttribute(name) { this.attrs.delete(name); }
|
|
}
|
|
|
|
test('locale render gate exposes cold/warm states, inertness and ignores disconnected hosts', async () => {
|
|
const load = deferred();
|
|
const runtime = new LanguageRuntime([
|
|
{ code: 'en', dictionary: {} },
|
|
{ code: 'de', loadDictionary: async () => load.promise },
|
|
], 'build');
|
|
const host = new FakeHost();
|
|
let language = 'en';
|
|
assert.equal(languageRenderGate(host, runtime, language), 'ready');
|
|
assert.equal(host.attrs.get('lang'), 'en');
|
|
language = 'de';
|
|
assert.equal(languageRenderGate(host, runtime, language), 'warm');
|
|
assert.equal(host.inert, true);
|
|
assert.equal(host.attrs.get('aria-busy'), 'true');
|
|
host.isConnected = false;
|
|
load.resolve({ dictionary: {}, fingerprint: 'build' });
|
|
await runtime.ensure('de');
|
|
await Promise.resolve();
|
|
assert.equal(host.updates, 0);
|
|
assert.equal(languageRenderGate(host, runtime, language), 'ready');
|
|
assert.equal(host.inert, false);
|
|
assert.equal(host.hasAttribute('aria-busy'), false);
|
|
assert.equal(host.attrs.get('lang'), 'de');
|
|
|
|
const coldLoad = deferred();
|
|
const coldRuntime = new LanguageRuntime([{
|
|
code: 'de', loadDictionary: async () => coldLoad.promise,
|
|
}], 'build');
|
|
const coldHost = new FakeHost();
|
|
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'cold');
|
|
coldLoad.resolve({ dictionary: {}, fingerprint: 'build' });
|
|
await coldRuntime.ensure('de');
|
|
await Promise.resolve();
|
|
assert.equal(coldHost.updates, 1);
|
|
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'ready');
|
|
assert.equal(coldHost.attrs.get('lang'), 'de');
|
|
});
|
|
|
|
test('locale render gate exposes the fallback language to assistive technology', async () => {
|
|
const runtime = new LanguageRuntime([{
|
|
code: 'de', loadDictionary: async () => { throw new Error('offline'); },
|
|
}], 'build', () => {});
|
|
const host = new FakeHost();
|
|
assert.equal(languageRenderGate(host, runtime, 'de'), 'cold');
|
|
await runtime.ensure('de');
|
|
assert.equal(languageRenderGate(host, runtime, 'de'), 'ready');
|
|
assert.equal(host.attrs.get('lang'), 'en');
|
|
});
|
|
|
|
test('the production runtime is the tested class, not a handwritten twin (#354)', async () => {
|
|
const registry = await import('../test-build/i18n/registry.js');
|
|
assert.ok(
|
|
registry.LANGUAGE_RUNTIME instanceof LanguageRuntime,
|
|
'LANGUAGE_RUNTIME must be an instance of the class this suite proves',
|
|
);
|
|
const { readFileSync } = await import('node:fs');
|
|
const source = readFileSync(new URL('../src/i18n/registry.ts', import.meta.url), 'utf8');
|
|
assert.doesNotMatch(
|
|
source,
|
|
/germanPending|germanFailed|settleGerman/,
|
|
'the handwritten duplicate must not return — tests would look past production again',
|
|
);
|
|
assert.match(
|
|
source,
|
|
/new LanguageRuntime\(\s*LANGUAGE_REGISTRY,\s*BUILD_FINGERPRINT,\s*console\.warn,\s*notifyLanguageLoadFailures,\s*\)/,
|
|
'the runtime must wire its loadFailed hook to the unit-proven delivery function',
|
|
);
|
|
});
|
|
|
|
test('a settled dictionary failure reaches the load-failed hook once (#354)', async () => {
|
|
const failures = [];
|
|
const warnings = [];
|
|
const runtime = new LanguageRuntime(
|
|
[
|
|
{ code: 'en', dictionary: { key: 'value' } },
|
|
{ code: 'de', loadDictionary: async () => { throw new Error('net'); } },
|
|
],
|
|
'fp',
|
|
(message, error) => warnings.push([message, String(error)]),
|
|
(code) => failures.push(code),
|
|
);
|
|
await runtime.ensure('de');
|
|
assert.deepEqual(failures, ['de']);
|
|
assert.equal(warnings.length, 1);
|
|
assert.equal(runtime.state('de'), 'fallback');
|
|
await runtime.ensure('de');
|
|
assert.deepEqual(failures, ['de'], 'a settled fallback never re-fires the hook');
|
|
});
|
|
|
|
test('language load failure subscription delivers codes and unsubscribes (#354)', async () => {
|
|
const { subscribeLanguageLoadFailures, notifyLanguageLoadFailures } =
|
|
await import('../test-build/i18n/registry.js');
|
|
const seen = [];
|
|
const unsubscribe = subscribeLanguageLoadFailures((code) => seen.push(code));
|
|
const second = subscribeLanguageLoadFailures((code) => seen.push(`2:${code}`));
|
|
notifyLanguageLoadFailures('de');
|
|
assert.deepEqual(seen, ['de', '2:de'], 'every live subscriber receives the failed code');
|
|
unsubscribe();
|
|
notifyLanguageLoadFailures('de');
|
|
assert.deepEqual(
|
|
seen,
|
|
['de', '2:de', '2:de'],
|
|
'an unsubscribed listener stays silent while the rest keep receiving',
|
|
);
|
|
second();
|
|
notifyLanguageLoadFailures('de');
|
|
assert.deepEqual(seen, ['de', '2:de', '2:de'], 'no listeners — no deliveries');
|
|
});
|
|
|
|
test('#627 AC5 составной гейт ждёт словарь пространства и не роняет lang в en при его отказе', async () => {
|
|
const { composeLanguageRuntimes } = await import('../test-build/i18n/namespace-language.js');
|
|
const primary = new LanguageRuntime([
|
|
{ code: 'en', dictionary: {} }, { code: 'ru', dictionary: {} },
|
|
], 'build');
|
|
const namespaceLoad = deferred();
|
|
const loads = [];
|
|
const namespace = new LanguageRuntime([
|
|
{ code: 'en', dictionary: { title: 'Settings' } },
|
|
{ code: 'ru', loadDictionary: async (attempt) => { loads.push(attempt); return namespaceLoad.promise; } },
|
|
], 'build', () => {});
|
|
const host = new FakeHost();
|
|
const composite = composeLanguageRuntimes(primary, [namespace]);
|
|
assert.equal(languageRenderGate(host, composite, 'en'), 'ready', 'en of the namespace is static');
|
|
assert.deepEqual(loads, [], 'English never requests a namespace chunk');
|
|
// Live switch en → ru: the main catalog is ready, the namespace is not.
|
|
assert.equal(composite.state('ru'), 'pending');
|
|
assert.equal(languageRenderGate(host, composite, 'ru'), 'warm');
|
|
assert.equal(host.inert, true);
|
|
assert.equal(host.attrs.get('aria-busy'), 'true');
|
|
assert.equal(host.attrs.get('lang'), 'en', 'the previous frame keeps its language while pending');
|
|
namespaceLoad.resolve({ dictionary: { title: 'Настройки' }, fingerprint: 'build' });
|
|
await composite.ensure('ru');
|
|
await Promise.resolve();
|
|
assert.equal(host.updates, 1, 'the host re-renders once the namespace settled');
|
|
assert.equal(languageRenderGate(host, composite, 'ru'), 'ready');
|
|
assert.equal(host.inert, false);
|
|
assert.equal(host.attrs.get('lang'), 'ru');
|
|
assert.equal(namespace.dictionary('ru').title, 'Настройки');
|
|
assert.deepEqual(loads, [0], 'one request per page, deduplicated');
|
|
|
|
// A failed namespace settles as fallback: the host unblocks, and its `lang`
|
|
// stays the main catalog language — only the namespace strings are English.
|
|
const failing = new LanguageRuntime([
|
|
{ code: 'en', dictionary: {} },
|
|
{ code: 'ru', loadDictionary: async () => { throw new Error('offline'); } },
|
|
], 'build', () => {});
|
|
const failedHost = new FakeHost();
|
|
const failedComposite = composeLanguageRuntimes(primary, [failing]);
|
|
assert.equal(languageRenderGate(failedHost, failedComposite, 'ru'), 'cold');
|
|
await failedComposite.ensure('ru');
|
|
assert.equal(failing.state('ru'), 'fallback');
|
|
assert.equal(failedComposite.state('ru'), 'ready');
|
|
assert.equal(languageRenderGate(failedHost, failedComposite, 'ru'), 'ready');
|
|
assert.equal(failedHost.attrs.get('lang'), 'ru');
|
|
assert.equal(failedHost.hasAttribute('aria-busy'), false);
|
|
|
|
// The main catalog still decides the fallback `lang` on its own failure.
|
|
const mainFailing = new LanguageRuntime([
|
|
{ code: 'en', dictionary: {} },
|
|
{ code: 'de', loadDictionary: async () => { throw new Error('offline'); } },
|
|
], 'build', () => {});
|
|
const settledNamespace = new LanguageRuntime([
|
|
{ code: 'en', dictionary: {} }, { code: 'de', dictionary: {} },
|
|
], 'build');
|
|
const mainComposite = composeLanguageRuntimes(mainFailing, [settledNamespace]);
|
|
await mainComposite.ensure('de');
|
|
assert.equal(mainComposite.state('de'), 'fallback');
|
|
});
|