import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createStore } from 'jotai'; // The module evaluates atomWithStorage(..., { getOnInit: true }) which reads // localStorage at load time. node has none — install a no-op mock, then import // dynamically (a static import would hoist above the mock). const localStorageBacking = new Map(); (globalThis as { localStorage?: unknown }).localStorage = { getItem: (k: string) => localStorageBacking.get(k) ?? null, setItem: (k: string, v: string) => { localStorageBacking.set(k, v); }, removeItem: (k: string) => { localStorageBacking.delete(k); }, }; const { addTranslation, findTranslation, makeCacheKey, translationCacheAtom, translationEntryAtomFamily, msgTranslationActiveAtomFamily, wasAutoTranslateTried, markAutoTranslateTried, clearTranslationCache, } = await import('./translation'); const entry = (key: string, translated = `t-${key}`, fromLang = 'de') => ({ key, translated, fromLang, }); test('makeCacheKey: eventId + normalized target + content fingerprint', () => { // Same event + target + text is stable and prefixed by event:normalizedTarget. const k1 = makeCacheKey('$abc', 'en-US', 'hola'); const k2 = makeCacheKey('$abc', 'EN', 'hola'); assert.equal(k1, k2); assert.ok(k1.startsWith('$abc:en:')); // Different body (an edit) => different key, so a stale translation misses. assert.notEqual(makeCacheKey('$abc', 'en', 'hola'), makeCacheKey('$abc', 'en', 'adios')); // Missing text arg still yields a stable key. assert.ok(makeCacheKey('$abc', 'en').startsWith('$abc:en:')); }); test('addTranslation: prepends, newest first', () => { const out = addTranslation([entry('a'), entry('b')], entry('c')); assert.deepEqual( out.map((e) => e.key), ['c', 'a', 'b'], ); }); test('addTranslation: de-dupes by key, moving to front (and updates value)', () => { const out = addTranslation([entry('a', 'old'), entry('b')], entry('a', 'new')); assert.deepEqual( out.map((e) => e.key), ['a', 'b'], ); assert.equal(out[0].translated, 'new'); }); test('addTranslation: caps at max (newest kept)', () => { const out = addTranslation([entry('a'), entry('b'), entry('c')], entry('d'), 3); assert.deepEqual( out.map((e) => e.key), ['d', 'a', 'b'], ); }); test('addTranslation: ignores empty key or translated', () => { const start = [entry('a')]; assert.equal(addTranslation(start, entry('', 'x')), start); assert.equal(addTranslation(start, entry('b', '')), start); }); test('findTranslation: returns match or undefined', () => { const list = [entry('a'), entry('b')]; assert.equal(findTranslation(list, 'b')?.key, 'b'); assert.equal(findTranslation(list, 'z'), undefined); }); // [Gitea #39] Keyed cache subscription: a message only re-renders for its own // entry, not the whole array. test('translationEntryAtomFamily: looks up only its own key, unaffected by others', () => { const store = createStore(); store.set(translationCacheAtom, () => [entry('a'), entry('b')]); assert.equal(store.get(translationEntryAtomFamily('a'))?.translated, 't-a'); assert.equal(store.get(translationEntryAtomFamily('z')), undefined); }); test('translationEntryAtomFamily: does not notify a subscriber when an unrelated key changes', () => { const store = createStore(); store.set(translationCacheAtom, () => [entry('a')]); let notifications = 0; const unsub = store.sub(translationEntryAtomFamily('a'), () => { notifications += 1; }); // atomWithStorage's `onMount` resyncs the base atom from storage as soon as // it gets its first subscriber, which — via the JSON round trip — produces // a structurally-equal-but-different-reference array, firing exactly one // notification unrelated to what this test checks. Drain it before // asserting so we're isolating the behaviour under test. notifications = 0; try { // Add an unrelated entry — 'a's object reference in the array is // preserved by addTranslation, so selectAtom's Object.is bails. store.set(translationCacheAtom, (prev) => addTranslation(prev, entry('other'))); assert.equal(notifications, 0); // Changing 'a' itself does notify. store.set(translationCacheAtom, (prev) => addTranslation(prev, entry('a', 'new-a'))); assert.equal(notifications, 1); } finally { unsub(); } }); test('msgTranslationActiveAtomFamily: FIFO-capped at 500 members', () => { const store = createStore(); // Prime "e0" with non-default state. store.set(msgTranslationActiveAtomFamily('e0'), true); assert.equal(store.get(msgTranslationActiveAtomFamily('e0')), true); // Create 500 more distinct members so the cap evicts "e0" (oldest-created); // real usage is one member per mounted message regardless of translation. for (let i = 1; i <= 500; i += 1) { msgTranslationActiveAtomFamily(`e${i}`); } // Evicted members are unmounted (`.remove()`d): re-accessing "e0" creates a // fresh atom, so it comes back at its default (inactive) value — evidence // the family doesn't grow without bound as the timeline is scrolled. assert.equal(store.get(msgTranslationActiveAtomFamily('e0')), false); }); test('translationEntryAtomFamily: stays usable well past the FIFO cap', () => { const store = createStore(); store.set(translationCacheAtom, () => [entry('k0')]); for (let i = 1; i <= 500; i += 1) { translationEntryAtomFamily(`k${i}`); } // Unlike the active family, this family is *derived* from the persisted // cache array, so a re-created (evicted-then-reaccessed) atom still finds // the entry — eviction here is a memory-hygiene concern, not a data-loss // one. Confirms the cap doesn't corrupt lookups either way. assert.equal(store.get(translationEntryAtomFamily('k0'))?.key, 'k0'); assert.equal(store.get(translationEntryAtomFamily('k250')), undefined); }); test('clearTranslationCache: wipes storage, live family members, and the auto-translate guard', () => { const store = createStore(); localStorageBacking.set('cinny_translation_cache_v1', '[{"key":"x"}]'); store.set(msgTranslationActiveAtomFamily('ev1'), true); markAutoTranslateTried('ev1', 'hola'); assert.equal(wasAutoTranslateTried('ev1', 'hola'), true); clearTranslationCache(); assert.equal(localStorageBacking.has('cinny_translation_cache_v1'), false); // Family member was `.remove()`d, so re-accessing it yields a fresh atom // back at its default value. assert.equal(store.get(msgTranslationActiveAtomFamily('ev1')), false); assert.equal(wasAutoTranslateTried('ev1', 'hola'), false); });