mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 00:15:27 -07:00
fix(character-dictionary): cache completed MeCab refreshes (#212)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character dictionary
|
||||||
|
|
||||||
|
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
|
||||||
|
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
|
||||||
+10
-6
@@ -2623,6 +2623,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
|
onIndexReady: () => refreshCurrentSubtitleAnnotations(),
|
||||||
|
onIndexReadyError: (error) =>
|
||||||
|
logger.warn(
|
||||||
|
'Failed to refresh subtitle annotations after character portrait index became ready.',
|
||||||
|
error,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
||||||
@@ -4047,7 +4053,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
|||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||||
};
|
};
|
||||||
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
function refreshCurrentSubtitleAnnotations(): void {
|
||||||
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
||||||
if (hasCurrentSubtitle) {
|
if (hasCurrentSubtitle) {
|
||||||
subtitlePrefetchService?.pause();
|
subtitlePrefetchService?.pause();
|
||||||
@@ -4058,7 +4064,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
|||||||
// Idle controller: no settle is coming to release the pause above.
|
// Idle controller: no settle is coming to release the pause above.
|
||||||
subtitlePrefetchService?.resume();
|
subtitlePrefetchService?.resume();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
let hasAttemptedImmersionTrackerStartup = false;
|
let hasAttemptedImmersionTrackerStartup = false;
|
||||||
const ensureImmersionTrackerStarted = (): void => {
|
const ensureImmersionTrackerStarted = (): void => {
|
||||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||||
@@ -5081,9 +5087,7 @@ function initializeOverlayRuntime(): void {
|
|||||||
overlayModalRuntime.primeModalWindow();
|
overlayModalRuntime.primeModalWindow();
|
||||||
}
|
}
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
|
||||||
);
|
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
@@ -5876,7 +5880,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration = integration;
|
appState.ankiIntegration = integration;
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
refreshCurrentSubtitleAnnotations,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||||
consumePendingSubtitleMiningContext,
|
consumePendingSubtitleMiningContext,
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||||
const resolvedNameSplits = nameSplitTokenizerAvailable
|
const nameSplitResolution = nameSplitTokenizerAvailable
|
||||||
? await resolveJapaneseNameSplits(
|
? await resolveJapaneseNameSplits(
|
||||||
characters,
|
characters,
|
||||||
deps.tokenizeJapaneseName!,
|
deps.tokenizeJapaneseName!,
|
||||||
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nameSplitSource =
|
const resolvedNameSplits = nameSplitResolution?.splits;
|
||||||
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
|
||||||
|
|
||||||
progress?.onGenerateProgress?.({
|
progress?.onGenerateProgress?.({
|
||||||
mediaId,
|
mediaId,
|
||||||
|
|||||||
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
|
|||||||
assert.equal(scoped.alt, 'Kazuma');
|
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 () => {
|
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
|
||||||
const outputDir = makeTempDir();
|
const outputDir = makeTempDir();
|
||||||
const snapshot: CharacterDictionarySnapshot = {
|
const snapshot: CharacterDictionarySnapshot = {
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
userDataPath?: string;
|
userDataPath?: string;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
getCurrentMediaId?: () => number | null | undefined;
|
getCurrentMediaId?: () => number | null | undefined;
|
||||||
|
onIndexReady?: () => void;
|
||||||
|
onIndexReadyError?: (error: unknown) => void;
|
||||||
}): {
|
}): {
|
||||||
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
let index = new Map<string, CharacterNameImage>();
|
let index = new Map<string, CharacterNameImage>();
|
||||||
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
let indexReadyDeliveryPending = false;
|
||||||
|
|
||||||
|
function deliverIndexReadyIfPending(): void {
|
||||||
|
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
indexReadyDeliveryPending = false;
|
||||||
|
try {
|
||||||
|
deps.onIndexReady();
|
||||||
|
} catch (error) {
|
||||||
|
indexReadyDeliveryPending = true;
|
||||||
|
try {
|
||||||
|
deps.onIndexReadyError?.(error);
|
||||||
|
} catch {
|
||||||
|
// Error reporting must not reject the detached index refresh task.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
|
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
|
||||||
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
||||||
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
signature = '';
|
signature = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
deliverIndexReadyIfPending();
|
||||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||||
if (nextSignature === signature || refreshInFlight) {
|
if (nextSignature === signature || refreshInFlight) {
|
||||||
return;
|
return;
|
||||||
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
index = nextIndex;
|
index = nextIndex;
|
||||||
indexByMediaId = nextIndexByMediaId;
|
indexByMediaId = nextIndexByMediaId;
|
||||||
signature = nextSignature;
|
signature = nextSignature;
|
||||||
|
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
|
||||||
|
deliverIndexReadyIfPending();
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
||||||
@@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
||||||
@@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
||||||
@@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.size, 0);
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.equal(splits.splits.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
||||||
@@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.size, 0);
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.equal(splits.splits.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
||||||
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
|||||||
(message) => warnings.push(message),
|
(message) => warnings.push(message),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.size, 0);
|
assert.equal(splits.kind, 'incomplete');
|
||||||
|
assert.equal(splits.splits.size, 0);
|
||||||
assert.equal(warnings.length, 1);
|
assert.equal(warnings.length, 1);
|
||||||
assert.match(warnings[0]!, /mecab unavailable/);
|
assert.match(warnings[0]!, /mecab unavailable/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
ResolvedNameSplit,
|
ResolvedNameSplit,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
export type JapaneseNameSplitResolution =
|
||||||
|
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
|
||||||
|
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
|
||||||
|
|
||||||
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
||||||
|
|
||||||
function joinSurfaces(tokens: NameSplitToken[]): string {
|
function joinSurfaces(tokens: NameSplitToken[]): string {
|
||||||
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
|
|||||||
tokenize: NameSplitTokenizer,
|
tokenize: NameSplitTokenizer,
|
||||||
logWarn?: (message: string) => void,
|
logWarn?: (message: string) => void,
|
||||||
onCharacterResolved?: (completed: number, total: number) => void,
|
onCharacterResolved?: (completed: number, total: number) => void,
|
||||||
): Promise<Map<string, ResolvedNameSplit>> {
|
): Promise<JapaneseNameSplitResolution> {
|
||||||
const splits = new Map<string, ResolvedNameSplit>();
|
const splits = new Map<string, ResolvedNameSplit>();
|
||||||
|
let tokenizerFailed = false;
|
||||||
let resolvedCharacters = 0;
|
let resolvedCharacters = 0;
|
||||||
for (const character of characters) {
|
for (const character of characters) {
|
||||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||||
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
|
|||||||
try {
|
try {
|
||||||
tokens = await tokenize(name);
|
tokens = await tokenize(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
tokenizerFailed = true;
|
||||||
logWarn?.(
|
logWarn?.(
|
||||||
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
if (!tokens) {
|
||||||
|
tokenizerFailed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
||||||
const splitIndex =
|
const splitIndex =
|
||||||
splitIndexFromPersonNamePos(tokens) ??
|
splitIndexFromPersonNamePos(tokens) ??
|
||||||
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
||||||
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
|
|||||||
resolvedCharacters += 1;
|
resolvedCharacters += 1;
|
||||||
onCharacterResolved?.(resolvedCharacters, characters.length);
|
onCharacterResolved?.(resolvedCharacters, characters.length);
|
||||||
}
|
}
|
||||||
return splits;
|
return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import test from 'node:test';
|
|||||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
import type { CharacterDictionarySnapshot } from './types';
|
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
|
||||||
|
|
||||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
const PNG_1X1 = Buffer.from(
|
const PNG_1X1 = Buffer.from(
|
||||||
@@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
|
||||||
|
characterPageRequests: number;
|
||||||
|
firstResultFromCache: boolean;
|
||||||
|
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
|
||||||
|
secondResultFromCache: boolean;
|
||||||
|
}> {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||||
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
|||||||
}) as typeof globalThis.fetch;
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let tokenizerCalls = 0;
|
|
||||||
const runtime = createCharacterDictionaryRuntimeService({
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
userDataPath,
|
userDataPath,
|
||||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
|||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
}),
|
}),
|
||||||
getNameMatchImagesEnabled: () => false,
|
getNameMatchImagesEnabled: () => false,
|
||||||
tokenizeJapaneseName: async () => {
|
tokenizeJapaneseName,
|
||||||
tokenizerCalls += 1;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
getJapaneseNameTokenizerAvailable: () => true,
|
getJapaneseNameTokenizerAvailable: () => true,
|
||||||
now: () => 1_700_000_000_500,
|
now: () => 1_700_000_000_500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.generateForCurrentMedia();
|
const firstResult = await runtime.generateForCurrentMedia();
|
||||||
const refreshedSnapshot = JSON.parse(
|
const refreshedSnapshot = JSON.parse(
|
||||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||||
) as CharacterDictionarySnapshot;
|
) as CharacterDictionarySnapshot;
|
||||||
|
const secondResult = await runtime.generateForCurrentMedia();
|
||||||
|
|
||||||
assert.equal(result.fromCache, false);
|
return {
|
||||||
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
|
characterPageRequests,
|
||||||
|
firstResultFromCache: firstResult.fromCache,
|
||||||
const retriedResult = await runtime.generateForCurrentMedia();
|
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||||
assert.equal(retriedResult.fromCache, false);
|
secondResultFromCache: secondResult.fromCache,
|
||||||
assert.equal(characterPageRequests, 2);
|
};
|
||||||
assert.equal(tokenizerCalls, 2);
|
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
||||||
|
let tokenizerCalls = 0;
|
||||||
|
const result = await runNameSplitRefreshScenario(async () => {
|
||||||
|
tokenizerCalls += 1;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.firstResultFromCache, false);
|
||||||
|
assert.equal(result.refreshedNameSplitSource, 'heuristic');
|
||||||
|
assert.equal(result.secondResultFromCache, false);
|
||||||
|
assert.equal(result.characterPageRequests, 2);
|
||||||
|
assert.equal(tokenizerCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
|
||||||
|
let tokenizerCalls = 0;
|
||||||
|
const result = await runNameSplitRefreshScenario(async () => {
|
||||||
|
tokenizerCalls += 1;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.firstResultFromCache, false);
|
||||||
|
assert.equal(result.refreshedNameSplitSource, 'mecab');
|
||||||
|
assert.equal(result.secondResultFromCache, true);
|
||||||
|
assert.equal(result.characterPageRequests, 1);
|
||||||
|
assert.equal(tokenizerCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
||||||
|
|||||||
@@ -482,10 +482,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
|
|||||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
|
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -503,6 +503,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('character portrait index readiness refreshes cached subtitle annotations', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const lookupDeps = source.match(
|
||||||
|
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(lookupDeps);
|
||||||
|
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
|
||||||
|
assert.match(
|
||||||
|
lookupDeps,
|
||||||
|
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const depsBlock = source.match(
|
const depsBlock = source.match(
|
||||||
|
|||||||
Reference in New Issue
Block a user