import assert from 'node:assert/strict'; import test from 'node:test'; import { isExcludedWord, getExcludedWordsSnapshot, initializeExcludedWordsStore, resetExcludedWordsStoreForTests, setExcludedWords, subscribeExcludedWordsServerSync, } from './useExcludedWords'; import { BASE_URL } from '../lib/api-client'; const STORAGE_KEY = 'subminer-excluded-words'; function installLocalStorage(initial: Record = {}) { const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); const values = new Map(Object.entries(initial)); Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key), }, }); return { values, restore: () => { if (previous) { Object.defineProperty(globalThis, 'localStorage', previous); } else { delete (globalThis as { localStorage?: unknown }).localStorage; } }, }; } test('initializeExcludedWordsStore seeds empty database exclusions from localStorage', async () => { resetExcludedWordsStoreForTests(); const localRows = [{ headword: '猫', word: '猫', reading: 'ねこ' }]; const { values: storage, restore } = installLocalStorage({ [STORAGE_KEY]: JSON.stringify(localRows), }); const originalFetch = globalThis.fetch; const requests: Array<{ url: string; method: string; body: string }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { requests.push({ url: String(input), method: init?.method ?? 'GET', body: String(init?.body ?? ''), }); if (!init?.method) { return new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } return new Response(JSON.stringify({ ok: true }), { status: 200 }); }) as typeof globalThis.fetch; try { await initializeExcludedWordsStore(); assert.deepEqual(getExcludedWordsSnapshot(), localRows); assert.deepEqual(requests, [ { url: `${BASE_URL}/api/stats/excluded-words`, method: 'GET', body: '' }, { url: `${BASE_URL}/api/stats/excluded-words`, method: 'PUT', body: JSON.stringify({ words: localRows }), }, ]); assert.equal(storage.get(STORAGE_KEY), JSON.stringify(localRows)); } finally { globalThis.fetch = originalFetch; restore(); resetExcludedWordsStoreForTests(); } }); test('setExcludedWords updates the database-backed exclusion list', async () => { resetExcludedWordsStoreForTests(); const { values: storage, restore } = installLocalStorage(); const originalFetch = globalThis.fetch; let seenBody = ''; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { seenBody = String(init?.body ?? ''); return new Response(JSON.stringify({ ok: true }), { status: 200 }); }) as typeof globalThis.fetch; try { const rows = [{ headword: 'する', word: 'する', reading: 'する' }]; await setExcludedWords(rows); assert.deepEqual(getExcludedWordsSnapshot(), rows); assert.equal(seenBody, JSON.stringify({ words: rows })); assert.equal(storage.get(STORAGE_KEY), JSON.stringify(rows)); } finally { globalThis.fetch = originalFetch; restore(); resetExcludedWordsStoreForTests(); } }); test('setExcludedWords persists one row per excluded token', async () => { resetExcludedWordsStoreForTests(); const { values: storage, restore } = installLocalStorage(); const originalFetch = globalThis.fetch; let seenBody = ''; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { seenBody = String(init?.body ?? ''); return new Response(JSON.stringify({ ok: true }), { status: 200 }); }) as typeof globalThis.fetch; try { const rows = [ { headword: 'ない', word: 'ない', reading: 'ない' }, { headword: 'ない', word: '無い', reading: 'ない' }, ]; const expected = [{ headword: 'ない', word: 'ない', reading: 'ない' }]; await setExcludedWords(rows); assert.deepEqual(getExcludedWordsSnapshot(), expected); assert.equal(seenBody, JSON.stringify({ words: expected })); assert.equal(storage.get(STORAGE_KEY), JSON.stringify(expected)); } finally { globalThis.fetch = originalFetch; restore(); resetExcludedWordsStoreForTests(); } }); test('exclusion matching covers vocabulary rows with the same visible token', () => { const excluded = [{ headword: 'ない', word: 'ない', reading: 'ない' }]; assert.equal(isExcludedWord(excluded, { headword: 'ない', word: '無い', reading: 'ない' }), true); assert.equal(isExcludedWord(excluded, { headword: '無い', word: 'ない', reading: 'ない' }), true); assert.equal( isExcludedWord(excluded, { headword: 'なる', word: 'なる', reading: 'なる' }), false, ); }); test('setExcludedWords rolls back local state when persistence fails', async () => { resetExcludedWordsStoreForTests(); const previousRows = [{ headword: '猫', word: '猫', reading: 'ねこ' }]; const nextRows = [{ headword: 'する', word: 'する', reading: 'する' }]; const { values: storage, restore } = installLocalStorage({ [STORAGE_KEY]: JSON.stringify(previousRows), }); const originalFetch = globalThis.fetch; const originalConsoleError = console.error; console.error = () => {}; globalThis.fetch = (async () => { return new Response('failed', { status: 500 }); }) as typeof globalThis.fetch; try { await assert.rejects(() => setExcludedWords(nextRows), /Stats API error: 500/); assert.deepEqual(getExcludedWordsSnapshot(), previousRows); assert.equal(storage.get(STORAGE_KEY), JSON.stringify(previousRows)); } finally { globalThis.fetch = originalFetch; console.error = originalConsoleError; restore(); resetExcludedWordsStoreForTests(); } }); test('initializeExcludedWordsStore retries after transient database load failures', async () => { resetExcludedWordsStoreForTests(); const { restore } = installLocalStorage(); const originalFetch = globalThis.fetch; const originalConsoleError = console.error; console.error = () => {}; let calls = 0; globalThis.fetch = (async () => { calls += 1; if (calls === 1) { return new Response('failed', { status: 500 }); } return new Response(JSON.stringify([{ headword: '猫', word: '猫', reading: 'ねこ' }]), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }) as typeof globalThis.fetch; try { await initializeExcludedWordsStore(); await initializeExcludedWordsStore(); assert.equal(calls, 2); assert.deepEqual(getExcludedWordsSnapshot(), [{ headword: '猫', word: '猫', reading: 'ねこ' }]); } finally { globalThis.fetch = originalFetch; console.error = originalConsoleError; restore(); resetExcludedWordsStoreForTests(); } }); test('a failing server-sync listener neither rolls back the write nor blocks other listeners', async () => { resetExcludedWordsStoreForTests(); const { values: storage, restore } = installLocalStorage(); const originalFetch = globalThis.fetch; const originalConsoleError = console.error; console.error = () => {}; globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch; const notified: string[] = []; const unsubscribeFirst = subscribeExcludedWordsServerSync(() => { notified.push('first'); throw new Error('listener exploded'); }); const unsubscribeSecond = subscribeExcludedWordsServerSync(() => { notified.push('second'); }); try { const rows = [{ headword: 'する', word: 'する', reading: 'する' }]; await assert.doesNotReject(() => setExcludedWords(rows)); assert.deepEqual(notified, ['first', 'second']); assert.deepEqual(getExcludedWordsSnapshot(), rows); assert.equal(storage.get(STORAGE_KEY), JSON.stringify(rows)); } finally { unsubscribeFirst(); unsubscribeSecond(); globalThis.fetch = originalFetch; console.error = originalConsoleError; restore(); resetExcludedWordsStoreForTests(); } }); test('overlapping writes serialize so an older list cannot overwrite a newer edit', async () => { resetExcludedWordsStoreForTests(); const { restore } = installLocalStorage(); const originalFetch = globalThis.fetch; const sentBodies: string[] = []; let releaseFirst: (() => void) | null = null; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { sentBodies.push(String(init?.body ?? '')); if (sentBodies.length === 1) { await new Promise((resolve) => { releaseFirst = resolve; }); } return new Response(JSON.stringify({ ok: true }), { status: 200 }); }) as typeof globalThis.fetch; const syncs: string[] = []; const unsubscribe = subscribeExcludedWordsServerSync(() => { syncs.push(JSON.stringify(getExcludedWordsSnapshot())); }); try { const first = [{ headword: '猫', word: '猫', reading: 'ねこ' }]; const second = [...first, { headword: '犬', word: '犬', reading: 'いぬ' }]; const third = [...second, { headword: '鳥', word: '鳥', reading: 'とり' }]; // The first write reaches the network before the later edits are made. const firstWrite = setExcludedWords(first); await new Promise((resolve) => setTimeout(resolve, 0)); const secondWrite = setExcludedWords(second); const thirdWrite = setExcludedWords(third); const release = releaseFirst as (() => void) | null; assert.ok(release, 'expected the first write to be in flight'); release(); await Promise.all([firstWrite, secondWrite, thirdWrite]); // The in-flight write finishes first, the superseded middle write is // dropped, and the newest list is the last thing the server is told. assert.deepEqual(sentBodies, [ JSON.stringify({ words: first }), JSON.stringify({ words: third }), ]); assert.deepEqual(getExcludedWordsSnapshot(), third); // Only the final revision notifies: the first write's acknowledgement was // already obsolete, so it must not trigger an aggregate recomputation. assert.deepEqual(syncs, [JSON.stringify(third)]); } finally { unsubscribe(); globalThis.fetch = originalFetch; restore(); resetExcludedWordsStoreForTests(); } });