import assert from "node:assert"; import { describe, it, mock } from "node:test"; import type { TLocalisationResourceAddedEvent, TLocalisationSource, } from "./types"; import { Localisation } from "./index"; describe("Localisation plugin", () => { let instance: Localisation | null = null; const languageChanges: string[] = []; const getInstance = (): Localisation => { assert.ok(instance); return instance; }; it("creates one instance and runs ready callbacks", async () => { const pendingReadyInstances: Localisation[] = []; Localisation.addReadyCallback((readyInstance) => { pendingReadyInstances.push(readyInstance); }); const firstPromise = Localisation.create({ locale: "ru", langKeys: [ "ru", "en" ], onChange: (_localisation, lang) => { languageChanges.push(lang); }, }); const secondPromise = Localisation.create(); const [ firstInstance, secondInstance ] = await Promise.all([ firstPromise, secondPromise, ]); instance = firstInstance; assert.equal(firstInstance, secondInstance); assert.equal(Localisation.instance, firstInstance); assert.deepEqual(pendingReadyInstances, [ firstInstance ]); assert.equal(Localisation.isReady, true); assert.equal(Localisation.isLoading, false); assert.equal(Localisation.isError, false); const immediateReadyCallback = mock.fn((_readyInstance: Localisation) => {}); Localisation.addReadyCallback(immediateReadyCallback); assert.equal(immediateReadyCallback.mock.calls.length, 1); assert.equal(immediateReadyCallback.mock.calls[0]?.arguments[0], firstInstance); }); it("rejects direct construction after initialization", () => { assert.throws(() => new Localisation(), /Use Localisation\.create\(\)/); }); it("resolves language codes with regions", () => { const errorSpy = mock.method(console, "error", () => {}); try { assert.equal(Localisation.getLangWithRegion("en"), "en_US"); assert.equal(Localisation.getLangWithRegion("ru-RU"), "ru_RU"); assert.equal(Localisation.getLangWithRegion("rus"), "ru_RU"); assert.equal(Localisation.getLangWithRegion(null), null); assert.equal(Localisation.getLangWithRegion(""), null); assert.equal(Localisation.getLangWithRegion("unknown"), null); assert.equal(errorSpy.mock.calls.length, 1); } finally { errorSpy.mock.restore(); } }); it("does not log error when onReady callback is undefined", () => { const errorSpy = mock.method(console, "error", () => {}); Localisation.onReady = undefined; assert.equal(errorSpy.mock.calls.length, 0); errorSpy.mock.restore(); }); it("returns translations, fallback keys, fixed functions, and resources", () => { const localisation = getInstance(); const selectLocale = localisation.getLocaleFn("select", "ru"); const fallbackLocale = localisation.getLocale([ "missing.locale", "forms.invalidInput" ]); const selectedValues = selectLocale("selectedValues", { current: 1, total: 2 }); assert.equal(localisation.isLocaleExist("forms.invalidInput"), true); assert.equal(localisation.getLocale("forms.invalidInput"), "Неверно"); assert.equal(localisation.getLocale("missing.locale"), ""); assert.equal(fallbackLocale, "Неверно"); assert.equal(selectedValues, "Выбрано 1 из 2"); assert.equal(localisation.getResource("forms.invalidInput"), "Неверно"); }); it("changes language and invokes change callbacks", async () => { const localisation = getInstance(); const callback = mock.fn((_error: unknown) => {}); try { await localisation.getChangeLanguage("en", callback); assert.equal(localisation.lang, "en"); assert.equal(localisation.getLocale("forms.invalidInput"), "Invalid"); assert.equal(languageChanges.at(-1), "en"); assert.equal(callback.mock.calls.length, 1); } finally { await localisation.getChangeLanguage("ru"); } }); it("appends dynamic resources for all configured languages", async () => { const localisation = getInstance(); const code = "new.dynamic"; const locales: TLocalisationSource = { new: { dynamic: [ "Значение динамической локали", "Dynamic locale value", ], }, }; const addedResources: TLocalisationResourceAddedEvent[] = []; const unsubscribe = Localisation.onResourceAdded((event) => { addedResources.push(event); }); try { assert.equal(localisation.isLocaleExist(code), false); await localisation.getResourceAppend(locales); const enResource = localisation.lib.getResource("en", localisation.cfg.fallbackNS, code); const addedResourceDetails = addedResources.map(({ lang, ns, keys, source, }) => { return { lang, ns, keys, source, }; }); assert.equal(localisation.getLocale(code), "Значение динамической локали"); assert.equal(enResource, "Dynamic locale value"); assert.deepEqual(addedResourceDetails, [ { lang: "ru", ns: "translation", keys: [ code ], source: "getResourceAppend", }, { lang: "en", ns: "translation", keys: [ code ], source: "getResourceAppend", }, ]); assert.equal(addedResources.every(({ isHasCode }) => isHasCode?.(code)), true); } finally { unsubscribe(); } }); it("does not emit resource events for an empty append", async () => { const localisation = getInstance(); const listener = mock.fn((_event: TLocalisationResourceAddedEvent) => {}); const unsubscribe = Localisation.onResourceAdded(listener); try { await localisation.getResourceAppend({}); assert.equal(listener.mock.calls.length, 0); } finally { unsubscribe(); } }); it("appends non-flat resources as separate namespaces", async () => { const localisation = getInstance(); const events: TLocalisationResourceAddedEvent[] = []; const unsubscribe = Localisation.onResourceAdded((event) => { events.push(event); }); try { await localisation.getResourceAppend({ secondary: { title: [ "Заголовок", "Title" ], }, }, false); assert.equal(localisation.lib.getResource("ru", "secondary", "title"), "Заголовок"); assert.equal(localisation.lib.getResource("en", "secondary", "title"), "Title"); assert.deepEqual(events.map(({ lang, ns, keys }) => { return { lang, ns, keys }; }), [ { lang: "ru", ns: "secondary", keys: [ "title" ] }, { lang: "en", ns: "secondary", keys: [ "title" ] }, ]); } finally { unsubscribe(); } }); it("adds, protects, and overrides a resource bundle", async () => { const localisation = getInstance(); const ns = "dynamicBundle"; const initialLocales: TLocalisationSource = { section: { title: [ "Исходное значение" ], }, }; const overriddenLocales: TLocalisationSource = { section: { title: [ "Новое значение" ], }, }; const events: TLocalisationResourceAddedEvent[] = []; const unsubscribe = Localisation.onResourceAdded((event) => { events.push(event); }); localisation.lib.removeResourceBundle("ru", ns); try { await localisation.getNewResource({ lang: "ru", ns, locales: initialLocales, }); assert.deepEqual(localisation.lib.getResourceBundle("ru", ns), initialLocales); const duplicateResource = localisation.getNewResource({ lang: "ru", ns, locales: initialLocales, }); await assert.rejects(duplicateResource, /Resource bundle already exist/); await localisation.getNewResource({ lang: "ru", ns, locales: overriddenLocales, isOverride: true, }); assert.deepEqual(localisation.lib.getResourceBundle("ru", ns), overriddenLocales); assert.deepEqual(events.map(({ keys, source }) => { return { keys, source }; }), [ { keys: [ "section" ], source: "getNewResource" }, { keys: [ "section" ], source: "getNewResource" }, ]); } finally { unsubscribe(); localisation.lib.removeResourceBundle("ru", ns); } }); it("rejects resource operations without a namespace", async () => { const localisation = getInstance(); await assert.rejects(localisation.getNewNS(""), /Param `ns` are not defined/); await assert.rejects(localisation.getNewResource({ ns: "" }), /Param `ns` are not defined/); }); it("notifies about loaded namespaces and their keys", async () => { const localisation = getInstance(); const namespaces = [ "loadedOne", "loadedTwo" ] as const; const events: TLocalisationResourceAddedEvent[] = []; const unsubscribe = Localisation.onResourceAdded((event) => { events.push(event); }); localisation.lib.addResourceBundle("ru", namespaces[0], { one: "Один" }); localisation.lib.addResourceBundle("ru", namespaces[1], { two: "Два" }); try { await localisation.getNewNS(namespaces); assert.deepEqual(events.map(({ ns, keys, source }) => { return { ns, keys, source }; }), [ { ns: namespaces[0], keys: [ "one" ], source: "getNewNS" }, { ns: namespaces[1], keys: [ "two" ], source: "getNewNS" }, ]); } finally { unsubscribe(); namespaces.forEach((ns) => localisation.lib.removeResourceBundle("ru", ns)); } }); it("isolates listener errors, matches related codes, and unsubscribes", async () => { const localisation = getInstance(); const receivedEvents: TLocalisationResourceAddedEvent[] = []; const errorSpy = mock.method(console, "error", () => {}); const unsubscribeFailing = Localisation.onResourceAdded(() => { throw new Error("Listener failed"); }); const unsubscribeReceiving = Localisation.onResourceAdded((event) => { receivedEvents.push(event); }); try { await localisation.getResourceAppend({ parent: { child: [ "Значение", "Value" ], }, }); assert.equal(receivedEvents.length, 2); assert.equal(errorSpy.mock.calls.length, 2); assert.equal(receivedEvents[0]?.isHasCode?.("parent.child"), true); assert.equal(receivedEvents[0]?.isHasCode?.("parent"), true); assert.equal(receivedEvents[0]?.isHasCode?.("parent.child.grandchild"), true); assert.equal(receivedEvents[0]?.isHasCode?.("unrelated"), false); unsubscribeFailing(); unsubscribeReceiving(); await localisation.getResourceAppend({ afterUnsubscribe: { value: [ "После отписки", "After unsubscribe" ], }, }); assert.equal(receivedEvents.length, 2); assert.equal(errorSpy.mock.calls.length, 2); } finally { unsubscribeFailing(); unsubscribeReceiving(); errorSpy.mock.restore(); } }); });