mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 17:01:25 -07:00
test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)
This commit is contained in:
1 parent
4b6842d034
commit
b48083ddd8
368 files changed
+18045
-60331
No files matched your search
@@ -121,18 +121,3 @@ test('readSnapshot ignores snapshots written with an older format version', asyn
|
||||
|
||||
assert.equal(await readSnapshot(snapshotPath), null);
|
||||
});
|
||||
|
||||
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', async () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshotPath = getSnapshotPath(outputDir, 130298);
|
||||
const staleSnapshot = {
|
||||
...createSnapshot(),
|
||||
formatVersion: 15,
|
||||
termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']],
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
|
||||
|
||||
assert.equal(await readSnapshot(snapshotPath), null);
|
||||
});
|
||||
@@ -161,6 +161,36 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c
|
||||
}
|
||||
});
|
||||
|
||||
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const mpvPath =
|
||||
'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv';
|
||||
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => null,
|
||||
getCurrentVideoPath: () => mpvPath,
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
|
||||
calls.push({ mediaPath, mediaTitle });
|
||||
return {
|
||||
title: 'KonoSuba - God’s blessing on this wonderful world!!',
|
||||
season: 2,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
};
|
||||
},
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||
|
||||
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
|
||||
assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!');
|
||||
assert.equal(snapshot.candidates.length, 0);
|
||||
});
|
||||
|
||||
test('resolvePinnedMediaId returns the manual override for the current season', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv';
|
||||
|
||||
@@ -106,12 +106,14 @@ test('returns null for a media with no cached snapshot', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('key changes when the snapshot content changes', async () => {
|
||||
test('invalidate picks up a snapshot change within the recheck interval and changes the key', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
// Frozen clock: only invalidate() can trigger the refresh below.
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
const first = await waitForRefresh(() => lookup.get());
|
||||
|
||||
@@ -162,31 +164,3 @@ test('does not re-read the snapshot directory on every lookup', async () => {
|
||||
assert.equal(refreshed.forms.length, 4, 'expected a refresh past the interval');
|
||||
});
|
||||
});
|
||||
|
||||
test('invalidate picks up a snapshot change on the next refresh', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
let nowMs = 1_000_000;
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
now: () => nowMs,
|
||||
});
|
||||
|
||||
await waitForRefresh(() => lookup.get());
|
||||
assert.equal(lookup.get()?.forms.length, 2);
|
||||
|
||||
writeSnapshot(dir, 1, [
|
||||
['ミナト', 'みなと'],
|
||||
['アクア', 'あくあ'],
|
||||
]);
|
||||
nowMs += 1;
|
||||
lookup.invalidate();
|
||||
|
||||
const refreshed = await waitForRefresh(() => {
|
||||
const candidates = lookup.get();
|
||||
return candidates && candidates.forms.length === 4 ? candidates : null;
|
||||
});
|
||||
assert.equal(refreshed.forms.length, 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { TestContext } from 'node:test';
|
||||
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { ANILIST_GRAPHQL_URL } from './constants';
|
||||
import type { CharacterDictionaryRuntimeDeps, CharacterDictionaryTermEntry } from './types';
|
||||
|
||||
// Shared fixtures for the character dictionary runtime tests: a fake AniList GraphQL + image
|
||||
// server, a runtime factory with Eminence in Shadow defaults, and readers for the built ZIP.
|
||||
|
||||
export const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
export function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
type FetchHandler = (url: string, init?: RequestInit) => Promise<Response> | Response;
|
||||
|
||||
/** Replaces `globalThis.fetch` for the duration of the test. */
|
||||
export function installFetch(t: TestContext, handler: FetchHandler): void {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return handler(url, init);
|
||||
}) as typeof globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
export function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** AniList `characters.edges[]` item, as returned by the GraphQL API. */
|
||||
export type FakeAniListCharacterEdge = {
|
||||
role: string;
|
||||
voiceActors?: Array<{
|
||||
id: number;
|
||||
name: { full?: string; native?: string };
|
||||
image?: { large?: string | null; medium?: string | null };
|
||||
}>;
|
||||
node: {
|
||||
id: number;
|
||||
description?: string;
|
||||
image?: { large?: string | null; medium?: string | null } | null;
|
||||
name: {
|
||||
full?: string;
|
||||
native?: string;
|
||||
first?: string | null;
|
||||
last?: string | null;
|
||||
alternative?: string[];
|
||||
};
|
||||
gender?: string;
|
||||
age?: string;
|
||||
dateOfBirth?: { month: number; day: number };
|
||||
bloodType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FakeAniListMedia = {
|
||||
id: number;
|
||||
episodes?: number;
|
||||
title: { romaji?: string; english?: string; native?: string };
|
||||
/** Search text that resolves to this media. Unmatched searches resolve to the first media. */
|
||||
search?: string;
|
||||
characters?: FakeAniListCharacterEdge[];
|
||||
};
|
||||
|
||||
export type FakeAniList = {
|
||||
/** Every fetched URL, in request order. */
|
||||
requests: string[];
|
||||
searchCount: number;
|
||||
characterPageCount: number;
|
||||
imageRequests: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves AniList media search and character page queries from `media`, and image downloads for
|
||||
* `images` (PNG) and `missingImages` (404). Any other request throws.
|
||||
*/
|
||||
export function installFakeAniList(
|
||||
t: TestContext,
|
||||
options: { media?: FakeAniListMedia[]; images?: string[]; missingImages?: string[] } = {},
|
||||
): FakeAniList {
|
||||
const media = options.media ?? [];
|
||||
const images = new Set(options.images ?? []);
|
||||
const missingImages = new Set(options.missingImages ?? []);
|
||||
const fake: FakeAniList = {
|
||||
requests: [],
|
||||
searchCount: 0,
|
||||
characterPageCount: 0,
|
||||
imageRequests: [],
|
||||
};
|
||||
|
||||
installFetch(t, (url, init) => {
|
||||
fake.requests.push(url);
|
||||
if (url === ANILIST_GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
variables?: { search?: string; id?: number };
|
||||
};
|
||||
|
||||
if (body.query?.includes('Page(perPage: 10)') && media.length > 0) {
|
||||
fake.searchCount += 1;
|
||||
const match = media.find((entry) => entry.search === body.variables?.search) ?? media[0]!;
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Page: { media: [{ id: match.id, episodes: match.episodes ?? 12, title: match.title }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const pageMedia = media.find((entry) => entry.id === body.variables?.id);
|
||||
if (body.query?.includes('characters(page: $page') && pageMedia) {
|
||||
fake.characterPageCount += 1;
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
title: pageMedia.title,
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: pageMedia.characters ?? [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (images.has(url) || missingImages.has(url)) {
|
||||
fake.imageRequests.push(url);
|
||||
return images.has(url)
|
||||
? new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } })
|
||||
: new Response('missing', { status: 404, headers: { 'content-type': 'text/plain' } });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
export const EMINENCE_MEDIA_ID = 130298;
|
||||
export const EMINENCE_TITLE = {
|
||||
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||
english: 'The Eminence in Shadow',
|
||||
native: '陰の実力者になりたくて!',
|
||||
};
|
||||
|
||||
export type CharacterDictionaryRuntime = ReturnType<typeof createCharacterDictionaryRuntimeService>;
|
||||
|
||||
/** Creates a runtime watching an Eminence in Shadow episode. AniList pacing sleeps are skipped. */
|
||||
export function createRuntime(overrides: Partial<CharacterDictionaryRuntimeDeps> = {}): {
|
||||
runtime: CharacterDictionaryRuntime;
|
||||
userDataPath: string;
|
||||
outputDir: string;
|
||||
} {
|
||||
const userDataPath = overrides.userDataPath ?? makeTempDir();
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
sleep: async () => undefined,
|
||||
...overrides,
|
||||
userDataPath,
|
||||
});
|
||||
return { runtime, userDataPath, outputDir: path.join(userDataPath, 'character-dictionaries') };
|
||||
}
|
||||
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
|
||||
export function readStoredZipEntry(zipPath: string, entryName: string): Buffer {
|
||||
const archive = fs.readFileSync(zipPath);
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 4 <= archive.length) {
|
||||
const signature = archive.readUInt32LE(offset);
|
||||
if (
|
||||
signature === CENTRAL_DIRECTORY_SIGNATURE ||
|
||||
signature === END_OF_CENTRAL_DIRECTORY_SIGNATURE
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (signature !== LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw new Error(`Unexpected ZIP signature 0x${signature.toString(16)} at offset ${offset}`);
|
||||
}
|
||||
|
||||
const compressionMethod = archive.readUInt16LE(offset + 8);
|
||||
assert.equal(compressionMethod, 0, 'expected stored ZIP entry');
|
||||
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||
const fileNameLength = archive.readUInt16LE(offset + 26);
|
||||
const extraFieldLength = archive.readUInt16LE(offset + 28);
|
||||
const fileNameStart = offset + 30;
|
||||
const fileNameEnd = fileNameStart + fileNameLength;
|
||||
const fileName = archive.subarray(fileNameStart, fileNameEnd).toString('utf8');
|
||||
const dataStart = fileNameEnd + extraFieldLength;
|
||||
const dataEnd = dataStart + compressedSize;
|
||||
|
||||
if (fileName === entryName) {
|
||||
return archive.subarray(dataStart, dataEnd);
|
||||
}
|
||||
|
||||
offset = dataEnd;
|
||||
}
|
||||
|
||||
throw new Error(`ZIP entry not found: ${entryName}`);
|
||||
}
|
||||
|
||||
export function readTermBank(zipPath: string): CharacterDictionaryTermEntry[] {
|
||||
return JSON.parse(
|
||||
readStoredZipEntry(zipPath, 'term_bank_1.json').toString('utf8'),
|
||||
) as CharacterDictionaryTermEntry[];
|
||||
}
|
||||
|
||||
type StructuredNode = { tag?: string; open?: boolean; content?: unknown };
|
||||
|
||||
/** Top-level structured-content children of a term entry's glossary. */
|
||||
export function getGlossaryChildren(entry: CharacterDictionaryTermEntry): StructuredNode[] {
|
||||
const glossary = entry[5][0] as { content: { content: StructuredNode[] } };
|
||||
return glossary.content.content;
|
||||
}
|
||||
|
||||
/** Finds a collapsible `details` section by its summary title. */
|
||||
export function findSection(
|
||||
children: StructuredNode[],
|
||||
title: string,
|
||||
): (StructuredNode & { content: StructuredNode[] }) | undefined {
|
||||
return children.find(
|
||||
(child): child is StructuredNode & { content: StructuredNode[] } =>
|
||||
child.tag === 'details' &&
|
||||
Array.isArray(child.content) &&
|
||||
(child.content[0] as StructuredNode | undefined)?.content === title,
|
||||
);
|
||||
}
|
||||
@@ -1,219 +1,135 @@
|
||||
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 test, { type TestContext } from 'node:test';
|
||||
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
|
||||
import { ANILIST_GRAPHQL_URL, CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import {
|
||||
EMINENCE_MEDIA_ID,
|
||||
EMINENCE_TITLE,
|
||||
PNG_1X1,
|
||||
createRuntime,
|
||||
installFakeAniList,
|
||||
installFetch,
|
||||
jsonResponse,
|
||||
type FakeAniListCharacterEdge,
|
||||
} from './runtime-test-harness';
|
||||
import type {
|
||||
CharacterDictionaryRuntimeDeps,
|
||||
CharacterDictionarySnapshot,
|
||||
NameSplitTokenizer,
|
||||
} from './types';
|
||||
|
||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
|
||||
function createSnapshotWithoutImages(
|
||||
overrides: Partial<CharacterDictionarySnapshot> = {},
|
||||
): CharacterDictionarySnapshot {
|
||||
return {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaId: EMINENCE_MEDIA_ID,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']],
|
||||
images: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchUrls: string[] = [];
|
||||
/** Seeds a cached snapshot, then returns a runtime that will find it for the current episode. */
|
||||
async function createRuntimeWithSnapshot(
|
||||
snapshot: CharacterDictionarySnapshot,
|
||||
overrides: Partial<CharacterDictionaryRuntimeDeps> = {},
|
||||
) {
|
||||
const harness = createRuntime({ now: () => 1_700_000_000_500, ...overrides });
|
||||
await writeSnapshot(getSnapshotPath(harness.outputDir, EMINENCE_MEDIA_ID), snapshot);
|
||||
return harness;
|
||||
}
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
fetchUrls.push(url);
|
||||
function readStoredSnapshot(outputDir: string): CharacterDictionarySnapshot {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, EMINENCE_MEDIA_ID), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
}
|
||||
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
};
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: {
|
||||
english: 'The Eminence in Shadow',
|
||||
},
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: {
|
||||
large: 'https://cdn.example.com/character-123.png',
|
||||
medium: null,
|
||||
},
|
||||
name: {
|
||||
full: 'Alexia Midgar',
|
||||
native: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
function installCharacterPage(t: TestContext, edge: FakeAniListCharacterEdge, images?: string[]) {
|
||||
return installFakeAniList(t, {
|
||||
media: [
|
||||
{ id: EMINENCE_MEDIA_ID, title: { english: EMINENCE_TITLE.english }, characters: [edge] },
|
||||
],
|
||||
images,
|
||||
});
|
||||
}
|
||||
|
||||
if (url === 'https://cdn.example.com/character-123.png') {
|
||||
return new Response(PNG_1X1, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
});
|
||||
}
|
||||
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async (t) => {
|
||||
const imageUrl = 'https://cdn.example.com/character-123.png';
|
||||
const anilist = installCharacterPage(
|
||||
t,
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: imageUrl, medium: null },
|
||||
name: { full: 'Alexia Midgar', native: 'アレクシア・ミドガル' },
|
||||
},
|
||||
},
|
||||
[imageUrl],
|
||||
);
|
||||
const { runtime, outputDir } = await createRuntimeWithSnapshot(createSnapshotWithoutImages(), {
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
});
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.ok(fetchUrls.includes(GRAPHQL_URL));
|
||||
assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png'));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.equal(anilist.characterPageCount, 1);
|
||||
assert.ok(
|
||||
readStoredSnapshot(outputDir).images.some((image) => image.path === 'img/m130298-c123.png'),
|
||||
);
|
||||
});
|
||||
|
||||
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
|
||||
async function runNameSplitRefreshScenario(
|
||||
t: TestContext,
|
||||
tokenizeJapaneseName: NameSplitTokenizer,
|
||||
): Promise<{
|
||||
characterPageRequests: number;
|
||||
firstResultFromCache: boolean;
|
||||
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
|
||||
secondResultFromCache: boolean;
|
||||
}> {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'heuristic',
|
||||
const anilist = installCharacterPage(t, {
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: null, medium: null },
|
||||
name: { first: 'Taro', last: 'Yamada', full: 'Taro Yamada', native: '山田太郎' },
|
||||
},
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
let characterPageRequests = 0;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { query?: string };
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
characterPageRequests += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'The Eminence in Shadow' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: null, medium: null },
|
||||
name: {
|
||||
first: 'Taro',
|
||||
last: 'Yamada',
|
||||
full: 'Taro Yamada',
|
||||
native: '山田太郎',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
const { runtime, outputDir } = await createRuntimeWithSnapshot(
|
||||
createSnapshotWithoutImages({ nameSplitSource: 'heuristic' }),
|
||||
{
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName,
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const firstResult = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
const secondResult = await runtime.generateForCurrentMedia();
|
||||
const firstResult = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = readStoredSnapshot(outputDir);
|
||||
const secondResult = await runtime.generateForCurrentMedia();
|
||||
|
||||
return {
|
||||
characterPageRequests,
|
||||
firstResultFromCache: firstResult.fromCache,
|
||||
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||
secondResultFromCache: secondResult.fromCache,
|
||||
};
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
return {
|
||||
characterPageRequests: anilist.characterPageCount,
|
||||
firstResultFromCache: firstResult.fromCache,
|
||||
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||
secondResultFromCache: secondResult.fromCache,
|
||||
};
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async (t) => {
|
||||
let tokenizerCalls = 0;
|
||||
const result = await runNameSplitRefreshScenario(async () => {
|
||||
const result = await runNameSplitRefreshScenario(t, async () => {
|
||||
tokenizerCalls += 1;
|
||||
return null;
|
||||
});
|
||||
@@ -225,9 +141,9 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
||||
assert.equal(tokenizerCalls, 2);
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
|
||||
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async (t) => {
|
||||
let tokenizerCalls = 0;
|
||||
const result = await runNameSplitRefreshScenario(async () => {
|
||||
const result = await runNameSplitRefreshScenario(t, async () => {
|
||||
tokenizerCalls += 1;
|
||||
return [];
|
||||
});
|
||||
@@ -239,129 +155,53 @@ test('generateForCurrentMedia caches completed MeCab refreshes with no resolved
|
||||
assert.equal(tokenizerCalls, 1);
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'mecab',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
const keptSnapshotCases: Array<{
|
||||
name: string;
|
||||
snapshot: CharacterDictionarySnapshot;
|
||||
deps: Partial<CharacterDictionaryRuntimeDeps>;
|
||||
}> = [
|
||||
{
|
||||
name: 'keeps mecab-split snapshots when MeCab is available',
|
||||
snapshot: createSnapshotWithoutImages({ nameSplitSource: 'mecab' }),
|
||||
deps: {
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => null,
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps heuristic-split snapshots while MeCab is unavailable', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'heuristic',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'keeps heuristic-split snapshots while MeCab is unavailable',
|
||||
snapshot: createSnapshotWithoutImages({ nameSplitSource: 'heuristic' }),
|
||||
deps: {
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => null,
|
||||
getJapaneseNameTokenizerAvailable: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'keeps same-version snapshots without images when inline images are disabled',
|
||||
snapshot: createSnapshotWithoutImages(),
|
||||
deps: { getNameMatchImagesEnabled: () => false },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, snapshot, deps } of keptSnapshotCases) {
|
||||
test(`generateForCurrentMedia ${name}`, async (t) => {
|
||||
// No media or images: any AniList request throws.
|
||||
installFakeAniList(t);
|
||||
const { runtime } = await createRuntimeWithSnapshot(snapshot, deps);
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('an unresolvable season is not cached as a normal AniList match', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
test('an unresolvable season is not cached as a normal AniList match', async (t) => {
|
||||
let searchCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url !== GRAPHQL_URL) {
|
||||
installFetch(t, (url, init) => {
|
||||
if (url !== ANILIST_GRAPHQL_URL) {
|
||||
return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
|
||||
@@ -371,84 +211,66 @@ test('an unresolvable season is not cached as a normal AniList match', async ()
|
||||
};
|
||||
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'My Teen Romantic Comedy SNAFU' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'MAIN',
|
||||
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'My Teen Romantic Comedy SNAFU' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'MAIN',
|
||||
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body.variables?.id === 'number') {
|
||||
// No sequel edges: season 3 is unreachable from the season 1 anchor.
|
||||
return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
return jsonResponse({ data: { Media: { relations: { edges: [] } } } });
|
||||
}
|
||||
|
||||
searchCalls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 14813,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 14813,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () =>
|
||||
'/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
season: 3,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
sleep: async () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
const { runtime } = createRuntime({
|
||||
getCurrentMediaPath: () => '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
getCurrentMediaTitle: () => null,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
season: 3,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
const searchesAfterFirst = searchCalls;
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
const searchesAfterFirst = searchCalls;
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
|
||||
// Re-resolved rather than served from the resolution cache, and warned both times.
|
||||
assert.ok(searchCalls > searchesAfterFirst);
|
||||
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
// Re-resolved rather than served from the resolution cache, and warned both times.
|
||||
assert.ok(searchCalls > searchesAfterFirst);
|
||||
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
|
||||
});
|
||||
Reference in new issue
Block a user