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'); });