mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-29 00:15:32 -07:00
296 lines
8.6 KiB
TypeScript
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);
|
|
});
|