Files
SubMiner/src/main/character-dictionary-runtime/image-lookup.test.ts
T

296 lines
8.6 KiB
TypeScript

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<T>(probe: () => T | null | undefined): Promise<T> {
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);
});