Every text message subscribed to the whole translation cache array, so one translation re-rendered the entire timeline. Messages now subscribe to their own keyed entry, the cache key is memoised, the auto-translate "already tried" guard is module-scoped (no re-detect on virtualised remount), and both atom families are FIFO-capped and evicted on logout. Unit-tested. Fixes #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
172 lines
6.5 KiB
TypeScript
172 lines
6.5 KiB
TypeScript
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<string, string>();
|
|
(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);
|
|
});
|