import assert from 'node:assert/strict'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import test from 'node:test'; import { getSnapshotPath, writeSnapshot } from './cache'; import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; import { buildCharacterNameImageIndexFromSnapshots, createCharacterDictionaryImageLookup, } from './image-lookup'; import type { CharacterDictionarySnapshot } from './types'; // Lookup indexes rebuild in the background while gets serve stale data, so tests poll until the // refresh they triggered has landed. async function waitForRefresh(probe: () => T | null | undefined): Promise { const deadline = Date.now() + 5000; for (;;) { const value = probe(); if (value !== null && value !== undefined) { return value; } if (Date.now() > deadline) { throw new Error('timed out waiting for background snapshot refresh'); } await new Promise((resolve) => setTimeout(resolve, 5)); } } const PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII='; function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-image-lookup-')); } test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', async () => { const outputDir = makeTempDir(); const snapshot: CharacterDictionarySnapshot = { formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, mediaId: 130298, mediaTitle: 'The Eminence in Shadow', entryCount: 1, updatedAt: 1_700_000_000_000, termEntries: [ [ 'アレクシア', 'あれくしあ', 'name primary', '', 75, [ { type: 'structured-content', content: { tag: 'div', content: [ { tag: 'div', content: 'アレクシア・ミドガル' }, { tag: 'div', content: { tag: 'img', path: 'img/m130298-c123.png', alt: 'アレクシア・ミドガル', }, }, { tag: 'details', content: [ { tag: 'summary', content: 'Voiced by' }, { tag: 'div', content: { tag: 'img', path: 'img/m130298-va456.png', alt: 'VA', }, }, ], }, ], }, }, ], 0, '', ], ], images: [ { path: 'img/m130298-c123.png', dataBase64: 'AAAA' }, { path: 'img/m130298-va456.png', dataBase64: 'BBBB' }, ], }; await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot); const index = await buildCharacterNameImageIndexFromSnapshots(outputDir); assert.deepEqual(index.get('アレクシア'), { src: 'data:image/png;base64,AAAA', alt: 'アレクシア・ミドガル', }); }); test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', async () => { const outputDir = makeTempDir(); const snapshot: CharacterDictionarySnapshot = { formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, mediaId: 130298, mediaTitle: 'The Eminence in Shadow', entryCount: 1, updatedAt: 1_700_000_000_000, termEntries: [ [ 'アレクシア', 'あれくしあ', 'name primary', '', 75, [ { type: 'structured-content', content: { tag: 'img', path: 'img/m130298-c123.jpg', alt: 'アレクシア・ミドガル', }, }, ], 0, '', ], ], images: [{ path: 'img/m130298-c123.jpg', dataBase64: PNG_1X1_BASE64 }], }; await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot); const index = await buildCharacterNameImageIndexFromSnapshots(outputDir); assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`); }); test('createCharacterDictionaryImageLookup can scope duplicate names to the current media', async () => { const outputDir = makeTempDir(); const towerSnapshot: CharacterDictionarySnapshot = { formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, mediaId: 115230, mediaTitle: 'Tower of God', entryCount: 1, updatedAt: 1_700_000_000_000, termEntries: [ [ 'カズ', 'かず', 'name primary', '', 75, [ { type: 'structured-content', content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' }, }, ], 0, '', ], ], images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }], }; const konosubaSnapshot: CharacterDictionarySnapshot = { ...towerSnapshot, mediaId: 21202, mediaTitle: 'KonoSuba', termEntries: [ [ 'カズ', 'かず', 'name primary', '', 75, [ { type: 'structured-content', content: { tag: 'img', path: 'img/m21202-c2.png', alt: 'Kazuma' }, }, ], 0, '', ], ], images: [{ path: 'img/m21202-c2.png', dataBase64: 'KONOSUBA' }], }; await writeSnapshot(getSnapshotPath(outputDir, towerSnapshot.mediaId), towerSnapshot); await writeSnapshot(getSnapshotPath(outputDir, konosubaSnapshot.mediaId), konosubaSnapshot); const lookup = createCharacterDictionaryImageLookup({ outputDir }); const scoped = await waitForRefresh(() => lookup.get('カズ', 21202)); assert.equal(scoped.alt, 'Kazuma'); }); test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => { const outputDir = makeTempDir(); const snapshot: CharacterDictionarySnapshot = { formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, mediaId: 21858, mediaTitle: 'Little Witch Academia', entryCount: 1, updatedAt: 1_700_000_000_000, termEntries: [ [ 'ダイアナ', 'だいあな', 'name primary', '', 75, [ { type: 'structured-content', content: { tag: 'img', path: 'img/m21858-c81709.png', alt: 'ダイアナ・キャベンディッシュ', }, }, ], 0, '', ], ], images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }], }; await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot); const callbackError = new Error('annotation refresh failed'); const reportingError = new Error('error reporter failed'); let readyCount = 0; const reportedErrors: unknown[] = []; const lookup = createCharacterDictionaryImageLookup({ outputDir, onIndexReady: () => { readyCount += 1; if (readyCount === 1) { throw callbackError; } }, onIndexReadyError: (error) => { reportedErrors.push(error); throw reportingError; }, }); assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null); await waitForRefresh(() => (reportedErrors.length === 1 ? true : null)); assert.ok(lookup.get('ダイアナ', snapshot.mediaId)); assert.equal(readyCount, 2); assert.deepEqual(reportedErrors, [callbackError]); lookup.get('ダイアナ', snapshot.mediaId); assert.equal(readyCount, 2); }); test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => { const outputDir = makeTempDir(); const snapshot: CharacterDictionarySnapshot = { formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, mediaId: 115230, mediaTitle: 'Tower of God', entryCount: 1, updatedAt: 1_700_000_000_000, termEntries: [ [ 'カズ', 'かず', 'name primary', '', 75, [ { type: 'structured-content', content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' }, }, ], 0, '', ], ], images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }], }; await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot); const lookup = createCharacterDictionaryImageLookup({ outputDir }); const unscoped = await waitForRefresh(() => lookup.get('カズ')); assert.equal(unscoped.alt, 'Kaz'); assert.equal(lookup.get('カズ', 21202), null); });