mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -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
@@ -3,20 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import { setLogLevel } from './logger';
|
||||
|
||||
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: {
|
||||
defaults: {
|
||||
httpAgent?: { options?: { keepAlive?: boolean } };
|
||||
httpsAgent?: { options?: { keepAlive?: boolean } };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
|
||||
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
|
||||
|
||||
+612
-1235
File diff suppressed because it is too large.
Load diff
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
import {
|
||||
type CardCreationDeps,
|
||||
createAnkiConfig,
|
||||
createCardCreationDeps,
|
||||
createFakeClient,
|
||||
createFakeMediaGenerator,
|
||||
} from './card-creation-test-utils';
|
||||
|
||||
test('sentence card writes generated audio only to sentence audio field', async () => {
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
@@ -13,128 +17,88 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
mirrorMpvVolume: true,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
addedFields.push(fields);
|
||||
return 42;
|
||||
},
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '字幕' },
|
||||
Sentence: { value: '字幕' },
|
||||
SelectionText: { value: 'Subtitle' },
|
||||
ExpressionAudio: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (
|
||||
_path,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
||||
let review: Awaited<ReturnType<NonNullable<CardCreationDeps['reviewMediaTiming']>>> = {
|
||||
action: 'confirm',
|
||||
startTime: 11.4,
|
||||
endTime: 14.2,
|
||||
};
|
||||
|
||||
const service = new CardCreationService(deps);
|
||||
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
||||
const service = new CardCreationService(
|
||||
createCardCreationDeps({
|
||||
getConfig: () =>
|
||||
createAnkiConfig({
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: { generateAudio: true, mirrorMpvVolume: true, maxMediaDuration: 30 },
|
||||
}),
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: createFakeClient({
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
addedFields.push(fields);
|
||||
return 42;
|
||||
},
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '字幕' },
|
||||
Sentence: { value: '字幕' },
|
||||
SelectionText: { value: 'Subtitle' },
|
||||
ExpressionAudio: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
}),
|
||||
mediaGenerator: createFakeMediaGenerator({
|
||||
generateAudio: async (
|
||||
_path,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
}),
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
reviewMediaTiming: async () => review,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.equal(await service.createSentenceCard('字幕', 12, 14, 'Subtitle'), true);
|
||||
assert.deepEqual(addedFields[0], {
|
||||
Sentence: '字幕',
|
||||
SelectionText: 'Subtitle',
|
||||
@@ -149,11 +113,11 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
review = { action: 'discard' };
|
||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||
assert.equal(addedFields.length, 1);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
||||
review = { action: 'skip-media' };
|
||||
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
|
||||
assert.equal(addedFields.length, 2);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
export type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
/** Builds the minimal config the service reads; pass only the sections a test cares about. */
|
||||
export function createAnkiConfig(
|
||||
overrides: {
|
||||
fields?: Record<string, string>;
|
||||
media?: Record<string, unknown>;
|
||||
} = {},
|
||||
): AnkiConnectConfig {
|
||||
return {
|
||||
deck: 'Mining',
|
||||
fields: { sentence: 'Sentence', audio: 'SentenceAudio', ...overrides.fields },
|
||||
media: { generateAudio: false, generateImage: false, ...overrides.media },
|
||||
behavior: {},
|
||||
ai: false,
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake CardCreationService dependencies: a bare mpv session, a client that adds note 42, no media,
|
||||
* and field resolvers that match exact field names. Override only what the test exercises.
|
||||
*/
|
||||
export function createCardCreationDeps(
|
||||
overrides: Partial<CardCreationDeps> = {},
|
||||
): CardCreationDeps {
|
||||
return {
|
||||
getConfig: () => createAnkiConfig(),
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentTimePos: 1.5,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Anki client overrides, kept separate so a test can swap one method without redeclaring the rest. */
|
||||
export function createFakeClient(
|
||||
overrides: Partial<CardCreationDeps['client']> = {},
|
||||
): CardCreationDeps['client'] {
|
||||
return { ...createCardCreationDeps().client, ...overrides };
|
||||
}
|
||||
|
||||
export function createFakeMediaGenerator(
|
||||
overrides: Partial<CardCreationDeps['mediaGenerator']> = {},
|
||||
): CardCreationDeps['mediaGenerator'] {
|
||||
return { ...createCardCreationDeps().mediaGenerator, ...overrides };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -500,3 +500,38 @@ test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty
|
||||
|
||||
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields keeps independent groups for identical sentence, audio, and image values', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
202,
|
||||
101,
|
||||
makeNote(202, {
|
||||
Sentence: 'same sentence',
|
||||
SentenceAudio: '[sound:same.mp3]',
|
||||
Picture: '<img src="same.png">',
|
||||
ExpressionAudio: '[sound:same.mp3]',
|
||||
}),
|
||||
makeNote(101, {
|
||||
Sentence: 'same sentence',
|
||||
SentenceAudio: '[sound:same.mp3]',
|
||||
Picture: '<img src="same.png">',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
|
||||
);
|
||||
assert.equal('ExpressionAudio' in merged, false);
|
||||
});
|
||||
@@ -127,18 +127,6 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a cache built under the old tier rules is invalidated', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
||||
};
|
||||
// v1 rules put lapsed cards in young because the interval queries did not
|
||||
// exclude is:learn; those persisted tiers must not be served under v2.
|
||||
assert.notEqual(
|
||||
getKnownWordCacheLifecycleConfig(config),
|
||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
||||
);
|
||||
});
|
||||
|
||||
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
|
||||
@@ -223,127 +223,6 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
fieldGroupingMode: 'manual',
|
||||
wordCardKind: 'click',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: 'x' },
|
||||
IsClickCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
IsClickCard: 'x',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
fieldGroupingMode: 'manual',
|
||||
wordCardKind: 'none',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'sentence expression' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: 'x' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
||||
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
|
||||
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
|
||||
assert.equal(
|
||||
packageJson.scripts['test:coverage:src'],
|
||||
'bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src',
|
||||
);
|
||||
});
|
||||
|
||||
test('source and coverage scripts discover the same maintained source lane', () => {
|
||||
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
|
||||
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
|
||||
/run-coverage-lane\.ts\s+([^\s]+)/,
|
||||
)?.[1];
|
||||
|
||||
assert.equal(sourceLane, 'bun-src-full');
|
||||
assert.equal(coverageLane, sourceLane);
|
||||
});
|
||||
|
||||
test('environment suite owns launcher smoke execution', () => {
|
||||
assert.match(
|
||||
packageJson.scripts['test:env'] ?? '',
|
||||
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ci delegates its gate instead of duplicating quality steps', () => {
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
|
||||
);
|
||||
assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/);
|
||||
assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/);
|
||||
assert.doesNotMatch(ciWorkflow, /bun run test:env/);
|
||||
});
|
||||
|
||||
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
|
||||
assert.match(docsPagesWorkflow, /name: Docs Pages/);
|
||||
assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
|
||||
assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
|
||||
assert.match(docsPagesWorkflow, /pages deploy \.\.\/\.tmp\/docs-versioned-site/);
|
||||
assert.match(docsPagesWorkflow, /--branch main/);
|
||||
});
|
||||
|
||||
test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => {
|
||||
assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/);
|
||||
assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/);
|
||||
assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/);
|
||||
assert.match(docsPagesWorkflow, /--require-archives/);
|
||||
assert.match(docsPagesWorkflow, /--rebuild-archives=\$\{REBUILD_ARCHIVES\}/);
|
||||
assert.match(docsPagesWorkflow, /workingDirectory: docs-site/);
|
||||
});
|
||||
|
||||
test('docs deploy skips invalid release tags without failing the workflow', () => {
|
||||
assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
|
||||
assert.match(docsPagesWorkflow, /stable_tag=false/);
|
||||
assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
|
||||
assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { printHelp } from './help';
|
||||
|
||||
test('printHelp includes configured texthooker port', () => {
|
||||
const original = console.log;
|
||||
let output = '';
|
||||
console.log = (value?: unknown) => {
|
||||
output += String(value);
|
||||
};
|
||||
|
||||
try {
|
||||
printHelp(7777);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
|
||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
assert.match(output, /--dictionary-target/);
|
||||
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
|
||||
assert.match(output, /--jellyfin-login/);
|
||||
assert.match(output, /--jellyfin-subtitles/);
|
||||
assert.match(output, /--jellyfin-play/);
|
||||
});
|
||||
+103
-1004
File diff suppressed because it is too large.
Load diff
@@ -2,17 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_KEYBINDINGS,
|
||||
RUNTIME_OPTION_REGISTRY,
|
||||
} from '../definitions';
|
||||
import { buildCoreConfigOptionRegistry } from './options-core';
|
||||
import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
||||
import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG } from '../definitions';
|
||||
|
||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
const leaves: string[] = [];
|
||||
@@ -87,58 +77,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'logging.files.mpv',
|
||||
'annotationWebsocket.enabled',
|
||||
'controller.enabled',
|
||||
'controller.scrollPixelsPerSecond',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'updates.channel',
|
||||
'youtube.primarySubLanguages',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
'mpv.profile',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.autoStartSubMiner',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
}
|
||||
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('known-word annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('n+1 annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
@@ -168,77 +106,10 @@ test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEA
|
||||
);
|
||||
});
|
||||
|
||||
test('config template sections include expected domains and unique keys', () => {
|
||||
test('config option paths and template section keys are unique', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'controller',
|
||||
'startupWarmups',
|
||||
'youtube',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'yomitan',
|
||||
'mpv',
|
||||
'immersionTracking',
|
||||
];
|
||||
|
||||
for (const requiredKey of requiredKeys) {
|
||||
assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
|
||||
}
|
||||
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
});
|
||||
|
||||
test('domain registry builders each contribute entries to composed registry', () => {
|
||||
const domainEntries = [
|
||||
buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
|
||||
buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
];
|
||||
const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
|
||||
for (const entries of domainEntries) {
|
||||
assert.ok(entries.length > 0);
|
||||
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
|
||||
}
|
||||
});
|
||||
|
||||
test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
|
||||
});
|
||||
|
||||
test('default keybindings include fullscreen on F', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
|
||||
});
|
||||
|
||||
test('default keybindings include replay and next subtitle controls', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
||||
});
|
||||
|
||||
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
|
||||
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
|
||||
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
|
||||
});
|
||||
@@ -11,209 +11,6 @@ function field(path: string) {
|
||||
return match;
|
||||
}
|
||||
|
||||
test('settings registry splits viewing into appearance and behavior categories', () => {
|
||||
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
|
||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior');
|
||||
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||
assert.equal(field('auto_start_overlay').category, 'behavior');
|
||||
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
|
||||
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
||||
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
|
||||
assert.equal(field('mpv.profile').category, 'behavior');
|
||||
assert.equal(field('mpv.profile').section, 'mpv Playback');
|
||||
assert.ok(
|
||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||
fields.findIndex(
|
||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
fields.findIndex(
|
||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry groups playback startup controls under playback behavior', () => {
|
||||
for (const path of [
|
||||
'subtitleStyle.autoPauseVideoOnHover',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'subtitleSidebar.pauseVideoOnHover',
|
||||
'mpv.autoStartSubMiner',
|
||||
'auto_start_overlay',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
]) {
|
||||
assert.equal(field(path).category, 'behavior', path);
|
||||
assert.equal(field(path).section, 'Playback Behavior', path);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
||||
});
|
||||
|
||||
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
|
||||
assert.equal(
|
||||
field('shortcuts.openCharacterDictionaryManager').label,
|
||||
'Open Character Dictionary Manager',
|
||||
);
|
||||
assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels');
|
||||
});
|
||||
|
||||
test('settings registry hides removed modal-only fields', () => {
|
||||
for (const path of [
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.directPlayContainers',
|
||||
]) {
|
||||
assert.equal(
|
||||
fields.some((candidate) => candidate.configPath === path),
|
||||
false,
|
||||
path,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry orders websocket server immediately after annotation websocket', () => {
|
||||
const integrationSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'integrations')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
|
||||
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
|
||||
});
|
||||
|
||||
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
|
||||
assert.equal(field('websocket.enabled').defaultValue, false);
|
||||
assert.equal(
|
||||
field('websocket.enabled').description,
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry places immersion tracking after other tracking and app sections', () => {
|
||||
const trackingSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'tracking-app')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
assert.equal(trackingSections.at(-1), 'Immersion tracking');
|
||||
});
|
||||
|
||||
test('settings registry groups annotation display fields by config group', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
||||
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
|
||||
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
|
||||
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
|
||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
||||
});
|
||||
|
||||
test('settings registry groups maturity settings under Known Words with color controls', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
|
||||
for (const tier of ['new', 'learning', 'young', 'mature']) {
|
||||
const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
|
||||
assert.equal(tierField.subsection, 'Known Words');
|
||||
assert.equal(tierField.control, 'color');
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry orders maturity colors from least mature to mature', () => {
|
||||
const knownWordsPaths = fields
|
||||
.filter((candidate) => candidate.subsection === 'Known Words')
|
||||
.map((candidate) => candidate.configPath);
|
||||
assert.deepEqual(knownWordsPaths, [
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.maturityEnabled',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'ankiConnect.knownWords.matureThresholdDays',
|
||||
'subtitleStyle.knownWordMaturityColors.new',
|
||||
'subtitleStyle.knownWordMaturityColors.learning',
|
||||
'subtitleStyle.knownWordMaturityColors.young',
|
||||
'subtitleStyle.knownWordMaturityColors.mature',
|
||||
]);
|
||||
});
|
||||
|
||||
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
||||
assert.equal(
|
||||
field('subtitleStyle.frequencyDictionary.sourcePath').section,
|
||||
'Frequency Highlighting',
|
||||
);
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
||||
});
|
||||
|
||||
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
});
|
||||
|
||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||
assert.equal(field('ankiConnect.deck').control, 'anki-deck');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
||||
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
||||
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
||||
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
|
||||
assert.equal(field('stats.toggleKey').control, 'key-code');
|
||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||
});
|
||||
|
||||
test('settings registry exposes mpv volume mirroring as a mining toggle', () => {
|
||||
const volumeField = field('ankiConnect.media.mirrorMpvVolume');
|
||||
|
||||
assert.equal(volumeField.category, 'mining-anki');
|
||||
assert.equal(volumeField.section, 'Media Capture');
|
||||
assert.equal(volumeField.control, 'boolean');
|
||||
assert.equal(volumeField.defaultValue, true);
|
||||
});
|
||||
|
||||
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
||||
const mediaCacheMode = field('youtube.mediaCache.mode');
|
||||
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
||||
|
||||
assert.equal(mediaCacheMode.control, 'select');
|
||||
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
|
||||
assert.deepEqual(mediaCacheMode.enumLabels, {
|
||||
direct: 'Direct stream extraction',
|
||||
background: 'Background media cache',
|
||||
});
|
||||
assert.equal(mediaCacheMaxHeight.control, 'number');
|
||||
assert.equal(mediaCacheMaxHeight.defaultValue, 720);
|
||||
});
|
||||
|
||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||
const primaryVisible = fields
|
||||
.filter(
|
||||
@@ -268,18 +65,6 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
|
||||
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
|
||||
});
|
||||
|
||||
test('settings registry routes playback-related integrations into integrations', () => {
|
||||
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||
assert.equal(field('subsync.replace').category, 'integrations');
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
|
||||
assert.equal(field('tmdb.apiKey').category, 'integrations');
|
||||
assert.equal(field('tmdb.apiKey').section, 'TMDB');
|
||||
assert.equal(field('tmdb.apiKey').secret, true);
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||
@@ -309,89 +94,3 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
||||
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||
for (const hiddenPath of [
|
||||
'ai.enabled',
|
||||
'ai.apiKey',
|
||||
'ai.apiKeyCommand',
|
||||
'ai.model',
|
||||
'ai.baseUrl',
|
||||
'ai.systemPrompt',
|
||||
'ai.requestTimeoutMs',
|
||||
'ankiConnect.ai.enabled',
|
||||
'ankiConnect.ai.model',
|
||||
'ankiConnect.ai.systemPrompt',
|
||||
'ankiConnect.fields.translation',
|
||||
'controller.bindings',
|
||||
'controller.preferredGamepadId',
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
||||
}
|
||||
assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
|
||||
});
|
||||
|
||||
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
for (const path of [
|
||||
'mpv.aniskipButtonKey',
|
||||
'stats.toggleKey',
|
||||
'stats.markWatchedKey',
|
||||
'logging.level',
|
||||
'logging.rotation',
|
||||
'logging.files.app',
|
||||
'logging.files.launcher',
|
||||
'logging.files.mpv',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku.apiBaseUrl',
|
||||
'jimaku.languagePreference',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
'ankiConnect.knownWords.matchMode',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.wordAudio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'ankiConnect.isSenren.fieldGrouping',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'hot-reload', path);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry does not expose removed subsync mode option', () => {
|
||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||
assert.equal(paths.has('subsync.defaultMode'), false);
|
||||
});
|
||||
|
||||
test('settings registry keeps unsafe config siblings restart-required', () => {
|
||||
for (const path of [
|
||||
'stats.serverPort',
|
||||
'ankiConnect.url',
|
||||
'ankiConnect.proxy.enabled',
|
||||
'mpv.socketPath',
|
||||
'mpv.profile',
|
||||
'websocket.port',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'restart', path);
|
||||
}
|
||||
});
|
||||
@@ -5,30 +5,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
|
||||
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
type ImmersionTrackerServiceCtor =
|
||||
typeof import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
|
||||
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
||||
|
||||
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
||||
if (trackerCtor) return trackerCtor;
|
||||
const mod = await import('../immersion-tracker-service');
|
||||
trackerCtor = mod.ImmersionTrackerService;
|
||||
return trackerCtor;
|
||||
}
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
interface TrackerInternals {
|
||||
db: DatabaseSync;
|
||||
queue: unknown[];
|
||||
@@ -42,155 +18,38 @@ interface TrackerInternals {
|
||||
writeLock: { locked: boolean };
|
||||
}
|
||||
|
||||
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
interface TrackerHarness {
|
||||
tracker: TrackerInternals;
|
||||
deleteRunnerCalls: () => number;
|
||||
}
|
||||
|
||||
/** Runs `fn` against a fresh tracker (batchSize 2) seeded with two library entries. */
|
||||
async function withTracker(fn: (harness: TrackerHarness) => Promise<void>): Promise<void> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||
const { ImmersionTrackerService } = await import('../immersion-tracker-service');
|
||||
let deleteRunnerCalls = 0;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor(
|
||||
{ dbPath, policy: { batchSize: 2 } },
|
||||
{
|
||||
runDeleteMaintenanceTask: async () => {
|
||||
deleteRunnerCalls += 1;
|
||||
},
|
||||
const service = new ImmersionTrackerService(
|
||||
{ dbPath: path.join(dir, 'immersion.sqlite'), policy: { batchSize: 2 } },
|
||||
{
|
||||
runDeleteMaintenanceTask: async () => {
|
||||
deleteRunnerCalls += 1;
|
||||
},
|
||||
);
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
let flushCalls = 0;
|
||||
internals.flushNow = () => {
|
||||
flushCalls += 1;
|
||||
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
||||
};
|
||||
|
||||
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
||||
|
||||
assert.equal(flushCalls, 1);
|
||||
assert.equal(deleteRunnerCalls, 0);
|
||||
assert.equal(internals.writeLock.locked, false);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
},
|
||||
);
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(
|
||||
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||
/queue did not drain/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare(
|
||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{ animeId: 1, anilistId: null },
|
||||
{ animeId: 2, anilistId: 123 },
|
||||
],
|
||||
);
|
||||
const tracker = service as unknown as TrackerInternals;
|
||||
seedTwoEntries(tracker.db);
|
||||
await fn({ tracker, deleteRunnerCalls: () => deleteRunnerCalls });
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
service.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
|
||||
.all()
|
||||
.map((row) => (row as { animeId: number }).animeId),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
||||
assert.equal(
|
||||
(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
|
||||
.get() as {
|
||||
animeId: number;
|
||||
}
|
||||
).animeId,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function seedTwoEntries(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
|
||||
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'show', 'Show', NULL, 1000, 1000), (2, 'show season 1', 'Show Season 1', 123, 1000, 1000);
|
||||
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
||||
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
||||
@@ -244,25 +103,53 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
||||
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT lines_seen AS linesSeen
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = ?
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId) as { linesSeen: number } | undefined;
|
||||
return row ? Number(row.linesSeen) : null;
|
||||
const SNAPSHOT_TABLES = [
|
||||
'imm_anime',
|
||||
'imm_videos',
|
||||
'imm_sessions',
|
||||
'imm_subtitle_lines',
|
||||
'imm_session_telemetry',
|
||||
'imm_lifetime_global',
|
||||
'imm_lifetime_anime',
|
||||
'imm_lifetime_media',
|
||||
'imm_lifetime_applied_sessions',
|
||||
];
|
||||
|
||||
function snapshotDb(db: DatabaseSync): Record<string, unknown[]> {
|
||||
return Object.fromEntries(
|
||||
SNAPSHOT_TABLES.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all()]),
|
||||
);
|
||||
}
|
||||
|
||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
||||
.get(animeId) as { total: number };
|
||||
return Number(row.total);
|
||||
const guardedOperations: Array<{
|
||||
name: string;
|
||||
run: (tracker: TrackerInternals) => Promise<unknown>;
|
||||
}> = [
|
||||
{ name: 'deleteSession', run: (tracker) => tracker.deleteSession(1) },
|
||||
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
|
||||
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
|
||||
{ name: 'rebuildLifetimeSummaries', run: (tracker) => tracker.rebuildLifetimeSummaries() },
|
||||
{
|
||||
// Anime 2 already owns AniList id 123, so this would otherwise resolve a conflict.
|
||||
name: 'reassignAnimeAnilist',
|
||||
run: (tracker) => tracker.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const operation of guardedOperations) {
|
||||
test(`${operation.name} fails closed without touching the database when queued writes cannot drain`, async () => {
|
||||
await withTracker(async ({ tracker, deleteRunnerCalls }) => {
|
||||
queueSubtitleLines(tracker, 1);
|
||||
tracker.flushNow = () => {};
|
||||
const before = snapshotDb(tracker.db);
|
||||
|
||||
await assert.rejects(operation.run(tracker), /queue did not drain/i);
|
||||
|
||||
assert.deepEqual(snapshotDb(tracker.db), before);
|
||||
assert.equal(deleteRunnerCalls(), 0);
|
||||
assert.equal(tracker.writeLock.locked, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,52 +158,36 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||
* repoints rows.
|
||||
*/
|
||||
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
const repointingOperations: Array<{
|
||||
name: string;
|
||||
run: (tracker: TrackerInternals) => Promise<unknown>;
|
||||
}> = [
|
||||
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
|
||||
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
|
||||
];
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
for (const operation of repointingOperations) {
|
||||
test(`${operation.name} drains a queue larger than one batch before repointing rows`, async () => {
|
||||
await withTracker(async ({ tracker }) => {
|
||||
queueSubtitleLines(tracker, 8);
|
||||
queueTelemetry(tracker, 8);
|
||||
assert.ok(tracker.queue.length > 2, 'expected more queued writes than one batch');
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
|
||||
await operation.run(tracker);
|
||||
|
||||
await internals.mergeAnime(1, [2]);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
// Every queued line landed, attributed to the surviving entry.
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
|
||||
await internals.moveVideoToAnime(2, 1);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
assert.equal(tracker.queue.length, 0);
|
||||
// Every queued line and the trailing telemetry sample landed on the surviving entry.
|
||||
const lines = tracker.db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1')
|
||||
.get() as { total: number };
|
||||
assert.equal(Number(lines.total), 8);
|
||||
const telemetry = tracker.db
|
||||
.prepare(
|
||||
`SELECT lines_seen AS linesSeen FROM imm_session_telemetry
|
||||
WHERE session_id = 1 ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1`,
|
||||
)
|
||||
.get() as { linesSeen: number } | undefined;
|
||||
assert.equal(Number(telemetry?.linesSeen), 8);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import {
|
||||
registerStatsAnalyticsRoutes,
|
||||
registerStatsIntegrationRoutes,
|
||||
registerStatsLibraryRoutes,
|
||||
registerStatsMiningRoutes,
|
||||
registerStatsStaticRoutes,
|
||||
} from '../stats-server/routes.js';
|
||||
|
||||
describe('stats server route groups', () => {
|
||||
test('exposes focused registrars for createStatsApp composition', () => {
|
||||
for (const registrar of [
|
||||
registerStatsAnalyticsRoutes,
|
||||
registerStatsLibraryRoutes,
|
||||
registerStatsIntegrationRoutes,
|
||||
registerStatsMiningRoutes,
|
||||
registerStatsStaticRoutes,
|
||||
]) {
|
||||
assert.equal(typeof registrar, 'function');
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -158,33 +158,6 @@ function createHarness(): RuntimeHarness {
|
||||
return { options, registered, state };
|
||||
}
|
||||
|
||||
test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
||||
const { registered } = createHarness();
|
||||
const expected = [
|
||||
'setAnkiConnectEnabled',
|
||||
'clearAnkiHistory',
|
||||
'refreshKnownWords',
|
||||
'respondFieldGrouping',
|
||||
'buildKikuMergePreview',
|
||||
'getJimakuMediaInfo',
|
||||
'searchJimakuEntries',
|
||||
'listJimakuFiles',
|
||||
'resolveJimakuApiKey',
|
||||
'getCurrentMediaPath',
|
||||
'isRemoteMediaPath',
|
||||
'downloadToFile',
|
||||
'onDownloadedSubtitle',
|
||||
'searchTsukihimeEntries',
|
||||
'listTsukihimeFiles',
|
||||
'downloadTsukihimeSubtitle',
|
||||
'onDownloadedSecondarySubtitle',
|
||||
];
|
||||
|
||||
for (const key of expected) {
|
||||
assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshKnownWords throws when integration is unavailable', async () => {
|
||||
const { registered } = createHarness();
|
||||
|
||||
@@ -196,20 +169,6 @@ test('refreshKnownWords throws when integration is unavailable', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshKnownWords delegates to integration', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
let refreshed = 0;
|
||||
state.ankiIntegration = {
|
||||
refreshKnownWordCache: async () => {
|
||||
refreshed += 1;
|
||||
},
|
||||
};
|
||||
|
||||
await registered.refreshKnownWords!();
|
||||
|
||||
assert.equal(refreshed, 1);
|
||||
});
|
||||
|
||||
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
|
||||
const { registered, state } = createHarness();
|
||||
let destroyed = 0;
|
||||
@@ -274,30 +233,6 @@ test('buildKikuMergePreview returns guard error when integration is missing', as
|
||||
});
|
||||
});
|
||||
|
||||
test('buildKikuMergePreview delegates to integration when available', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
const calls: unknown[] = [];
|
||||
state.ankiIntegration = {
|
||||
buildFieldGroupingPreview: async (
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
deleteDuplicate: boolean,
|
||||
) => {
|
||||
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await registered.buildKikuMergePreview!({
|
||||
keepNoteId: 3,
|
||||
deleteNoteId: 4,
|
||||
deleteDuplicate: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [[3, 4, true]]);
|
||||
assert.deepEqual(result, { ok: true });
|
||||
});
|
||||
|
||||
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
|
||||
@@ -103,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
@@ -226,22 +215,6 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
createImmersionTracker: undefined,
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
CliCommandServiceDeps,
|
||||
createCliCommandDepsRuntime,
|
||||
handleCliCommand,
|
||||
} from './cli-command';
|
||||
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
@@ -557,135 +553,6 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
||||
assert.ok(calls.includes('connectMpvClient'));
|
||||
});
|
||||
|
||||
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
setSocketPath: (socketPath: string) => {
|
||||
calls.push(`setSocketPath:${socketPath}`);
|
||||
},
|
||||
connect: () => {
|
||||
calls.push('connect');
|
||||
},
|
||||
reconnect: () => {
|
||||
calls.push('reconnect');
|
||||
},
|
||||
};
|
||||
const deps = createCliCommandDepsRuntime({
|
||||
mpv: {
|
||||
getSocketPath: () => '/tmp/runtime.sock',
|
||||
setSocketPath: () => {},
|
||||
getClient: () => client,
|
||||
showOsd: () => {},
|
||||
},
|
||||
texthooker: {
|
||||
service: { isRunning: () => false, start: () => {} },
|
||||
getPort: () => 5174,
|
||||
setPort: () => {},
|
||||
getWebsocketUrl: () => undefined,
|
||||
shouldOpenBrowser: () => false,
|
||||
openInBrowser: () => {},
|
||||
},
|
||||
overlay: {
|
||||
isInitialized: () => true,
|
||||
initialize: () => {},
|
||||
toggleVisible: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
setVisible: () => {},
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWords: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
},
|
||||
anilist: {
|
||||
getStatus: () => ({
|
||||
tokenStatus: 'not_checked',
|
||||
tokenSource: 'none',
|
||||
tokenMessage: null,
|
||||
tokenResolvedAt: null,
|
||||
tokenErrorAt: null,
|
||||
queuePending: 0,
|
||||
queueReady: 0,
|
||||
queueDeadLetter: 0,
|
||||
queueLastAttemptAt: null,
|
||||
queueLastError: null,
|
||||
}),
|
||||
clearToken: () => {},
|
||||
openSetup: () => {},
|
||||
getQueueStatus: () => ({
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
dictionary: {
|
||||
generate: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 0,
|
||||
}),
|
||||
getSelection: async () => ({
|
||||
seriesKey: 'test',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
}),
|
||||
setSelection: async () => ({
|
||||
ok: true,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 1, title: 'Test', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runCommand: async () => {},
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openYoutubeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
},
|
||||
app: {
|
||||
stop: () => {},
|
||||
hasMainWindow: () => true,
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
getMultiCopyTimeoutMs: () => 2500,
|
||||
schedule: () => undefined,
|
||||
log: () => {},
|
||||
logDebug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
deps.setMpvClientSocketPath('/tmp/runtime.sock');
|
||||
deps.connectMpvClient();
|
||||
|
||||
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
|
||||
});
|
||||
|
||||
test('handleCliCommand warns when texthooker port override used while running', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isTexthookerRunning: () => true,
|
||||
@@ -882,13 +749,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand logs AniList status details', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
||||
assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
|
||||
assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
|
||||
});
|
||||
|
||||
test('handleCliCommand runs AniList retry command', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
|
||||
|
||||
@@ -49,28 +49,6 @@ test('buildDiscordPresenceActivity falls back to idle with default style', () =>
|
||||
assert.equal(payload.details, 'Sentence Mining');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses meme style fallback', () => {
|
||||
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
|
||||
const payload = buildDiscordPresenceActivity(memeConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
|
||||
assert.equal(payload.smallImageText, 'Sentence Mining');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses japanese style', () => {
|
||||
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
|
||||
const payload = buildDiscordPresenceActivity(jpConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.details, '文の採掘中');
|
||||
assert.equal(payload.smallImageText, 'イマージョン学習');
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity uses minimal style', () => {
|
||||
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
|
||||
const payload = buildDiscordPresenceActivity(minConfig, {
|
||||
|
||||
@@ -3,78 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice } from '../../types';
|
||||
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
|
||||
|
||||
test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
|
||||
const sent: unknown[][] = [];
|
||||
let visible = false;
|
||||
const restore = new Set<'runtime-options' | 'subsync'>();
|
||||
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isLoading: () => false,
|
||||
send: (...args: unknown[]) => {
|
||||
sent.push(args);
|
||||
},
|
||||
},
|
||||
}),
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => restore,
|
||||
});
|
||||
|
||||
const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.equal(visible, true);
|
||||
assert.equal(restore.has('runtime-options'), true);
|
||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = next;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
|
||||
});
|
||||
|
||||
const callback = runtime.createFieldGroupingCallback();
|
||||
const result = await callback({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(result.keepNoteId, 0);
|
||||
assert.equal(result.deleteNoteId, 0);
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
|
||||
let resolver: unknown = null;
|
||||
let visible = false;
|
||||
|
||||
@@ -98,124 +98,59 @@ test('field grouping callback does not reject the next request after a response'
|
||||
assert.equal(secondChoice.keepNoteId, 2);
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
});
|
||||
const dismissCases = [
|
||||
{ name: 'the send fails', sends: false, responseTimeoutMs: undefined },
|
||||
{ name: 'the response times out', sends: true, responseTimeoutMs: 5 },
|
||||
];
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
for (const c of dismissCases) {
|
||||
test(`field grouping callback dismisses the modal UI when ${c.name}`, async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
sendRequestToVisibleOverlay: () => c.sends,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: c.responseTimeoutMs,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback reports modal dismiss failures', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const errors: unknown[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
await callback(makeRequestData());
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
test(`field grouping callback still cancels and clears the resolver when ${c.name} and dismiss throws`, async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => c.sends,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: c.responseTimeoutMs,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
|
||||
@@ -499,36 +499,13 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
|
||||
});
|
||||
|
||||
assert.equal(placed, true);
|
||||
assert.deepEqual(
|
||||
calls.map(([, args]) => args),
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['-j', 'status'],
|
||||
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
|
||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })',
|
||||
],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })',
|
||||
],
|
||||
['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'],
|
||||
],
|
||||
);
|
||||
const dispatches = calls
|
||||
.map(([, args]) => args as string[])
|
||||
.filter(([verb]) => verb === 'dispatch');
|
||||
assert.ok(dispatches.length > 0);
|
||||
for (const [, dispatcher] of dispatches) {
|
||||
assert.ok(dispatcher!.startsWith('hl.dsp.window.'), dispatcher);
|
||||
}
|
||||
});
|
||||
|
||||
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { toMonthKey } from './immersion-tracker/maintenance';
|
||||
import { enqueueWrite } from './immersion-tracker/queue';
|
||||
import { toDbTimestamp } from './immersion-tracker/query-shared';
|
||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||
@@ -137,11 +136,6 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
|
||||
assert.equal((queue[1] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 3);
|
||||
});
|
||||
|
||||
test('seam: toMonthKey uses UTC calendar month', () => {
|
||||
assert.equal(toMonthKey(-86_400_000), 196912);
|
||||
assert.equal(toMonthKey(0), 197001);
|
||||
});
|
||||
|
||||
test('startSession generates UUID-like session identifiers', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -528,37 +522,6 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh tracker DB creates lifetime summary tables', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const tableRows = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as Array<{ name: string }>;
|
||||
db.close();
|
||||
|
||||
const tableNames = new Set(tableRows.map((row) => row.name));
|
||||
const expectedTables = [
|
||||
'imm_lifetime_global',
|
||||
'imm_lifetime_anime',
|
||||
'imm_lifetime_media',
|
||||
'imm_lifetime_applied_sessions',
|
||||
];
|
||||
|
||||
for (const tableName of expectedTables) {
|
||||
assert.ok(tableNames.has(tableName), `Expected ${tableName} to exist`);
|
||||
}
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh tracker DB skips lexical rollup backfill work', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -3773,138 +3736,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('flushSingle reuses cached prepared statements', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let originalPrepare: DatabaseSync['prepare'] | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
flushSingle: (write: {
|
||||
kind: 'telemetry' | 'event';
|
||||
sessionId: number;
|
||||
sampleMs: number;
|
||||
eventType?: number;
|
||||
lineIndex?: number | null;
|
||||
segmentStartMs?: number | null;
|
||||
segmentEndMs?: number | null;
|
||||
tokensDelta?: number;
|
||||
cardsDelta?: number;
|
||||
payloadJson?: string | null;
|
||||
totalWatchedMs?: number;
|
||||
activeWatchedMs?: number;
|
||||
linesSeen?: number;
|
||||
tokensSeen?: number;
|
||||
cardsMined?: number;
|
||||
lookupCount?: number;
|
||||
lookupHits?: number;
|
||||
yomitanLookupCount?: number;
|
||||
pauseCount?: number;
|
||||
pauseMs?: number;
|
||||
seekForwardCount?: number;
|
||||
seekBackwardCount?: number;
|
||||
mediaBufferEvents?: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
originalPrepare = privateApi.db.prepare;
|
||||
let prepareCalls = 0;
|
||||
privateApi.db.prepare = (...args: Parameters<DatabaseSync['prepare']>) => {
|
||||
prepareCalls += 1;
|
||||
return originalPrepare!.apply(privateApi.db, args);
|
||||
};
|
||||
const preparedRestore = originalPrepare;
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
source_type,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'local:/tmp/prepared.mkv',
|
||||
'Prepared',
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
)
|
||||
`);
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE,
|
||||
ended_at_ms
|
||||
) VALUES (
|
||||
1,
|
||||
'33333333-3333-3333-3333-333333333333',
|
||||
1,
|
||||
1000,
|
||||
2,
|
||||
1000,
|
||||
1000,
|
||||
2000
|
||||
)
|
||||
`);
|
||||
|
||||
privateApi.flushSingle({
|
||||
kind: 'telemetry',
|
||||
sessionId: 1,
|
||||
sampleMs: 1500,
|
||||
totalWatchedMs: 1000,
|
||||
activeWatchedMs: 1000,
|
||||
linesSeen: 1,
|
||||
tokensSeen: 2,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
pauseCount: 0,
|
||||
pauseMs: 0,
|
||||
seekForwardCount: 0,
|
||||
seekBackwardCount: 0,
|
||||
mediaBufferEvents: 0,
|
||||
});
|
||||
|
||||
privateApi.flushSingle({
|
||||
kind: 'event',
|
||||
sessionId: 1,
|
||||
sampleMs: 1600,
|
||||
eventType: 1,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: 0,
|
||||
segmentEndMs: 1000,
|
||||
tokensDelta: 2,
|
||||
cardsDelta: 0,
|
||||
payloadJson: '{"event":"subtitle-line"}',
|
||||
});
|
||||
|
||||
privateApi.db.prepare = preparedRestore;
|
||||
|
||||
assert.equal(prepareCalls, 0);
|
||||
} finally {
|
||||
if (tracker && originalPrepare) {
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
privateApi.db.prepare = originalPrepare;
|
||||
}
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains compatible', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const start = source.indexOf('export function getRollupGroupsForSessions');
|
||||
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
|
||||
const functionSource = source.slice(start, end);
|
||||
|
||||
assert.match(functionSource, /'unixepoch', 'localtime'/);
|
||||
assert.doesNotMatch(functionSource, /UNION/);
|
||||
assert.doesNotMatch(functionSource, /86400000/);
|
||||
});
|
||||
@@ -602,39 +602,6 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('getDailyRollups limits by distinct days (not rows)', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const insert = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
|
||||
insert.run(10, 1, 1, 1, 0, 0, 2);
|
||||
insert.run(10, 2, 1, 1, 0, 0, 3);
|
||||
insert.run(9, 1, 1, 1, 0, 0, 1);
|
||||
insert.run(8, 1, 1, 1, 0, 0, 1);
|
||||
|
||||
const rows = getDailyRollups(db, 2);
|
||||
assert.equal(rows.length, 3);
|
||||
assert.ok(rows.every((r) => r.rollupDayOrMonth === 10 || r.rollupDayOrMonth === 9));
|
||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 1));
|
||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 2));
|
||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 9 && r.videoId === 1));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
@@ -2075,20 +2042,6 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularyStats returns empty array when no words exist', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = getVocabularyStats(db, 10);
|
||||
assert.deepEqual(rows, []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
@@ -2524,20 +2477,6 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getKanjiStats returns empty array when no kanji exist', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = getKanjiStats(db, 10);
|
||||
assert.deepEqual(rows, []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
@@ -2643,29 +2582,6 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents returns empty array for session with no events', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
|
||||
canonicalTitle: 'Events Empty',
|
||||
sourcePath: '/tmp/events-empty.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
|
||||
|
||||
const events = getSessionEvents(db, sessionId, 50);
|
||||
assert.deepEqual(events, []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents filters events to the requested session id', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
@@ -4,12 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from './sqlite';
|
||||
import {
|
||||
pruneRawRetention,
|
||||
pruneRollupRetention,
|
||||
runOptimizeMaintenance,
|
||||
toMonthKey,
|
||||
} from './maintenance';
|
||||
import { pruneRawRetention, pruneRollupRetention, toMonthKey } from './maintenance';
|
||||
import { ensureSchema } from './storage';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
|
||||
@@ -225,42 +220,3 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds sample_ms index for telemetry rollup scans', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const indexes = db.prepare("PRAGMA index_list('imm_session_telemetry')").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const hasSampleMsIndex = indexes.some((row) => row.name === 'idx_telemetry_sample_ms');
|
||||
assert.equal(hasSampleMsIndex, true);
|
||||
|
||||
const indexColumns = db.prepare("PRAGMA index_info('idx_telemetry_sample_ms')").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
assert.deepEqual(
|
||||
indexColumns.map((column) => column.name),
|
||||
['sample_ms'],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('runOptimizeMaintenance executes PRAGMA optimize', () => {
|
||||
const executedSql: string[] = [];
|
||||
const db = {
|
||||
exec(source: string) {
|
||||
executedSql.push(source);
|
||||
return this;
|
||||
},
|
||||
} as unknown as Parameters<typeof runOptimizeMaintenance>[0];
|
||||
|
||||
runOptimizeMaintenance(db);
|
||||
|
||||
assert.deepEqual(executedSql, ['PRAGMA optimize']);
|
||||
});
|
||||
@@ -59,34 +59,6 @@ function cleanupDbPath(dbPath: string): void {
|
||||
// libsql keeps Windows file handles alive after close when prepared statements were used.
|
||||
}
|
||||
|
||||
test('applyPragmas sets the SQLite tuning defaults used by immersion tracking', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
applyPragmas(db);
|
||||
|
||||
const journalModeRow = db.prepare('PRAGMA journal_mode').get() as {
|
||||
journal_mode: string;
|
||||
};
|
||||
const synchronousRow = db.prepare('PRAGMA synchronous').get() as { synchronous: number };
|
||||
const foreignKeysRow = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
|
||||
const busyTimeoutRow = db.prepare('PRAGMA busy_timeout').get() as { timeout: number };
|
||||
const journalSizeLimitRow = db.prepare('PRAGMA journal_size_limit').get() as {
|
||||
journal_size_limit: number;
|
||||
};
|
||||
|
||||
assert.equal(journalModeRow.journal_mode, 'wal');
|
||||
assert.equal(synchronousRow.synchronous, 1);
|
||||
assert.equal(foreignKeysRow.foreign_keys, 1);
|
||||
assert.equal(busyTimeoutRow.timeout, 2500);
|
||||
assert.equal(journalSizeLimitRow.journal_size_limit, 67_108_864);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
||||
const statements: string[] = [];
|
||||
const db: DatabaseSync = {
|
||||
@@ -110,76 +82,6 @@ test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureSchema creates immersion core tables', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const tableNames = new Set(rows.map((row) => row.name));
|
||||
|
||||
assert.ok(tableNames.has('imm_videos'));
|
||||
assert.ok(tableNames.has('imm_anime'));
|
||||
assert.ok(tableNames.has('imm_sessions'));
|
||||
assert.ok(tableNames.has('imm_session_telemetry'));
|
||||
assert.ok(tableNames.has('imm_session_events'));
|
||||
assert.ok(tableNames.has('imm_daily_rollups'));
|
||||
assert.ok(tableNames.has('imm_monthly_rollups'));
|
||||
assert.ok(tableNames.has('imm_words'));
|
||||
assert.ok(tableNames.has('imm_kanji'));
|
||||
assert.ok(tableNames.has('imm_subtitle_lines'));
|
||||
assert.ok(tableNames.has('imm_word_line_occurrences'));
|
||||
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
||||
assert.ok(tableNames.has('imm_rollup_state'));
|
||||
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
||||
assert.ok(tableNames.has('imm_youtube_videos'));
|
||||
assert.ok(tableNames.has('imm_stats_excluded_words'));
|
||||
|
||||
const videoColumns = new Set(
|
||||
(
|
||||
db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
|
||||
assert.ok(videoColumns.has('anime_id'));
|
||||
assert.ok(videoColumns.has('parsed_basename'));
|
||||
assert.ok(videoColumns.has('parsed_title'));
|
||||
assert.ok(videoColumns.has('parsed_season'));
|
||||
assert.ok(videoColumns.has('parsed_episode'));
|
||||
assert.ok(videoColumns.has('parser_source'));
|
||||
assert.ok(videoColumns.has('parser_confidence'));
|
||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
||||
assert.ok(videoColumns.has('anime_assignment_locked'));
|
||||
|
||||
const mediaArtColumns = new Set(
|
||||
(
|
||||
db.prepare('PRAGMA table_info(imm_media_art)').all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
assert.ok(mediaArtColumns.has('cover_blob_hash'));
|
||||
|
||||
const rollupStateRow = db
|
||||
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
||||
.get('last_rollup_sample_ms') as {
|
||||
state_value: string;
|
||||
} | null;
|
||||
assert.ok(rollupStateRow);
|
||||
assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -752,37 +654,6 @@ test('ensureSchema migrates session event timestamps to text and repairs libsql-
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema creates large-history performance indexes', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const indexNames = new Set(
|
||||
(
|
||||
db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'`)
|
||||
.all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
|
||||
assert.ok(indexNames.has('idx_telemetry_sample_ms'));
|
||||
assert.ok(indexNames.has('idx_sessions_started_at'));
|
||||
assert.ok(indexNames.has('idx_sessions_ended_at'));
|
||||
assert.ok(indexNames.has('idx_words_frequency'));
|
||||
assert.ok(indexNames.has('idx_kanji_frequency'));
|
||||
assert.ok(indexNames.has('idx_media_art_anilist_id'));
|
||||
assert.ok(indexNames.has('idx_media_art_cover_url'));
|
||||
assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
|
||||
assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds the subtitle event index to current-version databases', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -129,47 +129,32 @@ test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies
|
||||
assert.deepEqual(playbackFeedback, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
const specialCommandCases = [
|
||||
{ name: 'youtube picker open', command: '__youtube-picker-open', expected: 'youtube-picker' },
|
||||
{ name: 'jimaku open', command: '__jimaku-open', expected: 'jimaku' },
|
||||
// Legacy Animetosho command is kept as a TsukiHime alias.
|
||||
{
|
||||
name: 'legacy Animetosho open as a TsukiHime alias',
|
||||
command: '__animetosho-open',
|
||||
expected: 'tsukihime',
|
||||
},
|
||||
{
|
||||
name: 'playlist browser open',
|
||||
command: '__playlist-browser-open',
|
||||
expected: 'playlist-browser',
|
||||
},
|
||||
];
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
|
||||
assert.deepEqual(calls, ['youtube-picker']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__jimaku-open'], options);
|
||||
assert.deepEqual(calls, ['jimaku']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
|
||||
const { options, calls, sentCommands } = createOptions();
|
||||
|
||||
handleMpvCommandFromIpc(['__animetosho-open'], options);
|
||||
|
||||
assert.deepEqual(calls, ['tsukihime']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ['playlist-browser']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
for (const c of specialCommandCases) {
|
||||
test(`handleMpvCommandFromIpc dispatches special ${c.name} command`, async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc([c.command], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, [c.expected]);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
}
|
||||
|
||||
test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
|
||||
const { options, osd } = createOptions({
|
||||
|
||||
+37
-253
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type {
|
||||
PlaylistBrowserSnapshot,
|
||||
SessionActionDispatchRequest,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types';
|
||||
|
||||
interface FakeIpcRegistrar {
|
||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
@@ -187,107 +183,6 @@ function createFakeImmersionTracker(
|
||||
};
|
||||
}
|
||||
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
onOverlayModalClosed: () => {},
|
||||
onOverlayMouseInteractionChanged: (active) => {
|
||||
calls.push(`overlay-interaction:${active}`);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||
getPlaybackPaused: () => true,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getSessionBindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
activatePlaybackWindowForOverlayInteraction: () => false,
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => ({}),
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
||||
clearAnilistToken: () => {
|
||||
calls.push('clearAnilistToken');
|
||||
},
|
||||
openAnilistSetup: () => {
|
||||
calls.push('openAnilistSetup');
|
||||
},
|
||||
getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }),
|
||||
retryAnilistQueueNow: async () => {
|
||||
calls.push('retryAnilistQueueNow');
|
||||
return { ok: true, message: 'done' };
|
||||
},
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: '/tmp',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: 0,
|
||||
currentFilePath: '/tmp/current.mkv',
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
});
|
||||
|
||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||
deps.clearAnilistToken();
|
||||
deps.openAnilistSetup();
|
||||
deps.onOverlayMouseInteractionChanged?.(true, null);
|
||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
||||
pending: 1,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
assert.deepEqual(await deps.retryAnilistQueueNow(), {
|
||||
ok: true,
|
||||
message: 'done',
|
||||
});
|
||||
assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true);
|
||||
assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), {
|
||||
ok: true,
|
||||
message: 'append',
|
||||
});
|
||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
||||
assert.deepEqual(calls, [
|
||||
'clearAnilistToken',
|
||||
'openAnilistSetup',
|
||||
'overlay-interaction:true',
|
||||
'retryAnilistQueueNow',
|
||||
]);
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
|
||||
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
|
||||
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
|
||||
@@ -332,27 +227,6 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
|
||||
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const senderWindows: unknown[] = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
|
||||
senderWindows.push(senderWindow);
|
||||
}) as IpcServiceDeps['reportOverlayContentBounds'],
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
handler?.({}, { layer: 'visible' });
|
||||
|
||||
assert.deepEqual(senderWindows, [null]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
@@ -433,71 +307,6 @@ test('registerIpcHandlers skips AniList update when manual mark watched has no a
|
||||
assert.deepEqual(calls, ['mark']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, unknown[]]> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: '/tmp/videos',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp/videos',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: 1,
|
||||
currentFilePath: '/tmp/videos/ep2.mkv',
|
||||
}),
|
||||
appendPlaylistBrowserFile: async (filePath) => {
|
||||
calls.push(['append', [filePath]]);
|
||||
return { ok: true, message: 'append-ok' };
|
||||
},
|
||||
playPlaylistBrowserIndex: async (index) => {
|
||||
calls.push(['play', [index]]);
|
||||
return { ok: true, message: 'play-ok' };
|
||||
},
|
||||
removePlaylistBrowserIndex: async (index) => {
|
||||
calls.push(['remove', [index]]);
|
||||
return { ok: true, message: 'remove-ok' };
|
||||
},
|
||||
movePlaylistBrowserIndex: async (index, direction) => {
|
||||
calls.push(['move', [index, direction]]);
|
||||
return { ok: true, message: 'move-ok' };
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.(
|
||||
{},
|
||||
)) as PlaylistBrowserSnapshot | undefined;
|
||||
const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.(
|
||||
{},
|
||||
'/tmp/videos/ep3.mkv',
|
||||
);
|
||||
const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2);
|
||||
const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.(
|
||||
{},
|
||||
2,
|
||||
);
|
||||
const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.(
|
||||
{},
|
||||
2,
|
||||
-1,
|
||||
);
|
||||
|
||||
assert.equal(snapshot?.playingIndex, 1);
|
||||
assert.deepEqual(append, { ok: true, message: 'append-ok' });
|
||||
assert.deepEqual(play, { ok: true, message: 'play-ok' });
|
||||
assert.deepEqual(remove, { ok: true, message: 'remove-ok' });
|
||||
assert.deepEqual(move, { ok: true, message: 'move-ok' });
|
||||
assert.deepEqual(calls, [
|
||||
['append', ['/tmp/videos/ep3.mkv']],
|
||||
['play', [2]],
|
||||
['remove', [2]],
|
||||
['move', [2, -1]],
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<{ id: string; value: unknown }> = [];
|
||||
@@ -610,45 +419,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const snapshot = createSubtitleSidebarSnapshotFixture();
|
||||
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
|
||||
snapshot.config.enabled = true;
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(await handler!({}), snapshot);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playback window activation request', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
||||
calls.push('activate');
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(
|
||||
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
|
||||
);
|
||||
assert.ok(handler);
|
||||
assert.equal(await handler!({}), true);
|
||||
assert.deepEqual(calls, ['activate']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const requests: unknown[] = [];
|
||||
@@ -757,28 +527,6 @@ test('registerIpcHandlers validates and forwards timing review text and screensh
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: createFakeImmersionTracker({
|
||||
recordYomitanLookup: () => {
|
||||
calls.push('lookup');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup);
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
handler?.({}, null);
|
||||
|
||||
assert.deepEqual(calls, ['lookup']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const contexts: unknown[] = [];
|
||||
@@ -1369,3 +1117,39 @@ test('mpv discovery has its own request and does not change session bindings', a
|
||||
assert.deepEqual(await discovery({}), snapshot);
|
||||
assert.deepEqual(await session({}), []);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates playlist browser mutation payloads before forwarding', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const forwarded: unknown[][] = [];
|
||||
const ok = { ok: true, message: 'ok' };
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
appendPlaylistBrowserFile: async (...args) => (forwarded.push(args), ok),
|
||||
playPlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||
removePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||
movePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
const { request } = IPC_CHANNELS;
|
||||
const cases: Array<{ channel: string; args: unknown[]; valid: boolean }> = [
|
||||
{ channel: request.appendPlaylistBrowserFile, args: ['/media/ep01.mkv'], valid: true },
|
||||
{ channel: request.appendPlaylistBrowserFile, args: [' '], valid: false },
|
||||
{ channel: request.appendPlaylistBrowserFile, args: [42], valid: false },
|
||||
{ channel: request.playPlaylistBrowserIndex, args: [0], valid: true },
|
||||
{ channel: request.playPlaylistBrowserIndex, args: [-1], valid: false },
|
||||
{ channel: request.removePlaylistBrowserIndex, args: [1.5], valid: false },
|
||||
{ channel: request.removePlaylistBrowserIndex, args: ['2'], valid: false },
|
||||
{ channel: request.movePlaylistBrowserIndex, args: [2, -1], valid: true },
|
||||
{ channel: request.movePlaylistBrowserIndex, args: [2, 2], valid: false },
|
||||
{ channel: request.movePlaylistBrowserIndex, args: [Number.NaN, 1], valid: false },
|
||||
];
|
||||
|
||||
for (const { channel, args, valid } of cases) {
|
||||
forwarded.length = 0;
|
||||
const result = (await handlers.handle.get(channel)?.({}, ...args)) as { ok: boolean };
|
||||
const label = `${channel}(${args.map(String).join(', ')})`;
|
||||
assert.equal(result.ok, valid, label);
|
||||
assert.deepEqual(forwarded, valid ? [args] : [], label);
|
||||
}
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getIgnoredPos1Entries,
|
||||
JLPT_EXCLUDED_TERMS,
|
||||
JLPT_IGNORED_MECAB_POS1,
|
||||
JLPT_IGNORED_MECAB_POS1_ENTRIES,
|
||||
JLPT_IGNORED_MECAB_POS1_LIST,
|
||||
shouldIgnoreJlptByTerm,
|
||||
shouldIgnoreJlptForMecabPos1,
|
||||
} from './jlpt-token-filter';
|
||||
|
||||
test('shouldIgnoreJlptByTerm matches the excluded JLPT lexical terms', () => {
|
||||
assert.equal(shouldIgnoreJlptByTerm('この'), true);
|
||||
assert.equal(shouldIgnoreJlptByTerm('そこ'), true);
|
||||
assert.equal(shouldIgnoreJlptByTerm('猫'), false);
|
||||
assert.deepEqual(Array.from(JLPT_EXCLUDED_TERMS), [
|
||||
'この',
|
||||
'その',
|
||||
'あの',
|
||||
'どの',
|
||||
'これ',
|
||||
'それ',
|
||||
'あれ',
|
||||
'どれ',
|
||||
'ここ',
|
||||
'そこ',
|
||||
'あそこ',
|
||||
'どこ',
|
||||
'こと',
|
||||
'ああ',
|
||||
'ええ',
|
||||
'うう',
|
||||
'おお',
|
||||
'はは',
|
||||
'へえ',
|
||||
'ふう',
|
||||
'ほう',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shouldIgnoreJlptForMecabPos1 matches the exported ignored POS1 list', () => {
|
||||
assert.equal(shouldIgnoreJlptForMecabPos1('助詞'), true);
|
||||
assert.equal(shouldIgnoreJlptForMecabPos1('名詞'), false);
|
||||
assert.deepEqual(JLPT_IGNORED_MECAB_POS1, JLPT_IGNORED_MECAB_POS1_LIST);
|
||||
assert.deepEqual(
|
||||
JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1),
|
||||
JLPT_IGNORED_MECAB_POS1_LIST,
|
||||
);
|
||||
assert.deepEqual(getIgnoredPos1Entries(), JLPT_IGNORED_MECAB_POS1_ENTRIES);
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
playNextSubtitleRuntime,
|
||||
replayCurrentSubtitleRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
showMpvOsdRuntime,
|
||||
} from './mpv';
|
||||
import { showMpvOsdRuntime } from './mpv';
|
||||
|
||||
test('showMpvOsdRuntime sends show-text when connected', () => {
|
||||
const commands: (string | number)[][] = [];
|
||||
@@ -55,29 +49,3 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => {
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(logs, ['OSD (MPV not connected): hello']);
|
||||
});
|
||||
|
||||
test('mpv runtime command wrappers call expected client methods', () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
send: ({ command }: { command: (string | number)[] }) => {
|
||||
calls.push(`send:${command.join(',')}`);
|
||||
},
|
||||
replayCurrentSubtitle: () => {
|
||||
calls.push('replay');
|
||||
},
|
||||
playNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
setSubVisibility: (visible: boolean) => {
|
||||
calls.push(`subVisible:${visible}`);
|
||||
},
|
||||
};
|
||||
|
||||
replayCurrentSubtitleRuntime(client);
|
||||
playNextSubtitleRuntime(client);
|
||||
sendMpvCommandRuntime(client, ['script-message', 'x']);
|
||||
setMpvSubVisibilityRuntime(client, false);
|
||||
|
||||
assert.deepEqual(calls, ['replay', 'next', 'send:script-message,x', 'subVisible:false']);
|
||||
});
|
||||
@@ -48,19 +48,6 @@ class HangingSocket extends FakeSocket {
|
||||
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
||||
assert.equal(getMpvReconnectDelay(1, true), 1000);
|
||||
assert.equal(getMpvReconnectDelay(2, true), 2000);
|
||||
assert.equal(getMpvReconnectDelay(4, true), 5000);
|
||||
assert.equal(getMpvReconnectDelay(7, true), 10000);
|
||||
|
||||
assert.equal(getMpvReconnectDelay(0, false), 200);
|
||||
assert.equal(getMpvReconnectDelay(2, false), 500);
|
||||
assert.equal(getMpvReconnectDelay(4, false), 1000);
|
||||
assert.equal(getMpvReconnectDelay(6, false), 2000);
|
||||
});
|
||||
|
||||
test('scheduleMpvReconnect clears existing timer and increments attempt', () => {
|
||||
const existing = {} as ReturnType<typeof setTimeout>;
|
||||
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
|
||||
@@ -262,50 +262,6 @@ test('MpvIpcClient requestProperty throws on mpv error response', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('MpvIpcClient connect does not log connect-request at info level', () => {
|
||||
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
const originalInfo = console.info;
|
||||
const infoLines: string[] = [];
|
||||
process.env.SUBMINER_LOG_LEVEL = 'info';
|
||||
console.info = (message?: unknown) => {
|
||||
infoLines.push(String(message ?? ''));
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).transport.connect = () => {};
|
||||
client.connect();
|
||||
} finally {
|
||||
process.env.SUBMINER_LOG_LEVEL = originalLevel;
|
||||
console.info = originalInfo;
|
||||
}
|
||||
|
||||
const requestLogs = infoLines.filter((line) => line.includes('MPV IPC connect requested.'));
|
||||
assert.equal(requestLogs.length, 0);
|
||||
});
|
||||
|
||||
test('MpvIpcClient connect logs connect-request at debug level', () => {
|
||||
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
const originalDebug = console.debug;
|
||||
const debugLines: string[] = [];
|
||||
process.env.SUBMINER_LOG_LEVEL = 'debug';
|
||||
console.debug = (message?: unknown) => {
|
||||
debugLines.push(String(message ?? ''));
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).transport.connect = () => {};
|
||||
client.connect();
|
||||
} finally {
|
||||
process.env.SUBMINER_LOG_LEVEL = originalLevel;
|
||||
console.debug = originalDebug;
|
||||
}
|
||||
|
||||
const requestLogs = debugLines.filter((line) => line.includes('MPV IPC connect requested.'));
|
||||
assert.equal(requestLogs.length, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const calls: string[] = [];
|
||||
@@ -356,80 +312,6 @@ test('MpvIpcClient failPendingRequests resolves outstanding requests as disconne
|
||||
assert.equal((client as any).pendingRequests.size, 0);
|
||||
});
|
||||
|
||||
test('MpvIpcClient scheduleReconnect schedules timer and invokes connect', () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: (timer) => {
|
||||
timers.push(timer);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let connectCalled = false;
|
||||
(client as any).connect = () => {
|
||||
connectCalled = true;
|
||||
};
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
||||
handler();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
try {
|
||||
(client as any).scheduleReconnect();
|
||||
} finally {
|
||||
(globalThis as any).setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(connectCalled, true);
|
||||
});
|
||||
|
||||
test('MpvIpcClient scheduleReconnect clears existing reconnect timer', () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const existingTimer = {} as ReturnType<typeof setTimeout>;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getReconnectTimer: () => existingTimer,
|
||||
setReconnectTimer: (timer) => {
|
||||
timers.push(timer);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let connectCalled = false;
|
||||
(client as any).connect = () => {
|
||||
connectCalled = true;
|
||||
};
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
||||
handler();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
cleared.push(timer);
|
||||
};
|
||||
|
||||
try {
|
||||
(client as any).scheduleReconnect();
|
||||
} finally {
|
||||
(globalThis as any).setTimeout = originalSetTimeout;
|
||||
(globalThis as any).clearTimeout = originalClearTimeout;
|
||||
}
|
||||
|
||||
assert.equal(cleared.length, 1);
|
||||
assert.equal(cleared[0], existingTimer);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(connectCalled, true);
|
||||
});
|
||||
|
||||
test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const client = new MpvIpcClient(
|
||||
|
||||
@@ -1,51 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut';
|
||||
|
||||
test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => {
|
||||
const registered: string[] = [];
|
||||
const unregistered: string[] = [];
|
||||
const osd: string[] = [];
|
||||
const handlers = new Map<string, () => void>();
|
||||
|
||||
const runtime = createNumericShortcutRuntime({
|
||||
globalShortcut: {
|
||||
register: (accelerator, callback) => {
|
||||
registered.push(accelerator);
|
||||
handlers.set(accelerator, callback);
|
||||
return true;
|
||||
},
|
||||
unregister: (accelerator) => {
|
||||
unregistered.push(accelerator);
|
||||
handlers.delete(accelerator);
|
||||
},
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
setTimer: () => setTimeout(() => {}, 1000),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
});
|
||||
|
||||
const session = runtime.createSession();
|
||||
session.start({
|
||||
timeoutMs: 5000,
|
||||
onDigit: () => {},
|
||||
messages: {
|
||||
prompt: 'Select count',
|
||||
timeout: 'Timed out',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(session.isActive(), true);
|
||||
assert.ok(registered.includes('1'));
|
||||
assert.ok(registered.includes('Escape'));
|
||||
assert.equal(osd[0], 'Select count');
|
||||
|
||||
handlers.get('Escape')?.();
|
||||
assert.equal(session.isActive(), false);
|
||||
assert.ok(unregistered.includes('Escape'));
|
||||
});
|
||||
import { createNumericShortcutSession } from './numeric-shortcut';
|
||||
|
||||
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
|
||||
const handlers = new Map<string, () => void>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice } from '../../types';
|
||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
import { sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
|
||||
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
|
||||
const sent: unknown[][] = [];
|
||||
@@ -72,39 +71,3 @@ test('sendToVisibleOverlayRuntime waits for overlay page before sending open com
|
||||
|
||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next) => {
|
||||
resolver = next;
|
||||
},
|
||||
sendToVisibleOverlay: () => false,
|
||||
});
|
||||
|
||||
const result = await callback({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(result.keepNoteId, 0);
|
||||
assert.equal(result.deleteNoteId, 0);
|
||||
});
|
||||
@@ -1,37 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayManager,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
} from './overlay-manager';
|
||||
|
||||
test('overlay manager initializes with empty windows and hidden overlays', () => {
|
||||
const manager = createOverlayManager();
|
||||
assert.equal(manager.getMainWindow(), null);
|
||||
assert.equal(manager.getModalWindow(), null);
|
||||
assert.equal(manager.getVisibleOverlayVisible(), false);
|
||||
assert.deepEqual(manager.getOverlayWindows(), []);
|
||||
});
|
||||
|
||||
test('overlay manager stores window references and returns stable window order', () => {
|
||||
const manager = createOverlayManager();
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const modalWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
|
||||
manager.setMainWindow(visibleWindow);
|
||||
manager.setModalWindow(modalWindow);
|
||||
|
||||
assert.equal(manager.getMainWindow(), visibleWindow);
|
||||
assert.equal(manager.getModalWindow(), modalWindow);
|
||||
assert.equal(manager.getOverlayWindow(), visibleWindow);
|
||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]);
|
||||
});
|
||||
|
||||
test('overlay manager excludes destroyed windows', () => {
|
||||
const manager = createOverlayManager();
|
||||
manager.setMainWindow({
|
||||
@@ -44,13 +17,6 @@ test('overlay manager excludes destroyed windows', () => {
|
||||
assert.equal(manager.getOverlayWindows().length, 0);
|
||||
});
|
||||
|
||||
test('overlay manager stores visibility state', () => {
|
||||
const manager = createOverlayManager();
|
||||
|
||||
manager.setVisibleOverlayVisible(true);
|
||||
assert.equal(manager.getVisibleOverlayVisible(), true);
|
||||
});
|
||||
|
||||
test('overlay manager broadcasts to non-destroyed windows', () => {
|
||||
const manager = createOverlayManager();
|
||||
const calls: unknown[][] = [];
|
||||
@@ -136,19 +102,13 @@ test('overlay manager can suppress z-order promotion during bounds updates', ()
|
||||
assert.deepEqual(calls, ['promote:false']);
|
||||
});
|
||||
|
||||
test('runtime-option broadcast still uses expected channel', () => {
|
||||
const broadcasts: unknown[][] = [];
|
||||
broadcastRuntimeOptionsChangedRuntime(
|
||||
() => [],
|
||||
(channel, ...args) => {
|
||||
broadcasts.push([channel, ...args]);
|
||||
},
|
||||
);
|
||||
test('setOverlayDebugVisualizationEnabledRuntime only updates state when the value changes', () => {
|
||||
let state = false;
|
||||
const changed = setOverlayDebugVisualizationEnabledRuntime(state, true, (enabled) => {
|
||||
const setState = (enabled: boolean) => {
|
||||
state = enabled;
|
||||
});
|
||||
assert.equal(changed, true);
|
||||
};
|
||||
|
||||
assert.equal(setOverlayDebugVisualizationEnabledRuntime(state, false, setState), false);
|
||||
assert.equal(setOverlayDebugVisualizationEnabledRuntime(state, true, setState), true);
|
||||
assert.equal(state, true);
|
||||
assert.deepEqual(broadcasts, [['runtime-options:changed', []]]);
|
||||
});
|
||||
@@ -1,103 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
import {
|
||||
initializeOverlayAnkiIntegration,
|
||||
initializeOverlayRuntime,
|
||||
startOverlayWindowTracker,
|
||||
} from './overlay-runtime-init';
|
||||
|
||||
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
type AnkiOptions = Parameters<typeof initializeOverlayAnkiIntegration>[0];
|
||||
type RuntimeOptions = Parameters<typeof initializeOverlayRuntime>[0];
|
||||
type CreateAnkiIntegrationArgs = Parameters<NonNullable<AnkiOptions['createAnkiIntegration']>>[0];
|
||||
|
||||
function createFakeTracker(targetMinimized = false) {
|
||||
return {
|
||||
onGeometryChange: null as ((geometry: WindowGeometry) => void) | null,
|
||||
onWindowFound: null as ((geometry: WindowGeometry) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
isTargetWindowMinimized: () => false,
|
||||
start: () => {
|
||||
calls.push('start');
|
||||
isTargetWindowMinimized: () => targetMinimized,
|
||||
started: false,
|
||||
start() {
|
||||
this.started = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = startOverlayWindowTracker({
|
||||
backendOverride: 'windows',
|
||||
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
|
||||
createWindowTracker: (override, socketPath) => {
|
||||
calls.push(`create:${override}:${socketPath}`);
|
||||
return tracker as never;
|
||||
},
|
||||
setWindowTracker: (nextTracker) => {
|
||||
calls.push(nextTracker === tracker ? 'set-tracker' : 'clear-tracker');
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('bounds');
|
||||
},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
calls.push('refresh-subtitle');
|
||||
},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, tracker);
|
||||
tracker.onWindowFound?.({ x: 10, y: 20, width: 300, height: 200 });
|
||||
tracker.onWindowFocusChange?.(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'create:windows:\\\\.\\pipe\\subminer-socket',
|
||||
'set-tracker',
|
||||
'start',
|
||||
'bounds',
|
||||
'visibility',
|
||||
'refresh-subtitle',
|
||||
'visibility',
|
||||
'sync-shortcuts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
function createAnkiHarness(overrides: Partial<AnkiOptions> = {}) {
|
||||
const state = {
|
||||
created: [] as CreateAnkiIntegrationArgs[],
|
||||
started: 0,
|
||||
stored: [] as unknown[],
|
||||
};
|
||||
const options: AnkiOptions = {
|
||||
getResolvedConfig: () => ({ ankiConnect: { enabled: true } as never }),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getMpvClient: () => ({ send: () => {} }),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
createAnkiIntegration: (args) => {
|
||||
state.created.push(args);
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
state.started += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
setAnkiIntegration: (integration) => {
|
||||
state.stored.push(integration);
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
@@ -107,264 +57,149 @@ test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled i
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
...overrides,
|
||||
};
|
||||
return { options, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs initializeOverlayRuntime with Anki disabled and returns the fake tracker plus a
|
||||
* snapshot of what the tracker callbacks did since initialization finished.
|
||||
*/
|
||||
function createTrackerHarness(config: { overlayVisible: boolean; targetMinimized?: boolean }) {
|
||||
const tracker = createFakeTracker(config.targetMinimized);
|
||||
const fresh = () => ({
|
||||
bounds: [] as WindowGeometry[],
|
||||
visibilityRefreshes: 0,
|
||||
subtitleRefreshes: 0,
|
||||
shortcutSyncs: 0,
|
||||
hiddenWindows: [] as string[],
|
||||
});
|
||||
let state = fresh();
|
||||
const overlayWindows = ['visible', 'modal'].map((name) => ({
|
||||
hide: () => {
|
||||
state.hiddenWindows.push(name);
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(createdIntegrations, 0);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 0);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayRuntime({
|
||||
const options: RuntimeOptions = {
|
||||
...createAnkiHarness({
|
||||
getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }),
|
||||
}).options,
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: (geometry) => {
|
||||
state.bounds.push(geometry);
|
||||
},
|
||||
isVisibleOverlayVisible: () => config.overlayVisible,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
state.visibilityRefreshes += 1;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
state.subtitleRefreshes += 1;
|
||||
},
|
||||
getOverlayWindows: () => overlayWindows as never,
|
||||
syncOverlayShortcuts: () => {
|
||||
state.shortcutSyncs += 1;
|
||||
},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: (args) => {
|
||||
createdIntegrations += 1;
|
||||
assert.equal(args.config.enabled, true);
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
createWindowTracker: () => tracker as never,
|
||||
};
|
||||
|
||||
initializeOverlayRuntime(options);
|
||||
state = fresh();
|
||||
|
||||
return { tracker, snapshot: () => state };
|
||||
}
|
||||
|
||||
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
|
||||
const tracker = createFakeTracker();
|
||||
const created: Array<[string | null | undefined, string | null | undefined]> = [];
|
||||
const stored: unknown[] = [];
|
||||
const bounds: WindowGeometry[] = [];
|
||||
let visibilityRefreshes = 0;
|
||||
let handlersInstalledAtStart = false;
|
||||
tracker.start = () => {
|
||||
handlersInstalledAtStart = tracker.onWindowFound !== null && tracker.onWindowLost !== null;
|
||||
tracker.started = true;
|
||||
};
|
||||
|
||||
const result = startOverlayWindowTracker({
|
||||
backendOverride: 'windows',
|
||||
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
|
||||
createWindowTracker: (override, socketPath) => {
|
||||
created.push([override, socketPath]);
|
||||
return tracker as never;
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
setWindowTracker: (nextTracker) => {
|
||||
stored.push(nextTracker);
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 3,
|
||||
deleteNoteId: 4,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 1);
|
||||
assert.equal(startedIntegrations, 1);
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayAnkiIntegration({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: (args) => {
|
||||
createdIntegrations += 1;
|
||||
assert.equal(args.config.enabled, true);
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
updateVisibleOverlayBounds: (geometry) => {
|
||||
bounds.push(geometry);
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
visibilityRefreshes += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 11,
|
||||
deleteNoteId: 12,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 1);
|
||||
assert.equal(startedIntegrations, 1);
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration returns false when integration already exists', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
const result = initializeOverlayAnkiIntegration({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
getAnkiIntegration: () => ({}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 11,
|
||||
deleteNoteId: 12,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(createdIntegrations, 0);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 0);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration returns false when ankiConnect is disabled', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
const result = initializeOverlayAnkiIntegration({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 11,
|
||||
deleteNoteId: 12,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(createdIntegrations, 0);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 0);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 7,
|
||||
deleteNoteId: 8,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 1);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
const geometry = { x: 10, y: 20, width: 300, height: 200 };
|
||||
tracker.onWindowFound?.(geometry);
|
||||
|
||||
assert.equal(result, tracker);
|
||||
assert.deepEqual(created, [['windows', '\\\\.\\pipe\\subminer-socket']]);
|
||||
assert.deepEqual(stored, [tracker]);
|
||||
assert.equal(tracker.started, true);
|
||||
assert.equal(handlersInstalledAtStart, true);
|
||||
assert.deepEqual(bounds, [geometry]);
|
||||
assert.equal(visibilityRefreshes, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => {
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
const ankiNotCreatedCases: Array<{ name: string; overrides: Partial<AnkiOptions> }> = [
|
||||
{
|
||||
name: 'ankiConnect is disabled',
|
||||
overrides: { getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }) },
|
||||
},
|
||||
{ name: 'an integration already exists', overrides: { getAnkiIntegration: () => ({}) } },
|
||||
];
|
||||
|
||||
for (const c of ankiNotCreatedCases) {
|
||||
test(`initializeOverlayAnkiIntegration returns false when ${c.name}`, () => {
|
||||
const { options, state } = createAnkiHarness(c.overrides);
|
||||
|
||||
assert.equal(initializeOverlayAnkiIntegration(options), false);
|
||||
assert.equal(state.created.length, 0);
|
||||
assert.equal(state.started, 0);
|
||||
assert.equal(state.stored.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('initializeOverlayAnkiIntegration creates, starts, and stores the integration when enabled', () => {
|
||||
const { options, state } = createAnkiHarness();
|
||||
|
||||
assert.equal(initializeOverlayAnkiIntegration(options), true);
|
||||
assert.equal(state.created.length, 1);
|
||||
assert.equal(state.created[0]!.config.enabled, true);
|
||||
assert.equal(state.started, 1);
|
||||
assert.equal(state.stored.length, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration can skip starting the Anki integration transport', () => {
|
||||
const { options, state } = createAnkiHarness({ shouldStartAnkiIntegration: () => false });
|
||||
|
||||
initializeOverlayAnkiIntegration(options);
|
||||
|
||||
assert.equal(state.created.length, 1);
|
||||
assert.equal(state.started, 0);
|
||||
assert.equal(state.stored.length, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration merges shared ai config with Anki overrides', () => {
|
||||
const { options, state } = createAnkiHarness({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: {
|
||||
enabled: true,
|
||||
@@ -383,45 +218,23 @@ test('initializeOverlayRuntime merges shared ai config with Anki overrides', ()
|
||||
requestTimeoutMs: 15000,
|
||||
},
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: (args) => {
|
||||
assert.equal(args.aiConfig.apiKey, 'shared-key');
|
||||
assert.equal(args.aiConfig.baseUrl, 'https://openrouter.ai/api');
|
||||
assert.equal(args.aiConfig.model, 'openrouter/anki-model');
|
||||
assert.equal(args.aiConfig.systemPrompt, 'Translate mined sentence text.');
|
||||
return {
|
||||
start: () => {},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 5,
|
||||
deleteNoteId: 6,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
initializeOverlayAnkiIntegration(options);
|
||||
|
||||
const aiConfig = state.created[0]!.aiConfig;
|
||||
assert.equal(aiConfig.apiKey, 'shared-key');
|
||||
assert.equal(aiConfig.baseUrl, 'https://openrouter.ai/api');
|
||||
assert.equal(aiConfig.model, 'openrouter/anki-model');
|
||||
assert.equal(aiConfig.systemPrompt, 'Translate mined sentence text.');
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus changes', () => {
|
||||
let syncCalls = 0;
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
start: () => {},
|
||||
};
|
||||
test('initializeOverlayRuntime initializes the Anki integration after the window tracker starts', () => {
|
||||
const { options: ankiOptions, state } = createAnkiHarness();
|
||||
const tracker = createFakeTracker();
|
||||
|
||||
initializeOverlayRuntime({
|
||||
...ankiOptions,
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
@@ -429,287 +242,76 @@ test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus cha
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {
|
||||
syncCalls += 1;
|
||||
},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => tracker as never,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(typeof tracker.onWindowFocusChange, 'function');
|
||||
tracker.onWindowFocusChange?.(true);
|
||||
assert.equal(syncCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime refreshes visible overlay when tracker focus changes while overlay is shown', () => {
|
||||
let visibilityRefreshCalls = 0;
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
start: () => {},
|
||||
};
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
visibilityRefreshCalls += 1;
|
||||
},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => tracker as never,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
tracker.onWindowFocusChange?.(true);
|
||||
|
||||
assert.equal(visibilityRefreshCalls, 2);
|
||||
assert.equal(tracker.started, true);
|
||||
assert.equal(state.created.length, 1);
|
||||
assert.equal(state.started, 1);
|
||||
assert.equal(state.stored.length, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
|
||||
let subtitleRefreshCalls = 0;
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
start: () => {},
|
||||
};
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
refreshCurrentSubtitle: () => {
|
||||
subtitleRefreshCalls += 1;
|
||||
const trackerEventCases: Array<{
|
||||
name: string;
|
||||
overlayVisible: boolean;
|
||||
targetMinimized?: boolean;
|
||||
trigger: (tracker: ReturnType<typeof createFakeTracker>) => void;
|
||||
expected: Partial<ReturnType<ReturnType<typeof createTrackerHarness>['snapshot']>>;
|
||||
}> = [
|
||||
{
|
||||
name: 'refreshes the visible overlay and re-syncs shortcuts when focus changes while shown',
|
||||
overlayVisible: true,
|
||||
trigger: (tracker) => tracker.onWindowFocusChange?.(true),
|
||||
expected: { visibilityRefreshes: 1, shortcutSyncs: 1 },
|
||||
},
|
||||
{
|
||||
name: 'only re-syncs shortcuts when focus changes while the overlay is hidden',
|
||||
overlayVisible: false,
|
||||
trigger: (tracker) => tracker.onWindowFocusChange?.(true),
|
||||
expected: { shortcutSyncs: 1 },
|
||||
},
|
||||
{
|
||||
name: 'restores bounds, visibility, and subtitle when the tracker finds the target window again',
|
||||
overlayVisible: true,
|
||||
trigger: (tracker) => tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 }),
|
||||
expected: {
|
||||
bounds: [{ x: 100, y: 200, width: 1280, height: 720 }],
|
||||
visibilityRefreshes: 1,
|
||||
subtitleRefreshes: 1,
|
||||
},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => tracker as never,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
},
|
||||
{
|
||||
name: 'hides overlay windows when the tracker loses a minimized target window',
|
||||
overlayVisible: true,
|
||||
targetMinimized: true,
|
||||
trigger: (tracker) => tracker.onWindowLost?.(),
|
||||
expected: { hiddenWindows: ['visible', 'modal'], shortcutSyncs: 1 },
|
||||
},
|
||||
{
|
||||
name: 'refreshes visibility instead of hiding when the tracker loses a non-minimized target',
|
||||
overlayVisible: true,
|
||||
targetMinimized: false,
|
||||
trigger: (tracker) => tracker.onWindowLost?.(),
|
||||
expected: { visibilityRefreshes: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of trackerEventCases) {
|
||||
test(`initializeOverlayRuntime ${c.name}`, () => {
|
||||
const { tracker, snapshot } = createTrackerHarness(c);
|
||||
|
||||
c.trigger(tracker);
|
||||
|
||||
assert.deepEqual(snapshot(), {
|
||||
bounds: [],
|
||||
visibilityRefreshes: 0,
|
||||
subtitleRefreshes: 0,
|
||||
shortcutSyncs: 0,
|
||||
hiddenWindows: [],
|
||||
...c.expected,
|
||||
});
|
||||
});
|
||||
|
||||
tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 });
|
||||
|
||||
assert.equal(subtitleRefreshCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
isTargetWindowMinimized: () => true,
|
||||
start: () => {},
|
||||
};
|
||||
const overlayWindows = [
|
||||
{
|
||||
hide: () => calls.push('hide-visible'),
|
||||
},
|
||||
{
|
||||
hide: () => calls.push('hide-modal'),
|
||||
},
|
||||
];
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
getOverlayWindows: () => overlayWindows as never,
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => tracker as never,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
tracker.onWindowLost?.();
|
||||
|
||||
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
isTargetWindowMinimized: () => false,
|
||||
start: () => {},
|
||||
};
|
||||
const overlayWindows = [
|
||||
{
|
||||
hide: () => calls.push('hide-visible'),
|
||||
},
|
||||
];
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('update-visible');
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
getOverlayWindows: () => overlayWindows as never,
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => tracker as never,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
calls.length = 0;
|
||||
tracker.onWindowLost?.();
|
||||
|
||||
assert.deepEqual(calls, ['update-visible']);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
|
||||
const bounds: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||
let visibilityRefreshCalls = 0;
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||
onWindowLost: null as (() => void) | null,
|
||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||
start: () => {},
|
||||
};
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: (geometry) => {
|
||||
bounds.push(geometry);
|
||||
},
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
visibilityRefreshCalls += 1;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => tracker as never,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 };
|
||||
tracker.onWindowFound?.(restoredGeometry);
|
||||
|
||||
assert.deepEqual(bounds, [restoredGeometry]);
|
||||
assert.equal(visibilityRefreshCalls, 2);
|
||||
});
|
||||
}
|
||||
@@ -93,34 +93,6 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const { overlayHandlers, fallbackHandlers } = createOverlayShortcutRuntimeHandlers(deps);
|
||||
|
||||
overlayHandlers.copySubtitle();
|
||||
overlayHandlers.copySubtitleMultiple(1111);
|
||||
overlayHandlers.toggleSecondarySub();
|
||||
overlayHandlers.openRuntimeOptions();
|
||||
overlayHandlers.openCharacterDictionaryManager();
|
||||
overlayHandlers.openJimaku();
|
||||
overlayHandlers.mineSentenceMultiple(2222);
|
||||
overlayHandlers.updateLastCardFromClipboard();
|
||||
fallbackHandlers.mineSentence();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'copySubtitle',
|
||||
'copySubtitleMultiple:1111',
|
||||
'toggleSecondarySub',
|
||||
'openRuntimeOptions',
|
||||
'openCharacterDictionaryManager',
|
||||
'openJimaku',
|
||||
'mineSentenceMultiple:2222',
|
||||
'updateLastCardFromClipboard',
|
||||
'mineSentence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => {
|
||||
const logs: unknown[][] = [];
|
||||
const originalError = console.error;
|
||||
@@ -243,79 +215,58 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
assert.deepEqual(handled, []);
|
||||
});
|
||||
|
||||
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
toggleSecondarySub: 'Ctrl+2',
|
||||
const allowWhenRegisteredCases: Array<{
|
||||
name: string;
|
||||
shortcut: Partial<ConfiguredShortcuts>;
|
||||
accelerator: string;
|
||||
}> = [
|
||||
{
|
||||
name: 'secondary-sub toggle',
|
||||
shortcut: { toggleSecondarySub: 'Ctrl+2' },
|
||||
accelerator: 'Ctrl+2',
|
||||
},
|
||||
{
|
||||
name: 'registered-global jimaku shortcut',
|
||||
shortcut: { openJimaku: 'Ctrl+J' },
|
||||
accelerator: 'Ctrl+J',
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of allowWhenRegisteredCases) {
|
||||
test(`runOverlayShortcutLocalFallback passes allowWhenRegistered for ${c.name}`, () => {
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
makeShortcuts(c.shortcut),
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === c.accelerator;
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(matched, [{ accelerator: c.accelerator, allowWhenRegistered: true }]);
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === 'Ctrl+2';
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(matched, [{ accelerator: 'Ctrl+2', allowWhenRegistered: true }]);
|
||||
});
|
||||
|
||||
test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', () => {
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
openJimaku: 'Ctrl+J',
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === 'Ctrl+J';
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(matched, [{ accelerator: 'Ctrl+J', allowWhenRegistered: true }]);
|
||||
});
|
||||
}
|
||||
|
||||
test('runOverlayShortcutLocalFallback returns false when no action matches', () => {
|
||||
const shortcuts = makeShortcuts({
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||
import {
|
||||
registerOverlayShortcuts,
|
||||
syncOverlayShortcutsRuntime,
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from './overlay-shortcut';
|
||||
import { registerOverlayShortcuts } from './overlay-shortcut';
|
||||
|
||||
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||
return {
|
||||
@@ -36,27 +32,6 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
};
|
||||
}
|
||||
|
||||
test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => {
|
||||
assert.equal(
|
||||
registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), {
|
||||
copySubtitle: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
|
||||
assert.equal(
|
||||
registerOverlayShortcuts(createShortcuts(), {
|
||||
@@ -77,34 +52,3 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => {
|
||||
const calls: string[] = [];
|
||||
const result = syncOverlayShortcutsRuntime(false, true, {
|
||||
getConfiguredShortcuts: () => createShortcuts(),
|
||||
getOverlayHandlers: () => ({
|
||||
copySubtitle: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
markAudioCard: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
},
|
||||
cancelPendingMineSentenceMultiple: () => {
|
||||
calls.push('cancel-mine-sentence-multiple');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,19 +2,6 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||
|
||||
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
|
||||
const options = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(options.title, 'SubMiner Overlay');
|
||||
assert.equal(options.backgroundColor, '#00000000');
|
||||
assert.equal(options.paintWhenInitiallyHidden, true);
|
||||
assert.equal(options.webPreferences?.sandbox, false);
|
||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||
});
|
||||
|
||||
test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
@@ -31,117 +18,50 @@ test('macOS modal overlay uses a fullscreen auxiliary panel without changing the
|
||||
assert.equal(modalOptions.type, 'panel');
|
||||
});
|
||||
|
||||
test('non-macOS modal overlay remains a regular window', () => {
|
||||
const options = buildOverlayWindowOptions('modal', {
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
try {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.resizable, true);
|
||||
assert.equal(modalOptions.resizable, false);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
assert.equal(visibleOptions.resizable, true);
|
||||
assert.equal(modalOptions.resizable, false);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window stays managed so native apps can cover it', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.alwaysOnTop, false);
|
||||
assert.equal(visibleOptions.focusable, true);
|
||||
assert.equal(modalOptions.focusable, true);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
assert.equal(visibleOptions.alwaysOnTop, false);
|
||||
assert.equal(visibleOptions.focusable, true);
|
||||
assert.equal(modalOptions.focusable, true);
|
||||
});
|
||||
|
||||
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
linuxX11FullscreenOverlay: true,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
linuxX11FullscreenOverlay: true,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.alwaysOnTop, true);
|
||||
assert.equal(visibleOptions.focusable, false);
|
||||
assert.equal(visibleOptions.resizable, false);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows visible overlay window config does not start as always-on-top', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32',
|
||||
});
|
||||
|
||||
try {
|
||||
const options = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(options.alwaysOnTop, false);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
assert.equal(visibleOptions.alwaysOnTop, true);
|
||||
assert.equal(visibleOptions.focusable, false);
|
||||
assert.equal(visibleOptions.resizable, false);
|
||||
});
|
||||
|
||||
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||
|
||||
@@ -133,26 +133,6 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -176,92 +156,6 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clearRetimedSecondarySubtitleCache,
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
type RetimedSubtitleCommandRunner,
|
||||
} from './secondary-subtitle-sidecar.js';
|
||||
|
||||
const ORIGINAL_ENGLISH = `1
|
||||
00:00:09,000 --> 00:00:10,000
|
||||
Stale English subtitle
|
||||
`;
|
||||
|
||||
const ALIGNED_ENGLISH = `1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
Aligned English subtitle
|
||||
`;
|
||||
|
||||
type SidecarFixture = {
|
||||
sourcePath: string;
|
||||
japanesePath: string;
|
||||
englishPath: string;
|
||||
alassPath: string;
|
||||
};
|
||||
|
||||
/** Writes an episode with a correctly timed Japanese sidecar and a mistimed English one. */
|
||||
async function withSidecarFixture(fn: (fixture: SidecarFixture) => Promise<void>): Promise<void> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-secondary-sidecar-test-'));
|
||||
try {
|
||||
const fixture = {
|
||||
sourcePath: path.join(dir, 'episode.mkv'),
|
||||
japanesePath: path.join(dir, 'episode.ja.srt'),
|
||||
englishPath: path.join(dir, 'episode.en.srt'),
|
||||
alassPath: path.join(dir, 'alass-cli'),
|
||||
};
|
||||
fs.writeFileSync(fixture.sourcePath, 'fake media');
|
||||
fs.writeFileSync(fixture.alassPath, 'fake alass');
|
||||
fs.writeFileSync(fixture.japanesePath, '1\n00:00:01,000 --> 00:00:02,000\n猫を見た\n');
|
||||
fs.writeFileSync(fixture.englishPath, ORIGINAL_ENGLISH);
|
||||
await fn(fixture);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearRetimedSecondarySubtitleCache();
|
||||
});
|
||||
|
||||
it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => {
|
||||
await withSidecarFixture(async ({ sourcePath, japanesePath, englishPath, alassPath }) => {
|
||||
let alassRuns = 0;
|
||||
const input = { sourcePath, startMs: 1_000, endMs: 2_000, alassPath };
|
||||
|
||||
const first = await resolveRetimedSecondarySubtitleTextFromSidecar({
|
||||
...input,
|
||||
runAlass: async (_alassPath, referencePath, inputPath, outputPath) => {
|
||||
alassRuns += 1;
|
||||
assert.equal(referencePath, japanesePath);
|
||||
assert.equal(inputPath, englishPath);
|
||||
fs.writeFileSync(outputPath, ALIGNED_ENGLISH);
|
||||
return { ok: true, code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
const second = await resolveRetimedSecondarySubtitleTextFromSidecar({
|
||||
...input,
|
||||
runAlass: async () => {
|
||||
alassRuns += 1;
|
||||
return { ok: false, code: 1, stdout: '', stderr: 'should use cache' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(first, 'Aligned English subtitle');
|
||||
assert.equal(second, 'Aligned English subtitle');
|
||||
assert.equal(alassRuns, 1);
|
||||
assert.equal(fs.readFileSync(englishPath, 'utf8'), ORIGINAL_ENGLISH);
|
||||
});
|
||||
});
|
||||
|
||||
it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => {
|
||||
await withSidecarFixture(async ({ sourcePath, alassPath }) => {
|
||||
let alassRuns = 0;
|
||||
let releaseAlass!: () => void;
|
||||
const alassGate = new Promise<void>((resolve) => {
|
||||
releaseAlass = resolve;
|
||||
});
|
||||
const runAlass: RetimedSubtitleCommandRunner = async (_alass, _reference, _input, output) => {
|
||||
alassRuns += 1;
|
||||
await alassGate;
|
||||
fs.writeFileSync(output, ALIGNED_ENGLISH);
|
||||
return { ok: true, code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
const input = { sourcePath, startMs: 1_000, endMs: 2_000, alassPath, runAlass };
|
||||
|
||||
const first = resolveRetimedSecondarySubtitleTextFromSidecar(input);
|
||||
const second = resolveRetimedSecondarySubtitleTextFromSidecar(input);
|
||||
releaseAlass();
|
||||
|
||||
assert.deepEqual(await Promise.all([first, second]), [
|
||||
'Aligned English subtitle',
|
||||
'Aligned English subtitle',
|
||||
]);
|
||||
assert.equal(alassRuns, 1);
|
||||
});
|
||||
});
|
||||
@@ -81,17 +81,3 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
|
||||
|
||||
assert.deepEqual(calls, ['mark-watched']);
|
||||
});
|
||||
|
||||
test('dispatchSessionAction opens the character dictionary manager', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
|
||||
await dispatchSessionAction({ actionId: 'openCharacterDictionaryManager' }, deps);
|
||||
|
||||
assert.deepEqual(calls, ['character-dictionary-manager']);
|
||||
});
|
||||
|
||||
test('dispatchSessionAction opens subtitle generation without opening the sidebar', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
await dispatchSessionAction({ actionId: 'openSubtitleGeneration' }, deps);
|
||||
assert.deepEqual(calls, ['subtitle-generation']);
|
||||
});
|
||||
@@ -275,67 +275,6 @@ test('compileSessionBindings drops conflicting bindings that canonicalize to the
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings keeps default replay and next subtitle session actions on Linux', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
|
||||
keybindings: DEFAULT_KEYBINDINGS,
|
||||
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
|
||||
platform: 'linux',
|
||||
rawConfig: DEFAULT_CONFIG,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.warnings.filter((warning) => warning.kind === 'conflict'),
|
||||
[],
|
||||
);
|
||||
const bySignature = new Map(
|
||||
result.bindings.map((binding) => [
|
||||
`${binding.key.modifiers.join('+')}+${binding.key.code}`,
|
||||
binding,
|
||||
]),
|
||||
);
|
||||
|
||||
const replay = bySignature.get('ctrl+shift+KeyH');
|
||||
assert.equal(replay?.actionType, 'session-action');
|
||||
assert.equal(replay?.actionId, 'replayCurrentSubtitle');
|
||||
|
||||
const next = bySignature.get('ctrl+shift+KeyL');
|
||||
assert.equal(next?.actionType, 'session-action');
|
||||
assert.equal(next?.actionId, 'playNextSubtitle');
|
||||
});
|
||||
|
||||
test('compileSessionBindings keeps only the character dictionary manager bound by default', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
|
||||
keybindings: DEFAULT_KEYBINDINGS,
|
||||
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
|
||||
platform: 'linux',
|
||||
rawConfig: DEFAULT_CONFIG,
|
||||
});
|
||||
|
||||
const characterDictionaryBindings = result.bindings.flatMap((binding) => {
|
||||
if (binding.actionType !== 'session-action') return [];
|
||||
if (binding.actionId !== 'openCharacterDictionaryManager') {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
sourcePath: binding.sourcePath,
|
||||
originalKey: binding.originalKey,
|
||||
actionId: binding.actionId,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
assert.deepEqual(characterDictionaryBindings, [
|
||||
{
|
||||
sourcePath: 'shortcuts.openCharacterDictionaryManager',
|
||||
originalKey: 'CommandOrControl+D',
|
||||
actionId: 'openCharacterDictionaryManager',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
||||
const expectedSpecialActions: Record<string, string> = {
|
||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||
|
||||
@@ -78,19 +78,14 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
test('runStartupBootstrapRuntime configures startup state and starts lifecycle', () => {
|
||||
function runBootstrap(
|
||||
argsOverrides: Partial<CliArgs> = {},
|
||||
depOverrides: Partial<Parameters<typeof runStartupBootstrapRuntime>[0]> = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({
|
||||
logLevel: 'debug',
|
||||
socketPath: '/tmp/custom.sock',
|
||||
texthookerPort: 9001,
|
||||
backend: 'x11',
|
||||
autoStartOverlay: true,
|
||||
texthooker: true,
|
||||
});
|
||||
|
||||
const args = makeArgs(argsOverrides);
|
||||
const result = runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--log-level', 'debug'],
|
||||
argv: ['node', 'main.ts'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
@@ -99,6 +94,19 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
...depOverrides,
|
||||
});
|
||||
return { args, result, calls };
|
||||
}
|
||||
|
||||
test('runStartupBootstrapRuntime maps CLI args to startup state', () => {
|
||||
const { args, result } = runBootstrap({
|
||||
socketPath: '/tmp/custom.sock',
|
||||
texthookerPort: 9001,
|
||||
backend: 'x11',
|
||||
autoStartOverlay: true,
|
||||
texthooker: true,
|
||||
background: true,
|
||||
});
|
||||
|
||||
assert.equal(result.initialArgs, args);
|
||||
@@ -107,141 +115,59 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
|
||||
assert.equal(result.backendOverride, 'x11');
|
||||
assert.equal(result.autoStartOverlay, true);
|
||||
assert.equal(result.texthookerOnlyMode, true);
|
||||
assert.equal(result.backgroundMode, false);
|
||||
assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
assert.equal(result.backgroundMode, true);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({
|
||||
logLevel: 'warn',
|
||||
});
|
||||
|
||||
runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--log-level', 'warn'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.slice(0, 3), ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({
|
||||
jellyfin: true,
|
||||
jellyfinLibraries: true,
|
||||
socketPath: '/tmp/stable.sock',
|
||||
texthookerPort: 8888,
|
||||
});
|
||||
|
||||
const result = runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--jellyfin', '--jellyfin-libraries'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
});
|
||||
|
||||
assert.equal(result.mpvSocketPath, '/tmp/stable.sock');
|
||||
assert.equal(result.texthookerPort, 8888);
|
||||
assert.equal(result.backendOverride, null);
|
||||
assert.equal(result.autoStartOverlay, false);
|
||||
assert.equal(result.texthookerOnlyMode, false);
|
||||
assert.equal(result.backgroundMode, false);
|
||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime keeps --debug separate from log verbosity', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({
|
||||
debug: true,
|
||||
});
|
||||
|
||||
runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--debug'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({ generateConfig: true, logLevel: 'warn' });
|
||||
|
||||
const result = runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--generate-config'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => true,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
});
|
||||
test('runStartupBootstrapRuntime falls back to defaults when args are unset', () => {
|
||||
const { result } = runBootstrap();
|
||||
|
||||
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
|
||||
assert.equal(result.texthookerPort, 5174);
|
||||
assert.equal(result.backendOverride, null);
|
||||
assert.equal(result.autoStartOverlay, false);
|
||||
assert.equal(result.texthookerOnlyMode, false);
|
||||
assert.equal(result.backgroundMode, false);
|
||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime lets config govern background log level by default', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({ background: true });
|
||||
const logLevelCases: Array<{ name: string; args: Partial<CliArgs>; expected: string[] }> = [
|
||||
{
|
||||
name: '--log-level applies the requested level',
|
||||
args: { logLevel: 'debug' },
|
||||
expected: ['setLog:debug:cli'],
|
||||
},
|
||||
{ name: '--update defaults to warn', args: { update: true }, expected: ['setLog:warn:cli'] },
|
||||
{
|
||||
name: '--log-level wins over --update',
|
||||
args: { update: true, logLevel: 'info' },
|
||||
expected: ['setLog:info:cli'],
|
||||
},
|
||||
{ name: '--debug leaves log verbosity alone', args: { debug: true }, expected: [] },
|
||||
{ name: '--background leaves log level to config', args: { background: true }, expected: [] },
|
||||
];
|
||||
|
||||
const result = runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--background'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
for (const c of logLevelCases) {
|
||||
test(`runStartupBootstrapRuntime log level: ${c.name}`, () => {
|
||||
const { calls } = runBootstrap(c.args);
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call.startsWith('setLog:')),
|
||||
c.expected,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(result.backgroundMode, true);
|
||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
test('runStartupBootstrapRuntime applies log level and platform setup before starting lifecycle', () => {
|
||||
const { calls } = runBootstrap({ logLevel: 'debug' });
|
||||
|
||||
const index = (call: string) => calls.indexOf(call);
|
||||
assert.ok(index('setLog:debug:cli') >= 0);
|
||||
assert.ok(index('setLog:debug:cli') < index('forceX11'));
|
||||
assert.ok(index('forceX11') < index('startLifecycle'));
|
||||
assert.ok(index('enforceWayland') < index('startLifecycle'));
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({ update: true });
|
||||
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
|
||||
const { calls } = runBootstrap({ generateConfig: true }, { runGenerateConfigFlow: () => true });
|
||||
|
||||
const result = runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--update'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
});
|
||||
|
||||
assert.equal(result.backgroundMode, false);
|
||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
assert.equal(calls.includes('startLifecycle'), false);
|
||||
});
|
||||
@@ -1,447 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runAppReadyRuntime } from './startup';
|
||||
import { runAppReadyRuntime, type AppReadyRuntimeDeps } from './startup';
|
||||
|
||||
test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => {
|
||||
// Every dep records its name in `calls`; overrides replace individual deps.
|
||||
function createAppReadyDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {
|
||||
calls.push('config-warning');
|
||||
},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {
|
||||
calls.push('subtitle-ws');
|
||||
},
|
||||
startAnnotationWebsocket: () => {
|
||||
calls.push('annotation-ws');
|
||||
},
|
||||
startTexthooker: () => {
|
||||
calls.push('texthooker');
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
calls.push('mecab');
|
||||
},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('jellyfin');
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarm');
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldUseMinimalStartup: () => true,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['bootstrap', 'reload-config', 'handle-initial-args']);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {
|
||||
calls.push('config-warning');
|
||||
},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {
|
||||
calls.push('subtitle-ws');
|
||||
},
|
||||
startAnnotationWebsocket: () => {
|
||||
calls.push('annotation-ws');
|
||||
},
|
||||
startTexthooker: () => {
|
||||
calls.push('texthooker');
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
calls.push('mecab');
|
||||
},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('jellyfin');
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarm');
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
runHeadlessInitialCommand: async () => {
|
||||
calls.push('run-headless-command');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'bootstrap',
|
||||
'reload-config',
|
||||
'init-runtime-options',
|
||||
'run-headless-command',
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'bootstrap',
|
||||
'reload-config',
|
||||
'init-runtime-options',
|
||||
'create-mpv',
|
||||
'subtitle-timing',
|
||||
'load-yomitan',
|
||||
'init-overlay',
|
||||
'handle-initial-args',
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
const record = (name: string) => () => {
|
||||
calls.push(name);
|
||||
};
|
||||
const recordAsync = (name: string) => async () => {
|
||||
calls.push(name);
|
||||
};
|
||||
const deps: AppReadyRuntimeDeps = {
|
||||
ensureDefaultConfigBootstrap: record('bootstrap'),
|
||||
loadSubtitlePosition: record('load-subtitle-position'),
|
||||
resolveKeybindings: record('resolve-keybindings'),
|
||||
createMpvClient: record('create-mpv'),
|
||||
reloadConfig: record('reload-config'),
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
logConfigWarning: record('config-warning'),
|
||||
setLogLevel: record('set-log-level'),
|
||||
initRuntimeOptionsManager: record('init-runtime-options'),
|
||||
setSecondarySubMode: record('set-secondary-sub-mode'),
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {
|
||||
calls.push('subtitle-ws');
|
||||
},
|
||||
startAnnotationWebsocket: () => {
|
||||
calls.push('annotation-ws');
|
||||
},
|
||||
startTexthooker: () => {
|
||||
calls.push('texthooker');
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
startSubtitleWebsocket: record('subtitle-ws'),
|
||||
startAnnotationWebsocket: record('annotation-ws'),
|
||||
startTexthooker: record('texthooker'),
|
||||
log: record('log'),
|
||||
createMecabTokenizerAndCheck: recordAsync('mecab'),
|
||||
createSubtitleTimingTracker: record('subtitle-timing'),
|
||||
createImmersionTracker: record('immersion'),
|
||||
startJellyfinRemoteSession: recordAsync('jellyfin'),
|
||||
loadYomitanExtension: recordAsync('load-yomitan'),
|
||||
handleFirstRunSetup: recordAsync('first-run'),
|
||||
prewarmSubtitleDictionaries: recordAsync('prewarm'),
|
||||
startBackgroundWarmups: record('warmups'),
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: record('visible-overlay'),
|
||||
initializeOverlayRuntime: record('init-overlay'),
|
||||
handleInitialArgs: record('handle-initial-args'),
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
...overrides,
|
||||
};
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
function assertBefore(calls: string[], earlier: string, later: string) {
|
||||
const earlierIndex = calls.indexOf(earlier);
|
||||
const laterIndex = calls.indexOf(later);
|
||||
assert.notEqual(earlierIndex, -1, `${earlier} was not called`);
|
||||
assert.notEqual(laterIndex, -1, `${later} was not called`);
|
||||
assert.ok(earlierIndex < laterIndex, `${earlier} should run before ${later}`);
|
||||
}
|
||||
|
||||
test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => {
|
||||
const { calls, deps } = createAppReadyDeps({ shouldUseMinimalStartup: () => true });
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.includes('handle-initial-args'));
|
||||
for (const skipped of ['load-yomitan', 'first-run', 'create-mpv', 'init-overlay', 'warmups']) {
|
||||
assert.equal(calls.includes(skipped), false, `${skipped} should be skipped`);
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => {
|
||||
const { calls, deps } = createAppReadyDeps({
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
runHeadlessInitialCommand: async () => {
|
||||
calls.push('run-headless-command');
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('warmups') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assertBefore(calls, 'init-runtime-options', 'run-headless-command');
|
||||
for (const skipped of ['create-mpv', 'load-yomitan', 'init-overlay', 'handle-initial-args']) {
|
||||
assert.equal(calls.includes(skipped), false, `${skipped} should be skipped`);
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
||||
const { calls, deps } = createAppReadyDeps({ shouldRunHeadlessInitialCommand: () => true });
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assertBefore(calls, 'create-mpv', 'subtitle-timing');
|
||||
assertBefore(calls, 'load-yomitan', 'init-overlay');
|
||||
assertBefore(calls, 'init-overlay', 'handle-initial-args');
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||
const { calls, deps } = createAppReadyDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assertBefore(calls, 'init-overlay', 'warmups');
|
||||
assert.equal(calls.includes('load-yomitan'), false);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan-direct');
|
||||
},
|
||||
const { calls, deps } = createAppReadyDeps({
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('load-yomitan-guarded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.equal(calls.includes('load-yomitan-direct'), false);
|
||||
assert.equal(calls.includes('load-yomitan-guarded'), true);
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('load-yomitan'), false);
|
||||
assertBefore(calls, 'warmups', 'load-yomitan-guarded');
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildStatsWindowUrl,
|
||||
buildStatsWindowOptions,
|
||||
buildStatsNativeConfirmDialogOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
@@ -12,60 +11,8 @@ import {
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
shouldPresentStatsWindowAfterLoad,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
bounds: {
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 1440,
|
||||
height: 900,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(options.title, 'SubMiner Stats');
|
||||
assert.equal(options.x, 120);
|
||||
assert.equal(options.y, 80);
|
||||
assert.equal(options.width, 1440);
|
||||
assert.equal(options.height, 900);
|
||||
assert.equal(options.frame, false);
|
||||
assert.equal(options.transparent, false);
|
||||
assert.equal(options.backgroundColor, '#24273a');
|
||||
assert.equal(options.resizable, false);
|
||||
assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js');
|
||||
assert.equal(options.webPreferences?.contextIsolation, true);
|
||||
assert.equal(options.webPreferences?.nodeIntegration, false);
|
||||
assert.equal(options.webPreferences?.sandbox, true);
|
||||
});
|
||||
|
||||
test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(options.type, 'panel');
|
||||
});
|
||||
|
||||
test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('stats panels present after document load on macOS', () => {
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true);
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false);
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false);
|
||||
});
|
||||
|
||||
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
@@ -173,10 +120,6 @@ test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', (
|
||||
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
|
||||
});
|
||||
|
||||
test('buildStatsWindowUrl uses the active server port as the document origin', () => {
|
||||
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1');
|
||||
});
|
||||
|
||||
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
|
||||
assert.deepEqual(
|
||||
resolveStatsWindowOuterBoundsForContent(
|
||||
|
||||
@@ -133,31 +133,6 @@ test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media path
|
||||
assert.equal(ffsubsyncAvailable, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let spinnerRan = false;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.subtitleTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
|
||||
spinnerRan = true;
|
||||
return task();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 2);
|
||||
assert.equal(spinnerRan, false);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
|
||||
@@ -267,34 +267,6 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => {
|
||||
assert.equal(cues[0]!.text, '順番が違う');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues auto-detects SRT format', () => {
|
||||
const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.srt');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.text, 'SRTテスト');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues auto-detects ASS format', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,ASSテスト',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.text, 'ASSテスト');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues auto-detects VTT format', () => {
|
||||
const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.vtt');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.text, 'VTTテスト');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues returns empty for unknown format', () => {
|
||||
assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []);
|
||||
});
|
||||
@@ -449,23 +421,6 @@ test('parseSubtitleCues keeps short styled repeats separate even with richer sty
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
|
||||
// Several characters greeting in turn: distinct utterances that happen to abut.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう',
|
||||
'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう',
|
||||
'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
assert.equal(cues[0]!.endTime, 246.82);
|
||||
assert.equal(cues[2]!.startTime, 247.56);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
@@ -613,28 +568,6 @@ test('parseSubtitleCues keeps short animated English dialogue as separate cues',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not reconstruct an already canonical English cue', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF',
|
||||
'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF',
|
||||
'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: 'POOF',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 1,
|
||||
animationEndTime: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
@@ -794,38 +727,6 @@ test('parseSubtitleCues keeps two positioned signs that repeat the same text', (
|
||||
assert.equal(cues[1]!.startTime, 63.0);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => {
|
||||
// Three events is a sequence, but none of them runs at animation-frame speed.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||
'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||
'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:00:01,000 --> 00:00:01,200',
|
||||
'えっ',
|
||||
'',
|
||||
'2',
|
||||
'00:00:01,200 --> 00:00:01,400',
|
||||
'えっ',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.srt');
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
@@ -881,22 +782,6 @@ test('parseSubtitleCues does not merge a burst into unrelated dialogue between f
|
||||
assert.equal(cues[0]!.endTime, 3.55);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => {
|
||||
// Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical,
|
||||
// but authored as three lines: different styles and different actors.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
|
||||
@@ -541,20 +541,6 @@ test('cache evicts least recently used entries once the limit is reached', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('default cache limit covers a full-length title without evicting', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||
}
|
||||
|
||||
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
||||
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
||||
});
|
||||
|
||||
test('onSubtitleChange reports whether processing was scheduled', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
|
||||
+598
-4413
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -99,77 +99,3 @@ test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are n
|
||||
const emptyResult = enrichTokensWithMecabPos1(tokens, []);
|
||||
assert.strictEqual(emptyResult, tokens);
|
||||
});
|
||||
|
||||
test('enrichTokensWithMecabPos1 avoids repeated full scans over distant mecab surfaces', () => {
|
||||
const tokens = Array.from({ length: 12 }, (_, index) =>
|
||||
makeToken({ surface: `w${index}`, startPos: index, endPos: index + 1, pos1: '' }),
|
||||
);
|
||||
const mecabTokens = tokens.map((token) =>
|
||||
makeToken({
|
||||
surface: token.surface,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
pos1: '名詞',
|
||||
}),
|
||||
);
|
||||
|
||||
let distantSurfaceReads = 0;
|
||||
const distantToken = makeToken({ surface: '遠', startPos: 500, endPos: 501, pos1: '記号' });
|
||||
Object.defineProperty(distantToken, 'surface', {
|
||||
configurable: true,
|
||||
get() {
|
||||
distantSurfaceReads += 1;
|
||||
if (distantSurfaceReads > 3) {
|
||||
throw new Error('repeated full scan detected');
|
||||
}
|
||||
return '遠';
|
||||
},
|
||||
});
|
||||
mecabTokens.push(distantToken);
|
||||
|
||||
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
assert.equal(enriched.length, tokens.length);
|
||||
for (const token of enriched) {
|
||||
assert.equal(token.pos1, '名詞');
|
||||
}
|
||||
});
|
||||
|
||||
test('enrichTokensWithMecabPos1 avoids repeated active-candidate filter scans', () => {
|
||||
const tokens = Array.from({ length: 8 }, (_, index) =>
|
||||
makeToken({ surface: `u${index}`, startPos: index, endPos: index + 1, pos1: '' }),
|
||||
);
|
||||
const mecabTokens = [
|
||||
makeToken({ surface: 'SENTINEL', startPos: 0, endPos: 100, pos1: '記号' }),
|
||||
...tokens.map((token, index) =>
|
||||
makeToken({
|
||||
surface: `m${index}`,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
pos1: '名詞',
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
let sentinelFilterCalls = 0;
|
||||
const originalFilter = Array.prototype.filter;
|
||||
Array.prototype.filter = function filterWithSentinelCheck(
|
||||
this: unknown[],
|
||||
...args: any[]
|
||||
): any[] {
|
||||
const target = this as Array<{ surface?: string }>;
|
||||
if (target.some((candidate) => candidate?.surface === 'SENTINEL')) {
|
||||
sentinelFilterCalls += 1;
|
||||
if (sentinelFilterCalls > 2) {
|
||||
throw new Error('repeated active candidate filter scan detected');
|
||||
}
|
||||
}
|
||||
return (originalFilter as (...params: any[]) => any[]).apply(this, args);
|
||||
} as typeof Array.prototype.filter;
|
||||
|
||||
try {
|
||||
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
assert.equal(enriched.length, tokens.length);
|
||||
} finally {
|
||||
Array.prototype.filter = originalFilter;
|
||||
}
|
||||
});
|
||||
@@ -175,18 +175,6 @@ test('still drops punctuation-only and whitespace-only unparsed runs', () => {
|
||||
assert.equal(tokens?.map((token) => token.surface).join(','), '猫,犬');
|
||||
});
|
||||
|
||||
test('candidate with only unparsed tokens still yields no dictionary match', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[{ text: '戻', reading: '' }],
|
||||
[{ text: '轟', reading: '' }],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.equal(tokens, null);
|
||||
});
|
||||
|
||||
test('prefers the longest dictionary headword across merged segments', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
@@ -379,50 +367,30 @@ test('merges trailing katakana continuation without headword into previous token
|
||||
// is merged with trailing function particles (e.g. かかってこいよ → headword かかってくる).
|
||||
// When a competing multi-token candidate splits content and function separately, the
|
||||
// multi-token candidate should win so the content token remains frequency-highlightable.
|
||||
test('multi-token candidate beats single merged content+function token candidate (frequency regression)', () => {
|
||||
// Candidate A: single merged token — content verb fused with trailing sentence-final particle
|
||||
// This is the "bad" candidate: downstream annotation would exclude frequency for the whole
|
||||
// token because the merged pos1 would contain a function-word component.
|
||||
test('multi-token candidate beats single merged content+function token candidate in either input order (frequency regression)', () => {
|
||||
// Merged candidate: content verb fused with the trailing sentence-final particle. Downstream
|
||||
// annotation would exclude frequency for the whole token because its merged pos1 contains a
|
||||
// function-word component.
|
||||
const mergedCandidate = makeParseItem('scanning-parser', [
|
||||
[{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }],
|
||||
]);
|
||||
|
||||
// Candidate B: two tokens — content verb surface + particle separately.
|
||||
// The content token is frequency-eligible on its own.
|
||||
// Split candidate: the content token stays frequency-eligible on its own.
|
||||
const splitCandidate = makeParseItem('scanning-parser', [
|
||||
[{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }],
|
||||
[{ text: 'よ', reading: 'よ', headword: 'よ' }],
|
||||
]);
|
||||
|
||||
// When merged candidate comes first in the array, multi-token split still wins.
|
||||
const tokens = selectYomitanParseTokens(
|
||||
for (const candidates of [
|
||||
[mergedCandidate, splitCandidate],
|
||||
() => false,
|
||||
'headword',
|
||||
);
|
||||
assert.equal(tokens?.length, 2);
|
||||
assert.equal(tokens?.[0]?.surface, 'かかってこい');
|
||||
assert.equal(tokens?.[0]?.headword, 'かかってくる');
|
||||
assert.equal(tokens?.[1]?.surface, 'よ');
|
||||
});
|
||||
|
||||
test('multi-token candidate beats single merged content+function token regardless of input order', () => {
|
||||
const mergedCandidate = makeParseItem('scanning-parser', [
|
||||
[{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }],
|
||||
]);
|
||||
|
||||
const splitCandidate = makeParseItem('scanning-parser', [
|
||||
[{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }],
|
||||
[{ text: 'よ', reading: 'よ', headword: 'よ' }],
|
||||
]);
|
||||
|
||||
// Split candidate comes first — should still win over merged.
|
||||
const tokens = selectYomitanParseTokens(
|
||||
[splitCandidate, mergedCandidate],
|
||||
() => false,
|
||||
'headword',
|
||||
);
|
||||
assert.equal(tokens?.length, 2);
|
||||
assert.equal(tokens?.[0]?.surface, 'かかってこい');
|
||||
assert.equal(tokens?.[1]?.surface, 'よ');
|
||||
]) {
|
||||
const tokens = selectYomitanParseTokens(candidates, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
|
||||
[
|
||||
{ surface: 'かかってこい', headword: 'かかってくる' },
|
||||
{ surface: 'よ', headword: 'よ' },
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -4,10 +4,8 @@ import { MergedToken, PartOfSpeech } from '../../../types';
|
||||
import {
|
||||
createSubtitleAnnotationRuleContext,
|
||||
shouldExcludeTokenFromSubtitleAnnotations,
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS,
|
||||
SUBTITLE_ANNOTATION_RULES,
|
||||
} from './subtitle-annotation-filter';
|
||||
import { isKanaChar } from './token-classification';
|
||||
|
||||
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
|
||||
return {
|
||||
@@ -24,96 +22,6 @@ function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
|
||||
};
|
||||
}
|
||||
|
||||
test('subtitle annotation rules expose stable ordered provenance', () => {
|
||||
assert.deepEqual(
|
||||
SUBTITLE_ANNOTATION_RULES.map(({ id, issueRef }) => ({ id, issueRef })),
|
||||
[
|
||||
{ id: 'unparsed-run', issueRef: '#153' },
|
||||
{ id: 'configured-pos1-exclusion', issueRef: '#19' },
|
||||
{ id: 'configured-pos2-exclusion', issueRef: '#150' },
|
||||
{ id: 'coarse-grammar-pos-fallback', issueRef: '#19' },
|
||||
{ id: 'auxiliary-stem-grammar-tail', issueRef: '#19' },
|
||||
{ id: 'kana-non-independent-noun-helper', issueRef: '#56' },
|
||||
{ id: 'standalone-auxiliary-inflection', issueRef: '#57' },
|
||||
{ id: 'auxiliary-only-helper-span', issueRef: '#57' },
|
||||
{ id: 'standalone-suru-te-helper', issueRef: '#57' },
|
||||
{ id: 'standalone-grammar-particle', issueRef: '#57' },
|
||||
{ id: 'single-kana-fragment', issueRef: '#57' },
|
||||
{ id: 'merged-trailing-quote-particle', issueRef: '#19' },
|
||||
{ id: 'lexical-kureru-keep', issueRef: '#57' },
|
||||
{ id: 'excluded-term-or-pattern', issueRef: '#19, #33, #57' },
|
||||
],
|
||||
);
|
||||
assert.equal(new Set(SUBTITLE_ANNOTATION_RULES.map(({ id }) => id)).size, 14);
|
||||
assert.ok(SUBTITLE_ANNOTATION_RULES.every(({ description }) => description.length > 0));
|
||||
assert.ok(
|
||||
SUBTITLE_ANNOTATION_RULES.every(
|
||||
({ data }) => Object.isFrozen(data) && Object.values(data).every(Object.isFrozen),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('lexical kureru keep rule precedes and overrides the excluded-term rule', () => {
|
||||
const token = makeToken({
|
||||
surface: 'くれ',
|
||||
headword: 'くれる',
|
||||
reading: 'クレ',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
});
|
||||
const context = createSubtitleAnnotationRuleContext(token);
|
||||
const keepIndex = SUBTITLE_ANNOTATION_RULES.findIndex(({ id }) => id === 'lexical-kureru-keep');
|
||||
const termIndex = SUBTITLE_ANNOTATION_RULES.findIndex(
|
||||
({ id }) => id === 'excluded-term-or-pattern',
|
||||
);
|
||||
|
||||
assert.ok(keepIndex >= 0 && keepIndex < termIndex);
|
||||
assert.ok(SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has('くれ'));
|
||||
assert.equal(SUBTITLE_ANNOTATION_RULES[keepIndex]?.test(context), 'keep');
|
||||
assert.equal(SUBTITLE_ANNOTATION_RULES[termIndex]?.test(context), 'exclude');
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
||||
});
|
||||
|
||||
test('adding an exported excluded term changes annotation matching', () => {
|
||||
const term = '超語彙';
|
||||
const token = makeToken({
|
||||
surface: term,
|
||||
headword: term,
|
||||
reading: term,
|
||||
pos1: '名詞',
|
||||
pos2: '一般',
|
||||
});
|
||||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term);
|
||||
try {
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.add(term);
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
} finally {
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting an exported excluded term changes annotation matching', () => {
|
||||
const term = 'あなた';
|
||||
const token = makeToken({
|
||||
surface: term,
|
||||
headword: term,
|
||||
reading: term,
|
||||
pos1: '名詞',
|
||||
pos2: '一般',
|
||||
});
|
||||
|
||||
assert.ok(SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(term));
|
||||
try {
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term);
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
||||
} finally {
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.add(term);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured POS rules consume the context exclusion sets in table order', () => {
|
||||
const token = makeToken({ pos1: '名詞', pos2: '一般' });
|
||||
const context = createSubtitleAnnotationRuleContext(token, {
|
||||
@@ -175,9 +83,3 @@ test('configured POS2 rule preserves supplementary-plane kanji nouns', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('kana detection excludes katakana punctuation boundaries', () => {
|
||||
assert.equal(isKanaChar('゠'), false);
|
||||
assert.equal(isKanaChar('・'), false);
|
||||
assert.equal(isKanaChar('ァ'), true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -258,20 +258,6 @@ test('halfwidth voiced kana compose into the reading instead of leaving a stray
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens falls back to the exhaustive name scan without candidates', async () => {
|
||||
const withoutLookups: string[] = [];
|
||||
const withoutCandidates = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(withoutLookups),
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 1, nameCandidates: null },
|
||||
);
|
||||
|
||||
assert.equal(withoutCandidates?.[0]?.isNameMatch, true);
|
||||
// No candidate list means every Japanese position is probed, as before.
|
||||
assert.ok(countTermsFindLookups(withoutLookups, '校に') > 0);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens reinstalls name candidates when the media changes', async () => {
|
||||
const lookups: string[] = [];
|
||||
const deps = createNameScanDeps(lookups);
|
||||
@@ -302,3 +288,35 @@ test('requestYomitanScanTokens reinstalls name candidates when the media changes
|
||||
assert.equal(correctMedia?.[0]?.surface, 'ミナト');
|
||||
assert.equal(correctMedia?.[0]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens skips the greedy name pre-pass when name matching is off', async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'name-match metadata disabled',
|
||||
dictionaries: undefined,
|
||||
includeNameMatchMetadata: false,
|
||||
expectedNameMatch: false,
|
||||
},
|
||||
{
|
||||
name: 'no enabled character dictionary',
|
||||
dictionaries: ['JMdict'],
|
||||
includeNameMatchMetadata: true,
|
||||
// The main walk still classifies the match; only the pre-pass is gated.
|
||||
expectedNameMatch: true,
|
||||
},
|
||||
];
|
||||
for (const c of cases) {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(lookups, NAME_SCAN_WORDS, c.dictionaries),
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: c.includeNameMatchMetadata },
|
||||
);
|
||||
|
||||
assert.equal(result?.[0]?.surface, 'ミナト', c.name);
|
||||
assert.equal(result?.[0]?.isNameMatch, c.expectedNameMatch, c.name);
|
||||
// Mid-token positions are only probed by the pre-pass.
|
||||
assert.equal(countTermsFindLookups(lookups, '校に'), 0, c.name);
|
||||
}
|
||||
});
|
||||
@@ -39,7 +39,11 @@ function createYomitanScriptSandbox(handler: (action: string, params: unknown) =
|
||||
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
|
||||
) => {
|
||||
try {
|
||||
callback({ result: handler(payload.action ?? '', payload.params) });
|
||||
// Messages cross a process boundary in Electron; clone params into
|
||||
// the host realm so handlers can compare them structurally.
|
||||
const params =
|
||||
payload.params === undefined ? undefined : structuredClone(payload.params);
|
||||
callback({ result: handler(payload.action ?? '', params) });
|
||||
} catch (error) {
|
||||
callback({ error: { message: (error as Error).message } });
|
||||
}
|
||||
@@ -98,8 +102,128 @@ export function countTermsFindLookups(lookups: string[], prefix: string): number
|
||||
return lookups.filter((lookupText) => lookupText.startsWith(prefix)).length;
|
||||
}
|
||||
|
||||
export interface TermsFindResult {
|
||||
originalTextLength: number;
|
||||
dictionaryEntries: unknown[];
|
||||
}
|
||||
|
||||
// One termsFind dictionary entry whose headword exactly matches `originalText`
|
||||
// (the scanned surface, defaulting to the term). `dictionary` attaches a
|
||||
// definition from that dictionary, e.g. a SubMiner character dictionary.
|
||||
export function termEntry(
|
||||
term: string,
|
||||
reading: string,
|
||||
options: { originalText?: string; isPrimary?: boolean; dictionary?: string } = {},
|
||||
) {
|
||||
return {
|
||||
headwords: [
|
||||
{
|
||||
term,
|
||||
reading,
|
||||
sources: [
|
||||
{
|
||||
originalText: options.originalText ?? term,
|
||||
isPrimary: options.isPrimary ?? true,
|
||||
matchType: 'exact',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...(options.dictionary ? { definitions: [{ dictionary: options.dictionary }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function termsFound(
|
||||
originalTextLength: number,
|
||||
...dictionaryEntries: unknown[]
|
||||
): TermsFindResult {
|
||||
return { originalTextLength, dictionaryEntries };
|
||||
}
|
||||
|
||||
// Scan deps backed by a fake Yomitan backend. The active profile enables
|
||||
// `dictionaries` (in priority order; omitted means no dictionary list),
|
||||
// getDictionaryInfo returns `dictionaryInfo`, and termsFind answers through
|
||||
// `termsFind` (null/undefined means no match) while recording each text in
|
||||
// `lookups`. `actions` handles any other backend action and overrides the
|
||||
// defaults above; anything else throws. Every action name lands in `actionLog`.
|
||||
export function createBackendDeps(
|
||||
options: {
|
||||
dictionaries?: string[];
|
||||
dictionaryInfo?: unknown[];
|
||||
termsFind?: (text: string) => TermsFindResult | null | undefined;
|
||||
lookups?: string[];
|
||||
actions?: Record<string, (params: unknown) => unknown>;
|
||||
actionLog?: string[];
|
||||
onScript?: (script: string) => void;
|
||||
} = {},
|
||||
) {
|
||||
return createScanDeps(
|
||||
(action, params) => {
|
||||
options.actionLog?.push(action);
|
||||
const override = options.actions?.[action];
|
||||
if (override) {
|
||||
return override(params);
|
||||
}
|
||||
if (action === 'optionsGetFull') {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
...(options.dictionaries
|
||||
? {
|
||||
dictionaries: options.dictionaries.map((name, id) => ({
|
||||
name,
|
||||
enabled: true,
|
||||
id,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (action === 'getDictionaryInfo') {
|
||||
return options.dictionaryInfo ?? [];
|
||||
}
|
||||
if (action === 'termsFind' && options.termsFind) {
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
options.lookups?.push(text);
|
||||
return options.termsFind(text) ?? termsFound(0);
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
},
|
||||
{ onScript: options.onScript },
|
||||
);
|
||||
}
|
||||
|
||||
// Deps whose hidden settings.html window runs injected scripts against a fake
|
||||
// `__subminerYomitanSettingsAutomation` bridge, like the real settings page.
|
||||
export function createSettingsAutomationDeps(automation: Record<string, unknown>) {
|
||||
const context = vm.createContext({
|
||||
__subminerYomitanSettingsAutomation: { ready: true, ...automation },
|
||||
setTimeout,
|
||||
});
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => await vm.runInContext(script, context),
|
||||
},
|
||||
};
|
||||
return createDeps(async () => true, {
|
||||
createYomitanExtensionWindow: async (pageName) =>
|
||||
pageName === 'settings.html' ? settingsWindow : null,
|
||||
});
|
||||
}
|
||||
|
||||
export const SUBMINER_TEST_CHARACTER_DICTIONARY = 'SubMiner Character Dictionary (AniList 1)';
|
||||
|
||||
// Backend stub for the greedy name pre-pass: one character name (ミナト) in a
|
||||
// line of ordinary words, with the SubMiner character dictionary enabled.
|
||||
// line of ordinary words, with the SubMiner character dictionary enabled
|
||||
// unless `dictionaries` says otherwise.
|
||||
export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
||||
['ミナト', 'ミナト', 'みなと', true],
|
||||
['は', 'は', 'は', false],
|
||||
@@ -112,57 +236,24 @@ export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
||||
export function createNameScanDeps(
|
||||
lookups: string[],
|
||||
words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS,
|
||||
dictionaries: string[] = ['JMdict', SUBMINER_TEST_CHARACTER_DICTIONARY],
|
||||
) {
|
||||
return createScanDeps((action, params) => {
|
||||
if (action === 'optionsGetFull') {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [
|
||||
{ name: 'JMdict', enabled: true, id: 0 },
|
||||
{
|
||||
name: 'SubMiner Character Dictionary (AniList 1)',
|
||||
enabled: true,
|
||||
id: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (action === 'getDictionaryInfo') {
|
||||
return [];
|
||||
}
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
lookups.push(text);
|
||||
for (const [surface, term, reading, isName] of words) {
|
||||
if (text.startsWith(surface)) {
|
||||
return {
|
||||
originalTextLength: surface.length,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term,
|
||||
reading,
|
||||
sources: [{ originalText: surface, isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
definitions: [
|
||||
{ dictionary: isName ? 'SubMiner Character Dictionary (AniList 1)' : 'JMdict' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return createBackendDeps({
|
||||
dictionaries,
|
||||
lookups,
|
||||
termsFind: (text) => {
|
||||
const match = words.find(([surface]) => text.startsWith(surface));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
const [surface, term, reading, isName] = match;
|
||||
return termsFound(
|
||||
surface.length,
|
||||
termEntry(term, reading, {
|
||||
originalText: surface,
|
||||
dictionary: isName ? SUBMINER_TEST_CHARACTER_DICTIONARY : 'JMdict',
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,44 +2,49 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
|
||||
|
||||
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
|
||||
const calls: string[] = [];
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => {
|
||||
calls.push('destroy');
|
||||
},
|
||||
};
|
||||
const cases = [
|
||||
{ name: 'a live parser window', window: { destroyed: false }, expectDestroyed: true },
|
||||
{
|
||||
name: 'an already destroyed parser window',
|
||||
window: { destroyed: true },
|
||||
expectDestroyed: false,
|
||||
},
|
||||
{ name: 'no parser window', window: null, expectDestroyed: false },
|
||||
];
|
||||
|
||||
clearYomitanParserRuntimeState({
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
||||
setYomitanParserReadyPromise: (promise) =>
|
||||
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
||||
setYomitanParserInitPromise: (promise) =>
|
||||
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
||||
for (const c of cases) {
|
||||
test(`clearYomitanParserRuntimeState clears parser state with ${c.name}`, () => {
|
||||
let destroyCalls = 0;
|
||||
const parserWindow = c.window && {
|
||||
isDestroyed: () => c.window.destroyed,
|
||||
destroy: () => {
|
||||
destroyCalls += 1;
|
||||
},
|
||||
};
|
||||
const state: {
|
||||
window: unknown;
|
||||
ready: Promise<void> | null;
|
||||
init: Promise<boolean> | null;
|
||||
} = {
|
||||
window: parserWindow,
|
||||
ready: Promise.resolve(),
|
||||
init: Promise.resolve(true),
|
||||
};
|
||||
|
||||
clearYomitanParserRuntimeState({
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
state.window = window;
|
||||
},
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
state.ready = promise;
|
||||
},
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
state.init = promise;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(destroyCalls, c.expectDestroyed ? 1 : 0);
|
||||
assert.deepEqual(state, { window: null, ready: null, init: null });
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']);
|
||||
});
|
||||
|
||||
test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => {
|
||||
const calls: string[] = [];
|
||||
const parserWindow = {
|
||||
isDestroyed: () => true,
|
||||
destroy: () => {
|
||||
calls.push('destroy');
|
||||
},
|
||||
};
|
||||
|
||||
clearYomitanParserRuntimeState({
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
||||
setYomitanParserReadyPromise: (promise) =>
|
||||
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
||||
setYomitanParserInitPromise: (promise) =>
|
||||
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']);
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildYomitanSettingsCloseButtonScript,
|
||||
buildYomitanSettingsWindowMenuTemplate,
|
||||
buildYomitanSettingsUrl,
|
||||
configureYomitanSettingsWindowChrome,
|
||||
destroyYomitanSettingsWindow,
|
||||
installYomitanSettingsCloseButton,
|
||||
@@ -49,15 +47,6 @@ test('yomitan settings close menu skips destroyed windows', () => {
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('yomitan settings close button script installs an idempotent in-page close control', () => {
|
||||
const script = buildYomitanSettingsCloseButtonScript();
|
||||
|
||||
assert.match(script, /subminer-yomitan-settings-close/);
|
||||
assert.match(script, /aria-label', 'Close Yomitan settings'/);
|
||||
assert.match(script, /window\.close\(\)/);
|
||||
assert.match(script, /getElementById\(buttonId\)/);
|
||||
});
|
||||
|
||||
test('yomitan settings close button only installs for Hyprland sessions', () => {
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
@@ -96,13 +85,6 @@ test('yomitan settings close button injection skips non-Hyprland windows', () =>
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('yomitan settings URL disables the embedded popup preview', () => {
|
||||
assert.equal(
|
||||
buildYomitanSettingsUrl('abc123'),
|
||||
'chrome-extension://abc123/settings.html?popup-preview=false',
|
||||
);
|
||||
});
|
||||
|
||||
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -134,18 +116,6 @@ test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existi
|
||||
]);
|
||||
});
|
||||
|
||||
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const destroyed = destroyYomitanSettingsWindow({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy'),
|
||||
} as never);
|
||||
|
||||
assert.equal(destroyed, true);
|
||||
assert.deepEqual(calls, ['destroy']);
|
||||
});
|
||||
|
||||
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
|
||||
assert.equal(destroyYomitanSettingsWindow(null), false);
|
||||
assert.equal(
|
||||
|
||||
@@ -4,7 +4,6 @@ import { buildYoutubeMetadataProbeArgs } from './metadata-probe';
|
||||
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
|
||||
import { buildDownloadArgs } from './track-download';
|
||||
import { buildYoutubeTrackProbeArgs } from './track-probe';
|
||||
import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||
|
||||
// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole
|
||||
// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call.
|
||||
@@ -26,10 +25,6 @@ const cases: Array<{ name: string; args: string[] }> = [
|
||||
},
|
||||
];
|
||||
|
||||
test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => {
|
||||
assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist');
|
||||
});
|
||||
|
||||
for (const { name, args } of cases) {
|
||||
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
|
||||
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEAD_MODULE_PATHS = [
|
||||
'src/translators/index.ts',
|
||||
'src/subsync/engines.ts',
|
||||
'src/subtitle/pipeline.ts',
|
||||
'src/subtitle/stages/merge.ts',
|
||||
'src/subtitle/stages/normalize.ts',
|
||||
'src/subtitle/stages/normalize.test.ts',
|
||||
'src/subtitle/stages/tokenize.ts',
|
||||
'src/tokenizers/index.ts',
|
||||
'src/token-mergers/index.ts',
|
||||
] as const;
|
||||
|
||||
const FORBIDDEN_IMPORT_PATTERNS = [
|
||||
/from ['"]\.\.?\/tokenizers['"]/,
|
||||
/from ['"]\.\.?\/token-mergers['"]/,
|
||||
/from ['"]\.\.?\/subtitle\/pipeline['"]/,
|
||||
/from ['"]\.\.?\/subsync\/engines['"]/,
|
||||
/from ['"]\.\.?\/translators['"]/,
|
||||
] as const;
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function collectSourceFiles(rootDir: string): string[] {
|
||||
const absoluteRoot = path.join(process.cwd(), rootDir);
|
||||
const out: string[] = [];
|
||||
|
||||
const visit = (currentDir: string) => {
|
||||
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) {
|
||||
continue;
|
||||
}
|
||||
out.push(path.relative(process.cwd(), fullPath).replaceAll('\\', '/'));
|
||||
}
|
||||
};
|
||||
|
||||
visit(absoluteRoot);
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
test('dead registry and pipeline modules stay removed from the repository', () => {
|
||||
for (const relativePath of DEAD_MODULE_PATHS) {
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(process.cwd(), relativePath)),
|
||||
false,
|
||||
`${relativePath} should stay deleted`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('live source tree no longer imports dead registry and pipeline modules', () => {
|
||||
for (const relativePath of collectSourceFiles('src')) {
|
||||
const source = readWorkspaceFile(relativePath);
|
||||
for (const pattern of FORBIDDEN_IMPORT_PATTERNS) {
|
||||
assert.doesNotMatch(source, pattern, `${relativePath} should not import ${pattern.source}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
generateConfigExampleTemplate,
|
||||
resolveConfigExampleOutputPaths,
|
||||
writeConfigExampleArtifacts,
|
||||
} from './generate-config-example';
|
||||
@@ -81,9 +80,3 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
|
||||
const template = generateConfigExampleTemplate();
|
||||
|
||||
assert.match(template, /"socketPath": "\/tmp\/subminer-socket"/);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -7,13 +8,10 @@ import { DEFAULT_CONFIG } from './config/definitions';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
exitBackgroundBootstrap,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeStartupArgv,
|
||||
normalizeLaunchMpvTargets,
|
||||
resolveStatsDaemonCommandAction,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
@@ -24,36 +22,26 @@ import {
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
applyBackgroundBootstrapCommandLineSwitches,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveAppControlHandoffTimeoutMs,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('app-control handoffs allow for macOS application activation latency', () => {
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000);
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500);
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500);
|
||||
});
|
||||
|
||||
test('detached app launch policy stays in the startup runtime utilities', () => {
|
||||
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
||||
const runtimeSource = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/main-entry-runtime.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(typeof spawnDetachedApp, 'function');
|
||||
assert.doesNotMatch(entrySource, /function spawnDetachedApp/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/child\.once\('error', \(error\) => \{\s*console\.error\([^;]*error\);\s*\}\);\s*child\.unref\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('background bootstrap exits through Electron so Chromium children shut down', () => {
|
||||
const exitCodes: number[] = [];
|
||||
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
|
||||
assert.deepEqual(exitCodes, [0]);
|
||||
test('spawnDetachedApp survives a failed spawn of the detached app', () => {
|
||||
const child = Object.assign(new EventEmitter(), { unref: () => {} });
|
||||
const fakeSpawn = (() => child) as unknown as Parameters<typeof spawnDetachedApp>[2];
|
||||
const originalConsoleError = console.error;
|
||||
const logged: unknown[][] = [];
|
||||
console.error = (...args: unknown[]) => {
|
||||
logged.push(args);
|
||||
};
|
||||
try {
|
||||
spawnDetachedApp(['--start'], {}, fakeSpawn);
|
||||
// An unhandled 'error' event would throw here and crash the entry process.
|
||||
child.emit('error', new Error('spawn ENOENT'));
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
assert.equal(logged.length, 1);
|
||||
});
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
@@ -545,22 +533,6 @@ test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeHelpEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeLaunchMpvEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
||||
const env = sanitizeBackgroundEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
|
||||
@@ -296,15 +296,20 @@ export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
export function spawnDetachedApp(childArgs: string[], env: NodeJS.ProcessEnv): void {
|
||||
// spawnProcess is a test seam; production callers use node's spawn.
|
||||
export function spawnDetachedApp(
|
||||
childArgs: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
spawnProcess: typeof spawn = spawn,
|
||||
): void {
|
||||
const keepalive = resolveAppImageMountKeepaliveInvocation(env);
|
||||
const child = keepalive
|
||||
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
|
||||
? spawnProcess(keepalive.command, [...keepalive.args, ...childArgs], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env,
|
||||
})
|
||||
: spawn(process.execPath, childArgs, {
|
||||
: spawnProcess(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env,
|
||||
|
||||
@@ -5,8 +5,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import {
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
resolveAppImageMountKeepaliveInvocation,
|
||||
resolveMountFsType,
|
||||
} from './appimage-mount-keepalive';
|
||||
@@ -79,32 +77,17 @@ test('resolveMountFsType picks the longest containing mount point', () => {
|
||||
assert.equal(resolveMountFsType('/usr/bin/sh', ''), null);
|
||||
});
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation builds sh invocation with AppImage path', () => {
|
||||
const invocation = resolveOnFuse({ APPIMAGE: '/opt/SubMiner.AppImage' }, 'linux');
|
||||
assert.ok(invocation);
|
||||
assert.equal(invocation.command, '/bin/sh');
|
||||
assert.deepEqual(invocation.args, [
|
||||
'-c',
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
'/opt/SubMiner.AppImage',
|
||||
]);
|
||||
});
|
||||
|
||||
function runKeepaliveScript(
|
||||
appImagePath: string,
|
||||
extraArgs: string[] = [],
|
||||
): Promise<{ status: number }> {
|
||||
// Run the exact invocation the app would spawn, so the argv is exercised too.
|
||||
const invocation = resolveOnFuse({ APPIMAGE: appImagePath }, 'linux');
|
||||
assert.ok(invocation);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
appImagePath,
|
||||
...extraArgs,
|
||||
],
|
||||
invocation.command,
|
||||
[...invocation.args, ...extraArgs],
|
||||
{ timeout: 30_000 },
|
||||
(error) => {
|
||||
if (error && typeof error.code !== 'number') {
|
||||
|
||||
+133
-140
@@ -1,109 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ConfigStartupParseError } from '../../config';
|
||||
import { createMainBootServices } from './services';
|
||||
|
||||
test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
type MockAppLifecycleApp = {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
exit: (code?: number) => void;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
|
||||
const calls: string[] = [];
|
||||
let setPathValue: string | null = null;
|
||||
const appOnCalls: string[] = [];
|
||||
let secondInstanceHandlerRegistered = false;
|
||||
|
||||
const services = createMainBootServices<
|
||||
{ configDir: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ kind: string; payloadMode: 'plain' | 'annotated' },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||
{
|
||||
inputState: boolean;
|
||||
getModalInputExclusive: () => boolean;
|
||||
handleModalInputStateChange: (isActive: boolean) => void;
|
||||
},
|
||||
{ measurementStore: boolean },
|
||||
{ modalRuntime: boolean },
|
||||
{ mpvSocketPath: string; texthookerPort: number },
|
||||
MockAppLifecycleApp
|
||||
>({
|
||||
platform: 'linux',
|
||||
// Minimal params whose factories echo their inputs, so tests can assert what each service received.
|
||||
function baseParams() {
|
||||
return {
|
||||
platform: 'linux' as NodeJS.Platform,
|
||||
argv: ['node', 'main.ts'],
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: ' /tmp/custom.log ',
|
||||
envMpvLog: undefined as string | undefined,
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => '/tmp/subminer-config',
|
||||
existsSync: () => false,
|
||||
mkdirSync: (targetPath) => {
|
||||
calls.push(`mkdir:${targetPath}`);
|
||||
},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
existsSync: (_targetPath: string) => false,
|
||||
mkdirSync: (_targetPath: string) => {},
|
||||
joinPath: (...parts: string[]) => parts.join('/'),
|
||||
app: {
|
||||
setPath: (_name, value) => {
|
||||
setPathValue = value;
|
||||
},
|
||||
setPath: (_name: string, _value: string) => {},
|
||||
quit: () => {},
|
||||
exit: (code?: number) => {
|
||||
calls.push(`exit:${code ?? 0}`);
|
||||
},
|
||||
on: (event: string) => {
|
||||
appOnCalls.push(event);
|
||||
return {};
|
||||
},
|
||||
exit: (_code?: number) => {},
|
||||
on: (_event: string, _listener: unknown) => ({}),
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {
|
||||
secondInstanceHandlerRegistered = true;
|
||||
},
|
||||
onConfigStartupParseError: () => {
|
||||
throw new Error('unexpected parse failure');
|
||||
},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
|
||||
createLogger: (scope) =>
|
||||
({
|
||||
scope,
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
}) as const,
|
||||
createMainRuntimeRegistry: () => ({ registry: true }),
|
||||
createOverlayManager: () => ({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
registerSecondInstanceHandlerEarly: (_listener: unknown) => {},
|
||||
onConfigStartupParseError: (_error: ConfigStartupParseError) => {},
|
||||
createConfigService: (configDir: string) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath: string) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath: string) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath: string) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
inputState: true,
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
handleModalInputStateChange: (_isActive: boolean) => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
createOverlayContentMeasurementStore: () => ({}),
|
||||
getSyncOverlayShortcutsForModal: () => (_isActive: boolean) => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({ modalRuntime: true }),
|
||||
createAppState: (input) => ({ ...input }),
|
||||
});
|
||||
createOverlayModalRuntime: () => ({}),
|
||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => input,
|
||||
};
|
||||
}
|
||||
|
||||
function createServices(
|
||||
overrides: Partial<ReturnType<typeof baseParams>> & { configDir?: string },
|
||||
) {
|
||||
return createMainBootServices({ ...baseParams(), ...overrides });
|
||||
}
|
||||
|
||||
test('createMainBootServices derives data paths from the resolved config dir', () => {
|
||||
const services = createServices({});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/subminer-config');
|
||||
assert.equal(services.userDataPath, '/tmp/subminer-config');
|
||||
assert.equal(services.defaultMpvLogPath, '/tmp/custom.log');
|
||||
assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' });
|
||||
assert.deepEqual(services.anilistTokenStore, {
|
||||
@@ -115,79 +72,115 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(services.anilistUpdateQueue, {
|
||||
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
|
||||
});
|
||||
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
|
||||
assert.deepEqual(services.annotationSubtitleWsService, {
|
||||
kind: 'ws',
|
||||
payloadMode: 'annotated',
|
||||
});
|
||||
assert.deepEqual(services.subtitleWsService, { payloadMode: 'plain' });
|
||||
assert.deepEqual(services.annotationSubtitleWsService, { payloadMode: 'annotated' });
|
||||
assert.deepEqual(services.appState, {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
});
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('ready', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('second-instance', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
services.appLifecycleApp.exit(7);
|
||||
assert.deepEqual(appOnCalls, ['ready']);
|
||||
assert.equal(secondInstanceHandlerRegistered, true);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||
});
|
||||
|
||||
test('createMainBootServices creates the user data dir only when missing and registers it', () => {
|
||||
for (const exists of [false, true]) {
|
||||
const created: string[] = [];
|
||||
const setPaths: Array<[string, string]> = [];
|
||||
createServices({
|
||||
existsSync: () => exists,
|
||||
mkdirSync: (targetPath) => {
|
||||
created.push(targetPath);
|
||||
},
|
||||
app: {
|
||||
...baseParams().app,
|
||||
setPath: (name, value) => {
|
||||
setPaths.push([name, value]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(created, exists ? [] : ['/tmp/subminer-config']);
|
||||
assert.deepEqual(setPaths, [['userData', '/tmp/subminer-config']]);
|
||||
}
|
||||
});
|
||||
|
||||
test('createMainBootServices uses a trimmed MPV log env path and falls back when blank', () => {
|
||||
const cases = [
|
||||
{ envMpvLog: ' /tmp/custom.log ', expected: '/tmp/custom.log' },
|
||||
{ envMpvLog: ' ', expected: '/tmp/default.log' },
|
||||
{ envMpvLog: undefined, expected: '/tmp/default.log' },
|
||||
];
|
||||
for (const { envMpvLog, expected } of cases) {
|
||||
assert.equal(createServices({ envMpvLog }).defaultMpvLogPath, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
|
||||
const services = createMainBootServices({
|
||||
platform: 'linux',
|
||||
argv: ['electron', '.', '--dev'],
|
||||
const services = createServices({
|
||||
configDir: '/tmp/SubMiner-dev',
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: undefined,
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => {
|
||||
throw new Error('early profile should be authoritative');
|
||||
},
|
||||
existsSync: () => false,
|
||||
mkdirSync: () => {},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
app: {
|
||||
setPath: () => {},
|
||||
quit: () => {},
|
||||
exit: () => {},
|
||||
on: () => ({}),
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {},
|
||||
onConfigStartupParseError: () => {},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({}),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({}),
|
||||
createAppState: (input) => input,
|
||||
});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/SubMiner-dev');
|
||||
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
|
||||
});
|
||||
|
||||
test('createMainBootServices routes second-instance listeners to the early handler', () => {
|
||||
const appEvents: string[] = [];
|
||||
const earlyListeners: unknown[] = [];
|
||||
const services = createServices({
|
||||
app: {
|
||||
...baseParams().app,
|
||||
on: (event) => {
|
||||
appEvents.push(event);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
registerSecondInstanceHandlerEarly: (listener) => {
|
||||
earlyListeners.push(listener);
|
||||
},
|
||||
});
|
||||
|
||||
const secondInstanceListener = () => {};
|
||||
services.appLifecycleApp.on('ready', () => {});
|
||||
services.appLifecycleApp.on('second-instance', secondInstanceListener);
|
||||
|
||||
assert.deepEqual(appEvents, ['ready']);
|
||||
assert.deepEqual(earlyListeners, [secondInstanceListener]);
|
||||
});
|
||||
|
||||
test('createMainBootServices skips the single-instance lock when bypassed', () => {
|
||||
for (const bypass of [false, true]) {
|
||||
let lockRequests = 0;
|
||||
const services = createServices({
|
||||
shouldBypassSingleInstanceLock: () => bypass,
|
||||
requestSingleInstanceLockEarly: () => {
|
||||
lockRequests += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(services.appLifecycleApp.requestSingleInstanceLock(), bypass);
|
||||
assert.equal(lockRequests, bypass ? 0 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('createMainBootServices reports config parse errors and rethrows', () => {
|
||||
const parseError = new ConfigStartupParseError('/tmp/config.jsonc', 'unexpected token');
|
||||
const reported: ConfigStartupParseError[] = [];
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createServices({
|
||||
createConfigService: () => {
|
||||
throw parseError;
|
||||
},
|
||||
onConfigStartupParseError: (error) => {
|
||||
reported.push(error);
|
||||
},
|
||||
}),
|
||||
(error) => error === parseError,
|
||||
);
|
||||
assert.deepEqual(reported, [parseError]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningDialogDetails,
|
||||
buildConfigWarningNotificationBody,
|
||||
buildConfigWarningSummary,
|
||||
failStartupFromConfig,
|
||||
@@ -54,29 +52,27 @@ test('buildConfigWarningNotificationBody includes concise warning details', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConfigWarningDialogDetails includes full warning details', () => {
|
||||
const details = buildConfigWarningDialogDetails('/tmp/config.jsonc', [
|
||||
{
|
||||
path: 'ankiConnect.pollingRate',
|
||||
message: 'must be >= 50',
|
||||
value: 10,
|
||||
fallback: 250,
|
||||
},
|
||||
test('buildConfigWarningNotificationBody caps listed warnings and clips long config paths', () => {
|
||||
const longPath = `/home/user/${'nested/'.repeat(10)}config.jsonc`;
|
||||
const warnings = [1, 2, 3, 4, 5].map((n) => ({
|
||||
path: `key${n}`,
|
||||
message: 'invalid',
|
||||
value: n,
|
||||
fallback: 0,
|
||||
}));
|
||||
|
||||
const lines = buildConfigWarningNotificationBody(longPath, warnings).split('\n');
|
||||
|
||||
assert.equal(lines[0], '5 config validation issue(s) detected.');
|
||||
const fileLine = lines.find((line) => line.startsWith('File: '));
|
||||
assert.equal(fileLine, `File: ...${longPath.slice(-45)}`);
|
||||
assert.equal(fileLine?.length, 'File: '.length + 48);
|
||||
assert.deepEqual(lines.slice(-4), [
|
||||
'1. key1: invalid',
|
||||
'2. key2: invalid',
|
||||
'3. key3: invalid',
|
||||
'+2 more issue(s)',
|
||||
]);
|
||||
|
||||
assert.match(details, /SubMiner detected config validation issues\./);
|
||||
assert.match(details, /File: \/tmp\/config\.jsonc/);
|
||||
assert.match(details, /1\. ankiConnect\.pollingRate: must be >= 50/);
|
||||
assert.match(details, /actual=10 fallback=250/);
|
||||
});
|
||||
|
||||
test('buildConfigParseErrorDetails includes path error and restart guidance', () => {
|
||||
const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1');
|
||||
|
||||
assert.match(details, /Failed to parse config file at:/);
|
||||
assert.match(details, /\/tmp\/config\.jsonc/);
|
||||
assert.match(details, /Error: unexpected token at line 1/);
|
||||
assert.match(details, /Fix the config file and restart SubMiner\./);
|
||||
});
|
||||
|
||||
test('failStartupFromConfig invokes handlers and throws', () => {
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
|
||||
test('SM-012 controller config update path does not use JSON serialize-clone helpers', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/main/controller-config-update.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
|
||||
@@ -15,12 +15,13 @@ function withTempDir(run: (directory: string) => void): void {
|
||||
}
|
||||
|
||||
test('runtime guard major matches the pinned Electron dependency', () => {
|
||||
// A stale constant would make the guard reject the bundled runtime after an Electron bump.
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||
) as { devDependencies: { electron: string } };
|
||||
const pinnedMajor = Number.parseInt(packageJson.devDependencies.electron.replace(/^\D*/, ''), 10);
|
||||
|
||||
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
|
||||
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
|
||||
assert.equal(SUPPORTED_ELECTRON_MAJOR, pinnedMajor);
|
||||
});
|
||||
|
||||
test('runtime guard records the supported Electron major', () => {
|
||||
@@ -155,28 +156,6 @@ test('runtime guard allows a patch downgrade while preserving minor downgrade pr
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
const previousState = JSON.stringify({
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.7.2',
|
||||
});
|
||||
fs.writeFileSync(statePath, previousState, 'utf8');
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '42.11.8',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 42,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.equal(fs.readFileSync(statePath, 'utf8'), previousState);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard fails closed when its safety record is malformed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('main process call sites bypass zero-logic pass-through wrappers', () => {
|
||||
const sourcePath = path.join(process.cwd(), 'src/main.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(
|
||||
sourcePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
|
||||
if (isRuntimePassthrough(statement, statement.body)) {
|
||||
offenders.push(statement.name.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.initializer &&
|
||||
(ts.isArrowFunction(declaration.initializer) ||
|
||||
ts.isFunctionExpression(declaration.initializer)) &&
|
||||
isRuntimePassthrough(declaration.initializer, declaration.initializer.body)
|
||||
) {
|
||||
offenders.push(declaration.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
function isRuntimePassthrough(
|
||||
callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
|
||||
body: ts.ConciseBody,
|
||||
): boolean {
|
||||
let expression: ts.Expression | undefined;
|
||||
if (!ts.isBlock(body)) {
|
||||
expression = body;
|
||||
} else {
|
||||
if (body.statements.length !== 1) return false;
|
||||
const statement = body.statements[0]!;
|
||||
if (ts.isExpressionStatement(statement)) expression = statement.expression;
|
||||
if (ts.isReturnStatement(statement)) expression = statement.expression;
|
||||
}
|
||||
if (!expression) return false;
|
||||
if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) {
|
||||
expression = expression.expression;
|
||||
}
|
||||
if (!ts.isCallExpression(expression)) return false;
|
||||
|
||||
const root = getCalleeRoot(expression.expression);
|
||||
if (!root) return false;
|
||||
if (callable.parameters.length !== expression.arguments.length) return false;
|
||||
|
||||
return callable.parameters.every((parameter, index) => {
|
||||
if (!ts.isIdentifier(parameter.name)) return false;
|
||||
const argument = expression.arguments[index];
|
||||
if (!argument) return false;
|
||||
if (parameter.dotDotDotToken) {
|
||||
if (!ts.isSpreadElement(argument)) return false;
|
||||
const spreadExpression = unwrapTransparentExpression(argument.expression);
|
||||
return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text;
|
||||
}
|
||||
const argumentExpression = unwrapTransparentExpression(argument);
|
||||
return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text;
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapTransparentExpression(expression: ts.Expression): ts.Expression {
|
||||
let current = expression;
|
||||
while (
|
||||
ts.isParenthesizedExpression(current) ||
|
||||
ts.isAsExpression(current) ||
|
||||
ts.isNonNullExpression(current)
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getCalleeRoot(expression: ts.LeftHandSideExpression): string {
|
||||
let current: ts.Expression = expression;
|
||||
while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) {
|
||||
current = current.expression;
|
||||
}
|
||||
return ts.isIdentifier(current) ? current.text : '';
|
||||
}
|
||||
+17
-321
@@ -1,3 +1,7 @@
|
||||
// Interim source-text guards: each test regexes src/main.ts (or a runtime file) to
|
||||
// pin a startup/overlay invariant that main.ts closures make hard to exercise.
|
||||
// Replace each with a behavioral test as the logic moves out of main.ts closures
|
||||
// (see docs/workflow/testing.md, Known Debt). Do not add new tests here.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -50,16 +54,6 @@ test('media path changes clear rendered subtitle state without clearing same-you
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes start the YouTube media cache coordinator', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /youtubeMediaCachePlaybackRuntime\.handleMediaPathChange\(path\);/);
|
||||
});
|
||||
|
||||
test('same media path updates do not reset autoplay ready fallback state', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -104,19 +98,6 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const dismissBlock = source.match(
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(dismissBlock);
|
||||
assert.match(
|
||||
dismissBlock,
|
||||
/sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -157,6 +138,19 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
// Hiding must also dismiss the loading OSD, or it lingers over mpv.
|
||||
assert.match(
|
||||
setVisibleBlock,
|
||||
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
@@ -176,56 +170,6 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/,
|
||||
);
|
||||
});
|
||||
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/,
|
||||
);
|
||||
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
|
||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||
assert.match(source, /installWhenAvailable:\s*true/);
|
||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
||||
);
|
||||
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
});
|
||||
|
||||
test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -263,8 +207,6 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
||||
);
|
||||
assert.match(actionBlock, /if \(cachedPayload\) \{/);
|
||||
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
|
||||
assert.doesNotMatch(actionBlock, /withCurrentSubtitleTiming\(\{ text, tokens: null \}\)/);
|
||||
assert.doesNotMatch(actionBlock, /broadcastToOverlayWindows\('subtitle:set', rawPayload\)/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('consumeCachedSubtitle(text)') <
|
||||
@@ -272,31 +214,6 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeDepsBlock = source.match(
|
||||
/const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const emptyTextBlock = primeSource.match(
|
||||
/if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(runtimeDepsBlock);
|
||||
assert.match(
|
||||
runtimeDepsBlock,
|
||||
/getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/,
|
||||
);
|
||||
|
||||
assert.ok(emptyTextBlock);
|
||||
assert.ok(
|
||||
emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') <
|
||||
emptyTextBlock.indexOf(
|
||||
'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
@@ -509,20 +426,6 @@ test('subtitle annotation updates invalidate prefetched tokenizations before ref
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const depsBlock = source.match(
|
||||
@@ -542,120 +445,6 @@ test('subtitle processing controller resumes prefetch on settle, not on its emit
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
|
||||
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
|
||||
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
|
||||
});
|
||||
|
||||
test('manual visible overlay hide dismisses loading OSD', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const setOverlayBlock = source.match(
|
||||
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.ok(setOverlayBlock);
|
||||
assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('configured overlay notifications require visible ready overlay window', () => {
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const readinessBlock = source.match(
|
||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const statusBlock = source.match(
|
||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(readinessBlock);
|
||||
assert.ok(statusBlock);
|
||||
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||
});
|
||||
|
||||
test('YouTube media cache lifecycle routes through configured status notifications', () => {
|
||||
const source = readMainSource();
|
||||
const cacheBlock = source.match(
|
||||
/const youtubeMediaCache = createYoutubeMediaCacheService\(\{(?<body>[\s\S]*?)\n\}\);\nconst waitForYoutubeMpvConnected/,
|
||||
)?.groups?.body;
|
||||
const startCacheBlock = source.match(
|
||||
/startYoutubeMediaCache:\s*\(url\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n runYoutubePlaybackFlow/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(cacheBlock);
|
||||
assert.ok(startCacheBlock);
|
||||
assert.match(
|
||||
cacheBlock,
|
||||
/onDownloadStarted:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache is downloading\.'/,
|
||||
);
|
||||
assert.match(cacheBlock, /id:\s*'youtube-media-cache-status'/);
|
||||
assert.match(cacheBlock, /variant:\s*'progress'/);
|
||||
assert.match(cacheBlock, /persistent:\s*true/);
|
||||
assert.match(
|
||||
cacheBlock,
|
||||
/onReady:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache ready\.'/,
|
||||
);
|
||||
assert.match(cacheBlock, /variant:\s*'success'/);
|
||||
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
|
||||
assert.match(
|
||||
startCacheBlock,
|
||||
/const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/,
|
||||
);
|
||||
assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/);
|
||||
assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/);
|
||||
});
|
||||
|
||||
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const frequencyOptionsSnapshot = emitBlock?.match(
|
||||
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
||||
)?.[0];
|
||||
|
||||
assert.ok(emitBlock);
|
||||
assert.ok(frequencyOptionsSnapshot);
|
||||
assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/);
|
||||
assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/);
|
||||
assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/);
|
||||
assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/);
|
||||
assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||
assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/);
|
||||
assert.match(
|
||||
emitBlock,
|
||||
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
@@ -685,31 +474,6 @@ test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||
const source = readMainSource();
|
||||
const subtitleBlock = source.match(
|
||||
/startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startAnnotationWebsocket:/,
|
||||
)?.groups?.body;
|
||||
const annotationBlock = source.match(
|
||||
/startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startTexthooker:/,
|
||||
)?.groups?.body;
|
||||
const subtitleFrequencyOptionsCallback = subtitleBlock?.match(
|
||||
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||
)?.groups?.body;
|
||||
const annotationFrequencyOptionsCallback = annotationBlock?.match(
|
||||
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(subtitleFrequencyOptionsCallback);
|
||||
assert.ok(annotationFrequencyOptionsCallback);
|
||||
for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) {
|
||||
assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/);
|
||||
assert.match(callback, /topX:\s*frequencyDictionary\.topX/);
|
||||
assert.match(callback, /mode:\s*frequencyDictionary\.mode/);
|
||||
assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv connection flushes queued configured OSD notifications', () => {
|
||||
const source = readMainSource();
|
||||
const connectedBlock = source.match(
|
||||
@@ -721,48 +485,6 @@ test('mpv connection flushes queued configured OSD notifications', () => {
|
||||
assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/);
|
||||
});
|
||||
|
||||
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resetBlock);
|
||||
assert.ok(setBlock);
|
||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
|
||||
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
@@ -841,16 +563,6 @@ test('Linux visible overlay bounds refresh restores X11 shape after applying mpv
|
||||
);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
assert.equal((source.match(/socketPath: appState\.mpvSocketPath/g) ?? []).length, 1);
|
||||
assert.equal(
|
||||
(source.match(/binaryPath: getResolvedConfig\(\)\.mpv\.subminerBinaryPath/g) ?? []).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
|
||||
const source = readMainSource();
|
||||
const snapshotBlock = source.match(
|
||||
@@ -866,19 +578,3 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main process extracts internal subtitle tracks without a network-mount guard', () => {
|
||||
const source = readMainSource();
|
||||
const resolverWiring = source.match(
|
||||
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resolverWiring);
|
||||
// Network-mounted files are extracted like local ones; only remote URLs skip
|
||||
// extraction, handled inside the resolver itself.
|
||||
assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/);
|
||||
assert.match(
|
||||
resolverWiring,
|
||||
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { WindowGeometry } from '../types';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from '../core/services/overlay-window-flags';
|
||||
import {
|
||||
createOverlayModalRuntimeService,
|
||||
type OverlayModalRuntimeOptions,
|
||||
type OverlayWindowResolver,
|
||||
} from './overlay-runtime';
|
||||
|
||||
export const MODAL_URL = 'file:///overlay/index.html?layer=modal';
|
||||
export const MODAL_GEOMETRY: WindowGeometry = { x: 0, y: 0, width: 400, height: 300 };
|
||||
|
||||
export interface FakeOverlayWindowInit {
|
||||
// Prefix for show/hide entries pushed to `log`, e.g. `modal:show`.
|
||||
name?: string;
|
||||
log?: string[];
|
||||
visible?: boolean;
|
||||
focused?: boolean;
|
||||
webContentsFocused?: boolean;
|
||||
ignoreMouseEvents?: boolean;
|
||||
loading?: boolean;
|
||||
url?: string;
|
||||
contentReady?: boolean;
|
||||
documentLoaded?: boolean;
|
||||
}
|
||||
|
||||
// A still-loading window whose document has not been committed yet.
|
||||
export const LOADING_BLANK_WINDOW: FakeOverlayWindowInit = {
|
||||
loading: true,
|
||||
url: '',
|
||||
contentReady: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* BrowserWindow stand-in covering the surface overlay-runtime touches. Defaults to a hidden,
|
||||
* fully loaded, content-ready modal window.
|
||||
*/
|
||||
export class FakeOverlayWindow {
|
||||
destroyed = false;
|
||||
visible = false;
|
||||
focused = false;
|
||||
webContentsFocused = false;
|
||||
ignoreMouseEvents = false;
|
||||
forwardedIgnoreMouseEvents = false;
|
||||
loading = false;
|
||||
url = MODAL_URL;
|
||||
alwaysOnTopCalls: string[] = [];
|
||||
showCount = 0;
|
||||
hideCount = 0;
|
||||
sent: unknown[][] = [];
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG] = true;
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true;
|
||||
|
||||
private readonly name: string;
|
||||
private readonly log: string[] | undefined;
|
||||
private loadListeners: Array<() => void> = [];
|
||||
private readyToShowListeners: Array<() => void> = [];
|
||||
|
||||
readonly webContents = {
|
||||
isLoading: (): boolean => this.loading,
|
||||
getURL: (): string => this.url,
|
||||
send: (channel: string, ...payload: unknown[]): void => {
|
||||
this.sent.push([channel, ...payload]);
|
||||
},
|
||||
isFocused: (): boolean => this.webContentsFocused,
|
||||
focus: (): void => {
|
||||
this.webContentsFocused = true;
|
||||
},
|
||||
once: (_event: 'did-finish-load', listener: () => void): void => {
|
||||
this.loadListeners.push(listener);
|
||||
},
|
||||
};
|
||||
|
||||
constructor(init: FakeOverlayWindowInit = {}) {
|
||||
const { name = 'window', log, contentReady = true, documentLoaded = true, ...fields } = init;
|
||||
this.name = name;
|
||||
this.log = log;
|
||||
Object.assign(this, fields);
|
||||
this[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
|
||||
this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = documentLoaded;
|
||||
}
|
||||
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
isFocused(): boolean {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void {
|
||||
this.ignoreMouseEvents = ignore;
|
||||
this.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
}
|
||||
|
||||
setAlwaysOnTop(flag: boolean, level?: string, relativeLevel?: number): void {
|
||||
this.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
}
|
||||
|
||||
moveTop(): void {}
|
||||
|
||||
show(): void {
|
||||
this.log?.push(`${this.name}:show`);
|
||||
this.visible = true;
|
||||
this.showCount += 1;
|
||||
}
|
||||
|
||||
showInactive(): void {
|
||||
this.log?.push(`${this.name}:show-inactive`);
|
||||
this.visible = true;
|
||||
this.showCount += 1;
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.log?.push(`${this.name}:hide`);
|
||||
this.visible = false;
|
||||
this.hideCount += 1;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
once(_event: 'ready-to-show', listener: () => void): void {
|
||||
this.readyToShowListeners.push(listener);
|
||||
}
|
||||
|
||||
// Commits the modal document and fires the queued did-finish-load listeners.
|
||||
finishLoad(): void {
|
||||
this.loading = false;
|
||||
this.url = MODAL_URL;
|
||||
this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true;
|
||||
for (const listener of this.loadListeners.splice(0)) listener();
|
||||
}
|
||||
|
||||
// Marks renderer content ready and fires the queued ready-to-show listeners.
|
||||
emitReadyToShow(): void {
|
||||
this[OVERLAY_WINDOW_CONTENT_READY_FLAG] = true;
|
||||
for (const listener of this.readyToShowListeners.splice(0)) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export interface OverlayRuntimeHarnessInit {
|
||||
main?: FakeOverlayWindow | null;
|
||||
modal?: FakeOverlayWindow | null;
|
||||
// Called by the runtime's createModalWindow; its result becomes the current modal window.
|
||||
createModal?: () => FakeOverlayWindow | null;
|
||||
// Defaults to linux so behavior does not depend on the host running the tests.
|
||||
platform?: NodeJS.Platform;
|
||||
options?: Omit<OverlayModalRuntimeOptions, 'platform'>;
|
||||
setModalWindowBounds?: OverlayWindowResolver['setModalWindowBounds'];
|
||||
}
|
||||
|
||||
/** Builds the modal runtime over fake windows and records resolver traffic. */
|
||||
export function createHarness(init: OverlayRuntimeHarnessInit = {}) {
|
||||
const harness = {
|
||||
modal: init.modal ?? null,
|
||||
createCalls: 0,
|
||||
boundsCalls: [] as WindowGeometry[],
|
||||
};
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => (init.main ?? null) as never,
|
||||
getModalWindow: () => harness.modal as never,
|
||||
createModalWindow: () => {
|
||||
harness.createCalls += 1;
|
||||
harness.modal = init.createModal?.() ?? null;
|
||||
return harness.modal as never;
|
||||
},
|
||||
getModalGeometry: () => MODAL_GEOMETRY,
|
||||
setModalWindowBounds: (geometry) => {
|
||||
harness.boundsCalls.push(geometry);
|
||||
return init.setModalWindowBounds?.(geometry);
|
||||
},
|
||||
},
|
||||
{ ...init.options, platform: init.platform ?? 'linux' },
|
||||
);
|
||||
return Object.assign(harness, { runtime });
|
||||
}
|
||||
+415
-1135
File diff suppressed because it is too large.
Load diff
@@ -1,79 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildEnsureAnilistMediaGuessMainDepsHandler,
|
||||
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
||||
} from './anilist-media-guess-main-deps';
|
||||
|
||||
test('maybe probe anilist duration main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildMaybeProbeAnilistDurationMainDepsHandler({
|
||||
getState: () => ({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
}),
|
||||
setState: () => calls.push('set-state'),
|
||||
durationRetryIntervalMs: 1000,
|
||||
now: () => 42,
|
||||
requestMpvDuration: async () => 3600,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.durationRetryIntervalMs, 1000);
|
||||
assert.equal(deps.now(), 42);
|
||||
assert.equal(await deps.requestMpvDuration(), 3600);
|
||||
deps.setState({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: 100,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
});
|
||||
deps.logWarn('oops', null);
|
||||
assert.deepEqual(calls, ['set-state', 'warn:oops']);
|
||||
});
|
||||
|
||||
test('ensure anilist media guess main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildEnsureAnilistMediaGuessMainDepsHandler({
|
||||
getState: () => ({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
}),
|
||||
setState: () => calls.push('set-state'),
|
||||
resolveMediaPathForJimaku: (path) => {
|
||||
calls.push('resolve');
|
||||
return path;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
getCurrentMediaTitle: () => 'title',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls.push('guess');
|
||||
return { title: 'title', season: null, episode: 1, source: 'fallback' };
|
||||
},
|
||||
})();
|
||||
|
||||
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
|
||||
assert.equal(deps.getCurrentMediaTitle(), 'title');
|
||||
assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv');
|
||||
assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), {
|
||||
title: 'title',
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'fallback',
|
||||
});
|
||||
deps.setState({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
});
|
||||
assert.deepEqual(calls, ['resolve', 'guess', 'set-state']);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
} from './anilist-media-state-main-deps';
|
||||
|
||||
test('get current anilist media key main deps builder maps callbacks', () => {
|
||||
const deps = createBuildGetCurrentAnilistMediaKeyMainDepsHandler({
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
})();
|
||||
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
|
||||
});
|
||||
|
||||
test('reset anilist media tracking main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildResetAnilistMediaTrackingMainDepsHandler({
|
||||
setMediaKey: () => calls.push('key'),
|
||||
setMediaDurationSec: () => calls.push('duration'),
|
||||
setMediaGuess: () => calls.push('guess'),
|
||||
setMediaGuessPromise: () => calls.push('promise'),
|
||||
setLastDurationProbeAtMs: () => calls.push('probe'),
|
||||
})();
|
||||
deps.setMediaKey(null);
|
||||
deps.setMediaDurationSec(null);
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
deps.setLastDurationProbeAtMs(0);
|
||||
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
|
||||
});
|
||||
|
||||
test('get/set anilist media guess runtime state main deps builders map callbacks', () => {
|
||||
const getter = createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({
|
||||
getMediaKey: () => '/tmp/video.mkv',
|
||||
getMediaDurationSec: () => 24,
|
||||
getMediaGuess: () => ({ title: 'X' }) as never,
|
||||
getMediaGuessPromise: () => Promise.resolve(null) as never,
|
||||
getLastDurationProbeAtMs: () => 123,
|
||||
})();
|
||||
assert.equal(getter.getMediaKey(), '/tmp/video.mkv');
|
||||
assert.equal(getter.getMediaDurationSec(), 24);
|
||||
assert.equal(getter.getLastDurationProbeAtMs(), 123);
|
||||
|
||||
const calls: string[] = [];
|
||||
const setter = createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({
|
||||
setMediaKey: () => calls.push('key'),
|
||||
setMediaDurationSec: () => calls.push('duration'),
|
||||
setMediaGuess: () => calls.push('guess'),
|
||||
setMediaGuessPromise: () => calls.push('promise'),
|
||||
setLastDurationProbeAtMs: () => calls.push('probe'),
|
||||
})();
|
||||
setter.setMediaKey(null);
|
||||
setter.setMediaDurationSec(null);
|
||||
setter.setMediaGuess(null);
|
||||
setter.setMediaGuessPromise(null);
|
||||
setter.setLastDurationProbeAtMs(0);
|
||||
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
|
||||
});
|
||||
|
||||
test('reset anilist media guess state main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildResetAnilistMediaGuessStateMainDepsHandler({
|
||||
setMediaGuess: () => calls.push('guess'),
|
||||
setMediaGuessPromise: () => calls.push('promise'),
|
||||
})();
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
assert.deepEqual(calls, ['guess', 'promise']);
|
||||
});
|
||||
|
||||
test('record anilist media duration main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const state = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
|
||||
getCurrentMediaKey: () => {
|
||||
calls.push('key');
|
||||
return '/tmp/video.mkv';
|
||||
},
|
||||
getState: () => {
|
||||
calls.push('get');
|
||||
return state;
|
||||
},
|
||||
setState: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
})();
|
||||
|
||||
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
|
||||
deps.getState();
|
||||
deps.setState(state);
|
||||
assert.deepEqual(calls, ['key', 'get', 'set']);
|
||||
});
|
||||
@@ -1,12 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createRecordAnilistMediaDurationHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
} from './anilist-media-state';
|
||||
|
||||
test('get current anilist media key trims and normalizes empty path', () => {
|
||||
@@ -66,118 +63,6 @@ test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||
assert.equal(lastDurationProbeAtMs, 0);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking is idempotent', () => {
|
||||
const state = {
|
||||
mediaKey: 'old' as string | null,
|
||||
mediaDurationSec: 123 as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 999,
|
||||
};
|
||||
|
||||
const reset = createResetAnilistMediaTrackingHandler({
|
||||
setMediaKey: (value) => {
|
||||
state.mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
state.mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
state.lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
reset('/new/media');
|
||||
const afterFirstReset = { ...state };
|
||||
reset('/new/media');
|
||||
|
||||
assert.deepEqual(state, afterFirstReset);
|
||||
});
|
||||
|
||||
test('get/set anilist media guess runtime state round-trips fields', () => {
|
||||
let state = {
|
||||
mediaKey: null as string | null,
|
||||
mediaDurationSec: null as number | null,
|
||||
mediaGuess: null as { title: string } | null,
|
||||
mediaGuessPromise: null as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
|
||||
const setState = createSetAnilistMediaGuessRuntimeStateHandler({
|
||||
setMediaKey: (value) => {
|
||||
state.mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
state.mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
state.lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
const getState = createGetAnilistMediaGuessRuntimeStateHandler({
|
||||
getMediaKey: () => state.mediaKey,
|
||||
getMediaDurationSec: () => state.mediaDurationSec,
|
||||
getMediaGuess: () => state.mediaGuess as never,
|
||||
getMediaGuessPromise: () => state.mediaGuessPromise as never,
|
||||
getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs,
|
||||
});
|
||||
|
||||
const nextPromise = Promise.resolve(null);
|
||||
setState({
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: 24,
|
||||
mediaGuess: { title: 'Title' } as never,
|
||||
mediaGuessPromise: nextPromise as never,
|
||||
lastDurationProbeAtMs: 321,
|
||||
});
|
||||
|
||||
const roundTrip = getState();
|
||||
assert.equal(roundTrip.mediaKey, '/tmp/video.mkv');
|
||||
assert.equal(roundTrip.mediaDurationSec, 24);
|
||||
assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' });
|
||||
assert.equal(roundTrip.mediaGuessPromise, nextPromise);
|
||||
assert.equal(roundTrip.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
test('reset anilist media guess state clears guess and in-flight promise', () => {
|
||||
const state = {
|
||||
mediaKey: '/tmp/video.mkv' as string | null,
|
||||
mediaDurationSec: 240 as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 321,
|
||||
};
|
||||
|
||||
const resetGuessState = createResetAnilistMediaGuessStateHandler({
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
});
|
||||
|
||||
resetGuessState();
|
||||
assert.equal(state.mediaGuess, null);
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
assert.equal(state.mediaKey, '/tmp/video.mkv');
|
||||
assert.equal(state.mediaDurationSec, 240);
|
||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
test('record anilist media duration stores observed mpv duration for current media', () => {
|
||||
const existingPromise = Promise.resolve(null);
|
||||
let state = {
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
||||
} from './anilist-post-watch-main-deps';
|
||||
|
||||
test('process next anilist retry update main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
|
||||
nextReady: () => ({ key: 'k', title: 't', season: null, episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: () => calls.push('error'),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `ok:${season}`,
|
||||
}),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
now: () => 7,
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', season: null, episode: 1 });
|
||||
deps.refreshRetryQueueState();
|
||||
deps.setLastAttemptAt(1);
|
||||
deps.setLastError('x');
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||
status: 'updated',
|
||||
message: 'ok:2',
|
||||
});
|
||||
deps.markSuccess('k');
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
deps.markFailure('k', 'bad');
|
||||
deps.logInfo('hello');
|
||||
assert.equal(deps.now(), 7);
|
||||
assert.deepEqual(calls, [
|
||||
'refresh',
|
||||
'attempt',
|
||||
'error',
|
||||
'success',
|
||||
'remember',
|
||||
'failure',
|
||||
'info:hello',
|
||||
]);
|
||||
});
|
||||
|
||||
test('maybe run anilist post watch update main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => calls.push('in-flight'),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => 'media',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => 'media',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 100,
|
||||
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||
calls.push(`probe:${options?.force === true}`);
|
||||
return 120;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
|
||||
markRetryFailure: () => calls.push('retry-fail'),
|
||||
markRetrySuccess: () => calls.push('retry-ok'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `done:${season}`,
|
||||
}),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: () => calls.push('osd'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 5,
|
||||
minWatchRatio: 0.5,
|
||||
})();
|
||||
|
||||
assert.equal(deps.getInFlight(), false);
|
||||
deps.setInFlight(true);
|
||||
assert.equal(deps.isAnilistTrackingEnabled(deps.getResolvedConfig()), true);
|
||||
assert.equal(deps.getCurrentMediaKey(), 'media');
|
||||
assert.equal(deps.hasMpvClient(), true);
|
||||
assert.equal(deps.getTrackedMediaKey(), 'media');
|
||||
deps.resetTrackedMedia('media');
|
||||
assert.equal(deps.getWatchedSeconds(), 100);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120);
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
||||
title: 'x',
|
||||
season: null,
|
||||
episode: 1,
|
||||
});
|
||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
deps.enqueueRetry('k', 't', 1, 2);
|
||||
deps.markRetryFailure('k', 'bad');
|
||||
deps.markRetrySuccess('k');
|
||||
deps.refreshRetryQueueState();
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||
status: 'updated',
|
||||
message: 'done:2',
|
||||
});
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
deps.showMpvOsd('ok');
|
||||
deps.logInfo('x');
|
||||
deps.logWarn('y');
|
||||
assert.equal(deps.minWatchSeconds, 5);
|
||||
assert.equal(deps.minWatchRatio, 0.5);
|
||||
assert.deepEqual(calls, [
|
||||
'in-flight',
|
||||
'reset',
|
||||
'probe:true',
|
||||
'enqueue:2',
|
||||
'retry-fail',
|
||||
'retry-ok',
|
||||
'refresh',
|
||||
'remember',
|
||||
'osd',
|
||||
'info:x',
|
||||
'warn:y',
|
||||
]);
|
||||
});
|
||||
@@ -7,6 +7,99 @@ import {
|
||||
rememberAnilistAttemptedUpdateKey,
|
||||
} from './anilist-post-watch';
|
||||
|
||||
type PostWatchDeps = Parameters<typeof createMaybeRunAnilistPostWatchUpdateHandler>[0];
|
||||
type RetryDeps = Parameters<typeof createProcessNextAnilistRetryUpdateHandler>[0];
|
||||
type ProbeOptions = Parameters<PostWatchDeps['maybeProbeAnilistDuration']>[1];
|
||||
type ProgressCall = {
|
||||
title: string;
|
||||
episode: number;
|
||||
season?: number | null;
|
||||
mediaId?: number | null;
|
||||
};
|
||||
|
||||
const MEDIA_KEY = '/tmp/video.mkv';
|
||||
|
||||
function emptyOutcome() {
|
||||
return {
|
||||
inFlight: [] as boolean[],
|
||||
resets: [] as Array<string | null>,
|
||||
probes: [] as ProbeOptions[],
|
||||
guesses: 0,
|
||||
retryRuns: 0,
|
||||
tokenRefreshes: 0,
|
||||
updates: [] as ProgressCall[],
|
||||
enqueued: [] as Array<ProgressCall & { key: string }>,
|
||||
remembered: [] as string[],
|
||||
retrySuccesses: [] as string[],
|
||||
retryFailures: [] as Array<{ key: string; message: string }>,
|
||||
queueRefreshes: 0,
|
||||
osd: [] as string[],
|
||||
info: [] as string[],
|
||||
warn: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
// Defaults model a fully watched local episode 1 with a valid token. Default deps record
|
||||
// into `outcome`; an overridden dep records nothing unless the test does it itself.
|
||||
function makeDeps(overrides: Partial<PostWatchDeps> = {}) {
|
||||
const outcome = emptyOutcome();
|
||||
const attemptedKeys = new Set<string>();
|
||||
let inFlight = false;
|
||||
const deps: PostWatchDeps = {
|
||||
getInFlight: () => inFlight,
|
||||
setInFlight: (value) => {
|
||||
inFlight = value;
|
||||
outcome.inFlight.push(value);
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => MEDIA_KEY,
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => MEDIA_KEY,
|
||||
resetTrackedMedia: (mediaKey) => outcome.resets.push(mediaKey),
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||
outcome.probes.push(options);
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => {
|
||||
outcome.guesses += 1;
|
||||
return { title: 'Show', season: null, episode: 1 };
|
||||
},
|
||||
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||
processNextAnilistRetryUpdate: async () => {
|
||||
outcome.retryRuns += 1;
|
||||
return { ok: true, message: 'noop' };
|
||||
},
|
||||
refreshAnilistClientSecretState: async () => {
|
||||
outcome.tokenRefreshes += 1;
|
||||
return 'token';
|
||||
},
|
||||
enqueueRetry: (key, title, episode, season, mediaId) =>
|
||||
outcome.enqueued.push({ key, title, episode, season, mediaId }),
|
||||
markRetryFailure: (key, message) => outcome.retryFailures.push({ key, message }),
|
||||
markRetrySuccess: (key) => outcome.retrySuccesses.push(key),
|
||||
refreshRetryQueueState: () => {
|
||||
outcome.queueRefreshes += 1;
|
||||
},
|
||||
updateAnilistPostWatchProgress: async (_accessToken, title, episode, season, mediaId) => {
|
||||
outcome.updates.push({ title, episode, season, mediaId });
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
attemptedKeys.add(key);
|
||||
outcome.remembered.push(key);
|
||||
},
|
||||
showMpvOsd: (message) => outcome.osd.push(message),
|
||||
logInfo: (message) => outcome.info.push(message),
|
||||
logWarn: (message) => outcome.warn.push(message),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
...overrides,
|
||||
};
|
||||
return { outcome, attemptedKeys, run: createMaybeRunAnilistPostWatchUpdateHandler(deps) };
|
||||
}
|
||||
|
||||
test('buildAnilistAttemptKey formats media and episode', () => {
|
||||
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
|
||||
assert.equal(
|
||||
@@ -29,554 +122,221 @@ test('post-watch rejects empty media identities before attempted keys or update
|
||||
'https://[invalid',
|
||||
]) {
|
||||
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
|
||||
const calls: string[] = [];
|
||||
const unexpected = () => assert.fail('invalid identity reached update side effects');
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
const { outcome, run } = makeDeps({
|
||||
getCurrentMediaKey: () => mediaKey,
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => mediaKey,
|
||||
resetTrackedMedia: unexpected,
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
|
||||
hasAttemptedUpdateKey: unexpected,
|
||||
processNextAnilistRetryUpdate: unexpected,
|
||||
refreshAnilistClientSecretState: unexpected,
|
||||
enqueueRetry: unexpected,
|
||||
markRetryFailure: unexpected,
|
||||
markRetrySuccess: unexpected,
|
||||
refreshRetryQueueState: unexpected,
|
||||
updateAnilistPostWatchProgress: unexpected,
|
||||
rememberAttemptedUpdateKey: unexpected,
|
||||
showMpvOsd: unexpected,
|
||||
logInfo: unexpected,
|
||||
logWarn: unexpected,
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
hasAttemptedUpdateKey: () => assert.fail('invalid identity reached attempted-key lookup'),
|
||||
});
|
||||
await run();
|
||||
await run({ force: true });
|
||||
// Only gating ran: no retry, token, update, queue, or notification side effects.
|
||||
assert.deepEqual(outcome, {
|
||||
...emptyOutcome(),
|
||||
inFlight: [true, false, true, false],
|
||||
probes: [{ force: false }],
|
||||
guesses: 2,
|
||||
});
|
||||
await handler();
|
||||
await handler({ force: true });
|
||||
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
|
||||
}
|
||||
});
|
||||
|
||||
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
||||
test('createProcessNextAnilistRetryUpdateHandler forwards the queued item and marks success', async () => {
|
||||
const updates: ProgressCall[] = [];
|
||||
const successes: string[] = [];
|
||||
const remembered: string[] = [];
|
||||
const errors: Array<string | null> = [];
|
||||
const deps: RetryDeps = {
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, mediaId: 108489, episode: 1 }),
|
||||
refreshRetryQueueState: () => {},
|
||||
setLastAttemptAt: () => {},
|
||||
setLastError: (value) => errors.push(value),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `updated ok:${season}`,
|
||||
}),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
logInfo: () => calls.push('info'),
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
const result = await handler();
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
|
||||
assert.ok(calls.includes('success'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => null,
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.ok(calls.includes('enqueue'));
|
||||
assert.ok(calls.includes('mark-failure'));
|
||||
assert.ok(calls.includes('osd:AniList: access token not configured'));
|
||||
assert.ok(calls.includes('inflight:true'));
|
||||
assert.ok(calls.includes('inflight:false'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => false,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 0,
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
updateAnilistPostWatchProgress: async (_accessToken, title, episode, season, mediaId) => {
|
||||
updates.push({ title, episode, season, mediaId });
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
markSuccess: (key) => successes.push(key),
|
||||
rememberAttemptedUpdateKey: (key) => remembered.push(key),
|
||||
markFailure: () => assert.fail('successful retry marked as failure'),
|
||||
logInfo: () => {},
|
||||
now: () => 1,
|
||||
};
|
||||
|
||||
const result = await createProcessNextAnilistRetryUpdateHandler(deps)();
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
||||
assert.deepEqual(updates, [{ title: 'Show', episode: 1, season: 2, mediaId: 108489 }]);
|
||||
assert.deepEqual(successes, ['k1']);
|
||||
assert.deepEqual(remembered, ['k1']);
|
||||
assert.deepEqual(errors, [null]);
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
{ name: 'queues when token missing', pinnedMediaId: null },
|
||||
{ name: 'queues the pinned media id for retry', pinnedMediaId: 108489 },
|
||||
]) {
|
||||
test(`createMaybeRunAnilistPostWatchUpdateHandler ${c.name}`, async () => {
|
||||
const key = `${MEDIA_KEY}::1`;
|
||||
const { outcome, run } = makeDeps({
|
||||
refreshAnilistClientSecretState: async () => null,
|
||||
resolvePinnedAnilistMediaId: async () => c.pinnedMediaId,
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
assert.deepEqual(outcome.updates, []);
|
||||
assert.deepEqual(outcome.enqueued, [
|
||||
{ key, title: 'Show', episode: 1, season: null, mediaId: c.pinnedMediaId },
|
||||
]);
|
||||
assert.deepEqual(outcome.retryFailures, [
|
||||
{ key, message: 'cannot authenticate without anilist.accessToken' },
|
||||
]);
|
||||
assert.deepEqual(outcome.osd, ['AniList: access token not configured']);
|
||||
assert.deepEqual(outcome.inFlight, [true, false]);
|
||||
});
|
||||
}
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
|
||||
const { outcome, run } = makeDeps({
|
||||
hasMpvClient: () => false,
|
||||
getWatchedSeconds: () => 0,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
|
||||
});
|
||||
|
||||
await handler({ force: true });
|
||||
await run({ force: true });
|
||||
|
||||
assert.equal(calls.includes('probe'), false);
|
||||
assert.ok(calls.includes('update'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
assert.deepEqual(outcome.probes, []);
|
||||
assert.equal(outcome.updates.length, 1);
|
||||
assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::3`]);
|
||||
assert.deepEqual(outcome.osd, ['updated ok']);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<string>();
|
||||
const message =
|
||||
'AniList update not possible: Show is not in your AniList Planning or Watching list.';
|
||||
let updateCalls = 0;
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
const { outcome, run } = makeDeps({
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }),
|
||||
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
updateCalls += 1;
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message:
|
||||
'AniList update not possible: Show is not in your AniList Planning or Watching list.',
|
||||
};
|
||||
return { status: 'error', retryable: false, message };
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
attemptedKeys.add(key);
|
||||
calls.push(`remember:${key}`);
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
await handler();
|
||||
await run();
|
||||
await run();
|
||||
|
||||
assert.equal(updateCalls, 1);
|
||||
assert.equal(calls.includes('enqueue'), false);
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.ok(calls.some((call) => call.startsWith('remember:')));
|
||||
assert.ok(calls.includes('refresh'));
|
||||
assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible')));
|
||||
assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible')));
|
||||
assert.deepEqual(outcome.enqueued, []);
|
||||
assert.deepEqual(outcome.retryFailures, []);
|
||||
assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::2`]);
|
||||
assert.equal(outcome.queueRefreshes, 1);
|
||||
assert.deepEqual(outcome.osd, [message]);
|
||||
assert.deepEqual(outcome.warn, [message]);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
|
||||
const calls: string[] = [];
|
||||
let durationProbeOptions: unknown = null;
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
const { outcome, run } = makeDeps({
|
||||
getWatchedSeconds: () => 0,
|
||||
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||
durationProbeOptions = options;
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => {
|
||||
calls.push(`update:${season}`);
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler({ watchedSeconds: 850 });
|
||||
await run({ watchedSeconds: 850 });
|
||||
|
||||
assert.deepEqual(durationProbeOptions, { force: true });
|
||||
assert.ok(calls.includes('update:2'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
assert.deepEqual(outcome.probes, [{ force: true }]);
|
||||
assert.deepEqual(outcome.updates, [{ title: 'Show', episode: 8, season: 2, mediaId: null }]);
|
||||
assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::8`]);
|
||||
assert.deepEqual(outcome.osd, ['updated ok']);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
||||
const calls: string[] = [];
|
||||
let inFlight = false;
|
||||
let probes = 0;
|
||||
let resolveDuration!: (duration: number) => void;
|
||||
const durationPromise = new Promise<number>((resolve) => {
|
||||
resolveDuration = resolve;
|
||||
});
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => inFlight,
|
||||
setInFlight: (value) => {
|
||||
inFlight = value;
|
||||
calls.push(`inflight:${value}`);
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
const { outcome, run } = makeDeps({
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
probes += 1;
|
||||
return await durationPromise;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
const firstRun = handler();
|
||||
assert.deepEqual(calls, ['inflight:true', 'probe']);
|
||||
// The in-flight flag must be set synchronously, before the first await.
|
||||
const firstRun = run();
|
||||
assert.deepEqual(outcome.inFlight, [true]);
|
||||
assert.equal(probes, 1);
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(calls, ['inflight:true', 'probe']);
|
||||
await run();
|
||||
assert.deepEqual(outcome.inFlight, [true]);
|
||||
assert.equal(probes, 1);
|
||||
|
||||
resolveDuration(1000);
|
||||
await firstRun;
|
||||
|
||||
assert.equal(calls.filter((call) => call === 'update').length, 1);
|
||||
assert.equal(calls.at(-1), 'inflight:false');
|
||||
assert.equal(outcome.updates.length, 1);
|
||||
assert.deepEqual(outcome.inFlight, [true, false]);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
const { outcome, run } = makeDeps({
|
||||
getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => {
|
||||
calls.push('guess');
|
||||
return { title: 'Show', season: null, episode: 1 };
|
||||
},
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => {
|
||||
calls.push('process-retry');
|
||||
return { ok: true, message: 'noop' };
|
||||
},
|
||||
refreshAnilistClientSecretState: async () => {
|
||||
calls.push('refresh-token');
|
||||
return 'token';
|
||||
},
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(calls, []);
|
||||
await run();
|
||||
|
||||
assert.deepEqual(outcome, emptyOutcome());
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<string>();
|
||||
const mediaKey = '/tmp/video.mkv';
|
||||
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
|
||||
assert.ok(attemptKey);
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => mediaKey,
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => mediaKey,
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||
const { outcome, attemptedKeys, run } = makeDeps({
|
||||
processNextAnilistRetryUpdate: async () => {
|
||||
attemptedKeys.add(attemptKey);
|
||||
calls.push('process-retry');
|
||||
attemptedKeys.add(`${MEDIA_KEY}::1`);
|
||||
return { ok: true, message: 'retry ok' };
|
||||
},
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
attemptedKeys.add(key);
|
||||
calls.push(`remember:${key}`);
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
await run();
|
||||
|
||||
assert.equal(calls.includes('update'), false);
|
||||
assert.equal(calls.includes('enqueue'), false);
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
||||
assert.deepEqual(outcome.osd, ['retry ok']);
|
||||
assert.equal(outcome.tokenRefreshes, 0);
|
||||
assert.deepEqual(outcome.updates, []);
|
||||
assert.deepEqual(outcome.enqueued, []);
|
||||
assert.deepEqual(outcome.retryFailures, []);
|
||||
assert.deepEqual(outcome.inFlight, [true, false]);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => {
|
||||
const updateArgs: Array<number | null | undefined> = [];
|
||||
const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> =
|
||||
const guess = { title: 'Show', season: 3, episode: 1 };
|
||||
const pinInputs: Array<Parameters<NonNullable<PostWatchDeps['resolvePinnedAnilistMediaId']>>[0]> =
|
||||
[];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => {},
|
||||
const { outcome, run } = makeDeps({
|
||||
ensureAnilistMediaGuess: async () => guess,
|
||||
getCurrentMediaTitle: () => 'Show S03E01.mkv',
|
||||
resolvePinnedAnilistMediaId: async (input) => {
|
||||
pinInputs.push(input);
|
||||
return 108489;
|
||||
},
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
||||
updateArgs.push(mediaId);
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(updateArgs, [108489]);
|
||||
await run();
|
||||
|
||||
assert.deepEqual(outcome.updates, [{ title: 'Show', episode: 1, season: 3, mediaId: 108489 }]);
|
||||
// Resolved against the media this run captured, not whatever is playing now.
|
||||
assert.equal(pinInputs.length, 1);
|
||||
assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv');
|
||||
assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv');
|
||||
assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 });
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => {
|
||||
const enqueued: Array<number | null | undefined> = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => null,
|
||||
enqueueRetry: (_key, _title, _episode, _season, mediaId) => {
|
||||
enqueued.push(mediaId);
|
||||
},
|
||||
resolvePinnedAnilistMediaId: async () => 108489,
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(enqueued, [108489]);
|
||||
assert.deepEqual(pinInputs, [{ mediaPath: MEDIA_KEY, mediaTitle: 'Show S03E01.mkv', guess }]);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => {
|
||||
const updateArgs: Array<number | null | undefined> = [];
|
||||
const warnings: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => {},
|
||||
const { outcome, run } = makeDeps({
|
||||
resolvePinnedAnilistMediaId: async () => {
|
||||
throw new Error('store unreadable');
|
||||
},
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
||||
updateArgs.push(mediaId);
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(updateArgs, [null]);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /override lookup failed/i);
|
||||
});
|
||||
await run();
|
||||
|
||||
test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => {
|
||||
const received: Array<number | null | undefined> = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }),
|
||||
refreshRetryQueueState: () => {},
|
||||
setLastAttemptAt: () => {},
|
||||
setLastError: () => {},
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
||||
received.push(mediaId);
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
markSuccess: () => {},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
markFailure: () => {},
|
||||
logInfo: () => {},
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(received, [108489]);
|
||||
assert.deepEqual(
|
||||
outcome.updates.map((update) => update.mediaId),
|
||||
[null],
|
||||
);
|
||||
assert.equal(outcome.warn.length, 1);
|
||||
assert.match(outcome.warn[0]!, /override lookup failed/i);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler,
|
||||
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler,
|
||||
createBuildNotifyAnilistSetupMainDepsHandler,
|
||||
createBuildRegisterSubminerProtocolClientMainDepsHandler,
|
||||
} from './anilist-setup-protocol-main-deps';
|
||||
|
||||
test('notify anilist setup main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
|
||||
hasMpvClient: () => true,
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title) => calls.push(`notify:${title}`),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.hasMpvClient(), true);
|
||||
deps.showMpvOsd('ok');
|
||||
deps.showDesktopNotification('SubMiner', { body: 'x' });
|
||||
deps.logInfo('done');
|
||||
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
|
||||
});
|
||||
|
||||
test('notify anilist setup main deps builder preserves optional notification callbacks', () => {
|
||||
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
|
||||
hasMpvClient: () => true,
|
||||
showMpvOsd: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
logInfo: () => {},
|
||||
})();
|
||||
|
||||
assert.equal(deps.getNotificationType, undefined);
|
||||
assert.equal(deps.showOverlayNotification, undefined);
|
||||
});
|
||||
|
||||
test('consume anilist setup token main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
|
||||
consumeAnilistSetupCallbackUrl: () => true,
|
||||
saveToken: () => {
|
||||
calls.push('save');
|
||||
return true;
|
||||
},
|
||||
setCachedToken: () => calls.push('cache'),
|
||||
setResolvedState: () => calls.push('resolved'),
|
||||
setSetupPageOpened: () => calls.push('opened'),
|
||||
onSuccess: () => calls.push('success'),
|
||||
closeWindow: () => calls.push('close'),
|
||||
})();
|
||||
|
||||
assert.equal(
|
||||
deps.consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup',
|
||||
saveToken: () => true,
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
onSuccess: () => {},
|
||||
closeWindow: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
deps.saveToken('token');
|
||||
deps.setCachedToken('token');
|
||||
deps.setResolvedState(Date.now());
|
||||
deps.setSetupPageOpened(true);
|
||||
deps.onSuccess();
|
||||
deps.closeWindow();
|
||||
assert.deepEqual(calls, ['save', 'cache', 'resolved', 'opened', 'success', 'close']);
|
||||
});
|
||||
|
||||
test('handle anilist setup protocol url main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildHandleAnilistSetupProtocolUrlMainDepsHandler({
|
||||
consumeAnilistSetupTokenFromUrl: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.consumeAnilistSetupTokenFromUrl('subminer://anilist-setup'), true);
|
||||
deps.logWarn('missing', null);
|
||||
assert.deepEqual(calls, ['warn:missing']);
|
||||
});
|
||||
|
||||
test('register subminer protocol client main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRegisterSubminerProtocolClientMainDepsHandler({
|
||||
isDefaultApp: () => true,
|
||||
getArgv: () => ['electron', 'entry.js'],
|
||||
execPath: '/tmp/electron',
|
||||
resolvePath: (value) => `/abs/${value}`,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isDefaultApp(), true);
|
||||
assert.deepEqual(deps.getArgv(), ['electron', 'entry.js']);
|
||||
assert.equal(deps.execPath, '/tmp/electron');
|
||||
assert.equal(deps.resolvePath('entry.js'), '/abs/entry.js');
|
||||
assert.equal(deps.setAsDefaultProtocolClient('subminer'), true);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createConsumeAnilistSetupTokenFromUrlHandler,
|
||||
createHandleAnilistSetupProtocolUrlHandler,
|
||||
createNotifyAnilistSetupHandler,
|
||||
createRegisterSubminerProtocolClientHandler,
|
||||
@@ -37,20 +36,6 @@ test('createNotifyAnilistSetupHandler routes through configured notification sur
|
||||
]);
|
||||
});
|
||||
|
||||
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
|
||||
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
|
||||
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
|
||||
saveToken: () => true,
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
onSuccess: () => {},
|
||||
closeWindow: () => {},
|
||||
});
|
||||
assert.equal(consume('subminer://anilist-setup?access_token=ok'), true);
|
||||
assert.equal(consume('subminer://anilist-setup'), false);
|
||||
});
|
||||
|
||||
test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => {
|
||||
const warnings: string[] = [];
|
||||
const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildOpenAnilistSetupWindowMainDepsHandler } from './anilist-setup-window-main-deps';
|
||||
|
||||
test('open anilist setup window main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildOpenAnilistSetupWindowMainDepsHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => ({}) as never,
|
||||
buildAuthorizeUrl: () => 'https://anilist.co/auth',
|
||||
consumeCallbackUrl: () => true,
|
||||
openSetupInBrowser: (url) => calls.push(`browser:${url}`),
|
||||
loadManualTokenEntry: () => calls.push('manual'),
|
||||
redirectUri: 'subminer://anilist-auth',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
clearSetupWindow: () => calls.push('clear'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${String(opened)}`),
|
||||
setSetupWindow: () => calls.push('window'),
|
||||
openExternal: (url) => calls.push(`external:${url}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
|
||||
assert.equal(deps.buildAuthorizeUrl(), 'https://anilist.co/auth');
|
||||
assert.equal(deps.consumeCallbackUrl('subminer://anilist-setup?access_token=x'), true);
|
||||
assert.equal(deps.redirectUri, 'subminer://anilist-auth');
|
||||
assert.equal(deps.developerSettingsUrl, 'https://anilist.co/settings/developer');
|
||||
assert.equal(deps.isAllowedExternalUrl('https://anilist.co'), true);
|
||||
assert.equal(deps.isAllowedNavigationUrl('https://anilist.co/oauth'), true);
|
||||
deps.openSetupInBrowser('https://anilist.co/auth');
|
||||
deps.loadManualTokenEntry({} as never, 'https://anilist.co/auth');
|
||||
deps.logWarn('warn');
|
||||
deps.logError('error', null);
|
||||
deps.clearSetupWindow();
|
||||
deps.setSetupPageOpened(true);
|
||||
deps.setSetupWindow({} as never);
|
||||
deps.openExternal('https://anilist.co');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'browser:https://anilist.co/auth',
|
||||
'manual',
|
||||
'warn:warn',
|
||||
'error:error',
|
||||
'clear',
|
||||
'opened:true',
|
||||
'window',
|
||||
'external:https://anilist.co',
|
||||
]);
|
||||
});
|
||||
@@ -1,12 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createHandleAnilistSetupWindowClosedHandler,
|
||||
createMaybeFocusExistingAnilistSetupWindowHandler,
|
||||
createHandleAnilistSetupWindowOpenedHandler,
|
||||
createAnilistSetupDidFailLoadHandler,
|
||||
createAnilistSetupDidFinishLoadHandler,
|
||||
createAnilistSetupDidNavigateHandler,
|
||||
createAnilistSetupFallbackHandler,
|
||||
createAnilistSetupWillNavigateHandler,
|
||||
createAnilistSetupWillRedirectHandler,
|
||||
@@ -32,20 +27,6 @@ test('manual anilist setup submission forwards access token to callback consumer
|
||||
assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123'));
|
||||
});
|
||||
|
||||
test('maybe focus anilist setup window focuses existing window', () => {
|
||||
let focused = false;
|
||||
const handler = createMaybeFocusExistingAnilistSetupWindowHandler({
|
||||
getSetupWindow: () => ({
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const handled = handler();
|
||||
assert.equal(handled, true);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test('manual anilist setup submission warns on missing token', () => {
|
||||
const warnings: string[] = [];
|
||||
const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({
|
||||
@@ -153,35 +134,6 @@ test('anilist setup will-redirect handler prevents callback redirects', () => {
|
||||
assert.equal(prevented, true);
|
||||
});
|
||||
|
||||
test('anilist setup did-navigate handler consumes callback url', () => {
|
||||
const seen: string[] = [];
|
||||
const handler = createAnilistSetupDidNavigateHandler({
|
||||
consumeCallbackUrl: (url) => {
|
||||
seen.push(url);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
handler('https://anilist.subminer.moe/#access_token=abc');
|
||||
assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']);
|
||||
});
|
||||
|
||||
test('anilist setup did-fail-load handler forwards details', () => {
|
||||
const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = [];
|
||||
const handler = createAnilistSetupDidFailLoadHandler({
|
||||
onLoadFailure: (details) => seen.push(details),
|
||||
});
|
||||
|
||||
handler({
|
||||
errorCode: -3,
|
||||
errorDescription: 'timeout',
|
||||
validatedURL: 'https://anilist.co/api/v2/oauth/authorize',
|
||||
});
|
||||
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0]!.errorCode, -3);
|
||||
});
|
||||
|
||||
test('anilist setup did-finish-load handler triggers fallback on blank page', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createAnilistSetupDidFinishLoadHandler({
|
||||
@@ -204,28 +156,6 @@ test('anilist setup did-finish-load handler no-ops on non-blank page', () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('anilist setup window closed handler clears references', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleAnilistSetupWindowClosedHandler({
|
||||
clearSetupWindow: () => calls.push('clear-window'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['clear-window', 'opened:no']);
|
||||
});
|
||||
|
||||
test('anilist setup window opened handler sets references', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleAnilistSetupWindowOpenedHandler({
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['set-window', 'opened:yes']);
|
||||
});
|
||||
|
||||
test('open anilist setup handler no-ops when existing setup window focused', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createOpenAnilistSetupWindowHandler({
|
||||
|
||||
@@ -116,39 +116,6 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
|
||||
const originalDateNow = Date.now;
|
||||
const events: string[] = [];
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => {
|
||||
events.push(`save:${value}`);
|
||||
return true;
|
||||
},
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl keeps setup open when token persistence fails', () => {
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './anilist-token-refresh-main-deps';
|
||||
|
||||
test('refresh anilist client secret state main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const config = { anilist: { accessToken: 'token' } };
|
||||
const deps = createBuildRefreshAnilistClientSecretStateMainDepsHandler({
|
||||
getResolvedConfig: () => config as never,
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCachedAccessToken: () => 'cached',
|
||||
setCachedAccessToken: () => calls.push('set-cache'),
|
||||
saveStoredToken: () => calls.push('save'),
|
||||
loadStoredToken: () => 'stored',
|
||||
setClientSecretState: () => calls.push('set-state'),
|
||||
getAnilistSetupPageOpened: () => false,
|
||||
setAnilistSetupPageOpened: () => calls.push('set-opened'),
|
||||
openAnilistSetupWindow: () => calls.push('open-window'),
|
||||
now: () => 123,
|
||||
})();
|
||||
|
||||
assert.equal(deps.getResolvedConfig(), config);
|
||||
assert.equal(deps.isAnilistTrackingEnabled(config as never), true);
|
||||
assert.equal(deps.getCachedAccessToken(), 'cached');
|
||||
deps.setCachedAccessToken(null);
|
||||
deps.saveStoredToken('x');
|
||||
assert.equal(deps.loadStoredToken(), 'stored');
|
||||
deps.setClientSecretState({} as never);
|
||||
assert.equal(deps.getAnilistSetupPageOpened(), false);
|
||||
deps.setAnilistSetupPageOpened(true);
|
||||
deps.openAnilistSetupWindow();
|
||||
assert.equal(deps.now(), 123);
|
||||
assert.deepEqual(calls, ['set-cache', 'save', 'set-state', 'set-opened', 'open-window']);
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildMarkLastCardAsAudioCardMainDepsHandler,
|
||||
createBuildMineSentenceCardMainDepsHandler,
|
||||
createBuildRefreshKnownWordCacheMainDepsHandler,
|
||||
createBuildTriggerFieldGroupingMainDepsHandler,
|
||||
createBuildUpdateLastCardFromClipboardMainDepsHandler,
|
||||
} from './anki-actions-main-deps';
|
||||
|
||||
test('anki action main deps builders map callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const update = createBuildUpdateLastCardFromClipboardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
readClipboardText: () => 'clip',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
updateLastCardFromClipboardCore: async () => {
|
||||
calls.push('update');
|
||||
},
|
||||
})();
|
||||
assert.deepEqual(update.getAnkiIntegration(), { enabled: true });
|
||||
assert.equal(update.readClipboardText(), 'clip');
|
||||
update.showMpvOsd('x');
|
||||
await update.updateLastCardFromClipboardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
readClipboardText: () => '',
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const refresh = createBuildRefreshKnownWordCacheMainDepsHandler({
|
||||
getAnkiIntegration: () => null,
|
||||
missingIntegrationMessage: 'missing',
|
||||
})();
|
||||
assert.equal(refresh.getAnkiIntegration(), null);
|
||||
assert.equal(refresh.missingIntegrationMessage, 'missing');
|
||||
|
||||
const fieldGrouping = createBuildTriggerFieldGroupingMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
showMpvOsd: (text) => calls.push(`fg:${text}`),
|
||||
triggerFieldGroupingCore: async () => {
|
||||
calls.push('trigger');
|
||||
},
|
||||
})();
|
||||
fieldGrouping.showMpvOsd('fg');
|
||||
await fieldGrouping.triggerFieldGroupingCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const markAudio = createBuildMarkLastCardAsAudioCardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
showMpvOsd: (text) => calls.push(`audio:${text}`),
|
||||
markLastCardAsAudioCardCore: async () => {
|
||||
calls.push('mark');
|
||||
},
|
||||
})();
|
||||
markAudio.showMpvOsd('a');
|
||||
await markAudio.markLastCardAsAudioCardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const mine = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||
text: '正式な字幕',
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
});
|
||||
mine.showMpvOsd('m');
|
||||
await mine.mineSentenceCardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
mpvClient: { connected: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
|
||||
});
|
||||
@@ -1,31 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
} from './anki-actions';
|
||||
|
||||
test('update last card handler forwards integration/clipboard/osd deps', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const updateLastCard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
readClipboardText: () => 'clipboard-value',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
updateLastCardFromClipboardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.readClipboardText(), 'clipboard-value');
|
||||
options.showMpvOsd('ok');
|
||||
calls.push('core');
|
||||
},
|
||||
});
|
||||
|
||||
await updateLastCard();
|
||||
assert.deepEqual(calls, ['osd:ok', 'core']);
|
||||
});
|
||||
import { createMineSentenceCardHandler, createRefreshKnownWordCacheHandler } from './anki-actions';
|
||||
|
||||
test('refresh known word cache handler throws when Anki integration missing', async () => {
|
||||
const refresh = createRefreshKnownWordCacheHandler({
|
||||
@@ -36,57 +11,6 @@ test('refresh known word cache handler throws when Anki integration missing', as
|
||||
await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/);
|
||||
});
|
||||
|
||||
test('trigger and mark handlers delegate to core services', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
triggerFieldGroupingCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
options.showMpvOsd('group');
|
||||
calls.push('group-core');
|
||||
},
|
||||
});
|
||||
const markAudio = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
markLastCardAsAudioCardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
options.showMpvOsd('mark');
|
||||
calls.push('mark-core');
|
||||
},
|
||||
});
|
||||
|
||||
await triggerFieldGrouping();
|
||||
await markAudio();
|
||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||
});
|
||||
|
||||
test('mine sentence handler leaves card counting to card creation', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const mpvClient = {};
|
||||
let created = false;
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
getMpvClient: () => mpvClient,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
mineSentenceCardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.mpvClient, mpvClient);
|
||||
options.showMpvOsd('mine');
|
||||
return created;
|
||||
},
|
||||
});
|
||||
|
||||
created = false;
|
||||
await mineSentenceCard();
|
||||
created = true;
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
|
||||
});
|
||||
|
||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
|
||||
@@ -3,10 +3,62 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
createForceQuitHandler,
|
||||
createOnWillQuitCleanupHandler,
|
||||
createRestoreWindowsOnActivateHandler,
|
||||
createShouldRestoreWindowsOnActivateHandler,
|
||||
} from './app-lifecycle-actions';
|
||||
|
||||
type CleanupDeps = Parameters<typeof createOnWillQuitCleanupHandler>[0];
|
||||
|
||||
// Every cleanup step records its own dep name into `calls` unless overridden.
|
||||
function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}): CleanupDeps {
|
||||
const record = (name: string) => () => {
|
||||
calls.push(name);
|
||||
};
|
||||
return {
|
||||
destroyTray: record('destroyTray'),
|
||||
stopConfigHotReload: record('stopConfigHotReload'),
|
||||
restorePreviousSecondarySubVisibility: record('restorePreviousSecondarySubVisibility'),
|
||||
restoreMpvSubVisibility: record('restoreMpvSubVisibility'),
|
||||
unregisterAllGlobalShortcuts: record('unregisterAllGlobalShortcuts'),
|
||||
stopSubtitleWebsocket: record('stopSubtitleWebsocket'),
|
||||
stopTexthookerService: record('stopTexthookerService'),
|
||||
stopSyncAutoScheduler: record('stopSyncAutoScheduler'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: record(
|
||||
'clearWindowsVisibleOverlayForegroundPollLoop',
|
||||
),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: record(
|
||||
'clearLinuxMpvFullscreenOverlayRefreshTimeouts',
|
||||
),
|
||||
destroyMainOverlayWindow: record('destroyMainOverlayWindow'),
|
||||
destroyModalOverlayWindow: record('destroyModalOverlayWindow'),
|
||||
destroyYomitanParserWindow: record('destroyYomitanParserWindow'),
|
||||
clearYomitanParserState: record('clearYomitanParserState'),
|
||||
stopWindowTracker: record('stopWindowTracker'),
|
||||
flushMpvLog: record('flushMpvLog'),
|
||||
destroyMpvSocket: record('destroyMpvSocket'),
|
||||
clearReconnectTimer: record('clearReconnectTimer'),
|
||||
destroySubtitleTimingTracker: record('destroySubtitleTimingTracker'),
|
||||
stopStatsServer: record('stopStatsServer'),
|
||||
destroyImmersionTracker: record('destroyImmersionTracker'),
|
||||
destroyAnkiIntegration: record('destroyAnkiIntegration'),
|
||||
destroyAnilistSetupWindow: record('destroyAnilistSetupWindow'),
|
||||
clearAnilistSetupWindow: record('clearAnilistSetupWindow'),
|
||||
destroyJellyfinSetupWindow: record('destroyJellyfinSetupWindow'),
|
||||
clearJellyfinSetupWindow: record('clearJellyfinSetupWindow'),
|
||||
destroyFirstRunSetupWindow: record('destroyFirstRunSetupWindow'),
|
||||
clearFirstRunSetupWindow: record('clearFirstRunSetupWindow'),
|
||||
destroyYomitanSettingsWindow: record('destroyYomitanSettingsWindow'),
|
||||
clearYomitanSettingsWindow: record('clearYomitanSettingsWindow'),
|
||||
stopJellyfinRemoteSession: record('stopJellyfinRemoteSession'),
|
||||
cleanupInternalSubtitleTrackCache: record('cleanupInternalSubtitleTrackCache'),
|
||||
cleanupYoutubeSubtitleTempDirs: record('cleanupYoutubeSubtitleTempDirs'),
|
||||
cleanupYoutubeMediaCache: record('cleanupYoutubeMediaCache'),
|
||||
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
|
||||
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
|
||||
stopDiscordPresenceService: record('stopDiscordPresenceService'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('forced quit finalizes stats before exiting, even when finalization throws', async () => {
|
||||
for (const fails of [false, true]) {
|
||||
const calls: string[] = [];
|
||||
@@ -26,86 +78,44 @@ test('forced quit finalizes stats before exiting, even when finalization throws'
|
||||
}
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler runs all cleanup steps', async () => {
|
||||
test('on will quit cleanup handler flushes before teardown and awaits stats before dependents', async () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
stopSyncAutoScheduler: () => {
|
||||
calls.push('stop-sync-auto-scheduler');
|
||||
},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-poll'),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
|
||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
stopWindowTracker: () => calls.push('stop-tracker'),
|
||||
flushMpvLog: () => calls.push('flush-mpv-log'),
|
||||
destroyMpvSocket: () => calls.push('destroy-socket'),
|
||||
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
||||
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
||||
stopStatsServer: async () => {
|
||||
calls.push('stop-stats-server-start');
|
||||
await Promise.resolve();
|
||||
calls.push('stop-stats-server-complete');
|
||||
},
|
||||
destroyImmersionTracker: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('destroy-immersion');
|
||||
},
|
||||
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
||||
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'),
|
||||
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
const cleanup = createOnWillQuitCleanupHandler(
|
||||
makeCleanupDeps(calls, {
|
||||
stopStatsServer: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('stopStatsServer:complete');
|
||||
},
|
||||
destroyImmersionTracker: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('destroyImmersionTracker');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await cleanup();
|
||||
assert.equal(calls.length, 38);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-internal-subtitles'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||
assert.ok(calls.includes('cleanup-remote-media-windows'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion'));
|
||||
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki'));
|
||||
assert.ok(calls.indexOf('flushMpvLog') < calls.indexOf('destroyMpvSocket'));
|
||||
assert.ok(calls.indexOf('stopStatsServer:complete') < calls.indexOf('destroyImmersionTracker'));
|
||||
assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration'));
|
||||
});
|
||||
|
||||
test('forced quit waits for asynchronous stats finalization', async () => {
|
||||
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
|
||||
const calls: string[] = [];
|
||||
await createForceQuitHandler({
|
||||
destroyImmersionTracker: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('finalized');
|
||||
},
|
||||
logError: () => calls.push('error'),
|
||||
exit: () => calls.push('exit'),
|
||||
})();
|
||||
assert.deepEqual(calls, ['finalized', 'exit']);
|
||||
const cleanup = createOnWillQuitCleanupHandler(
|
||||
makeCleanupDeps(calls, {
|
||||
stopJellyfinRemoteSession: () => {
|
||||
calls.push('stopJellyfinRemoteSession');
|
||||
throw new Error('stop failed');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls.slice(calls.indexOf('stopJellyfinRemoteSession')), [
|
||||
'stopJellyfinRemoteSession',
|
||||
'cleanupJellyfinSubtitleCache',
|
||||
'cleanupInternalSubtitleTrackCache',
|
||||
]);
|
||||
});
|
||||
|
||||
test('forced quit exits when asynchronous stats finalization never settles', async () => {
|
||||
@@ -121,59 +131,6 @@ test('forced quit exits when asynchronous stats finalization never settles', asy
|
||||
assert.deepEqual(calls, ['timeout', 'exit']);
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
stopSyncAutoScheduler: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
destroyMainOverlayWindow: () => {},
|
||||
destroyModalOverlayWindow: () => {},
|
||||
destroyYomitanParserWindow: () => {},
|
||||
clearYomitanParserState: () => {},
|
||||
stopWindowTracker: () => {},
|
||||
flushMpvLog: () => {},
|
||||
destroyMpvSocket: () => {},
|
||||
clearReconnectTimer: () => {},
|
||||
destroySubtitleTimingTracker: () => {},
|
||||
stopStatsServer: () => {},
|
||||
destroyImmersionTracker: () => {},
|
||||
destroyAnkiIntegration: () => {},
|
||||
destroyAnilistSetupWindow: () => {},
|
||||
clearAnilistSetupWindow: () => {},
|
||||
destroyJellyfinSetupWindow: () => {},
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
destroyFirstRunSetupWindow: () => {},
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
destroyYomitanSettingsWindow: () => {},
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {
|
||||
calls.push('stop-jellyfin-remote');
|
||||
throw new Error('stop failed');
|
||||
},
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
await assert.rejects(cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls, [
|
||||
'stop-jellyfin-remote',
|
||||
'cleanup-jellyfin-subtitles',
|
||||
'cleanup-internal-subtitles',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
@@ -188,15 +145,3 @@ test('should restore windows on activate requires initialized runtime and no win
|
||||
windowCount = 0;
|
||||
assert.equal(shouldRestore(), true);
|
||||
});
|
||||
|
||||
test('restore windows on activate recreates windows then syncs visibility', () => {
|
||||
const calls: string[] = [];
|
||||
const restore = createRestoreWindowsOnActivateHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
|
||||
});
|
||||
|
||||
restore();
|
||||
assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildRestoreWindowsOnActivateMainDepsHandler,
|
||||
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
|
||||
} from './app-lifecycle-main-activate';
|
||||
|
||||
test('should restore windows on activate deps builder maps visibility state checks', () => {
|
||||
const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
getAllWindowCount: () => 0,
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.getAllWindowCount(), 0);
|
||||
});
|
||||
|
||||
test('restore windows on activate deps builder maps all restoration callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
|
||||
})();
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']);
|
||||
});
|
||||
@@ -3,231 +3,134 @@ import test from 'node:test';
|
||||
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
|
||||
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
||||
|
||||
test('cleanup deps builder returns handlers that guard optional runtime objects', async () => {
|
||||
const calls: string[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
||||
let immersionTracker: { destroy: () => Promise<void> } | null = {
|
||||
destroy: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('destroy-immersion');
|
||||
},
|
||||
type BuilderDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
|
||||
|
||||
// Defaults model an app that is ready but has no live runtime objects.
|
||||
function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
|
||||
const noop = () => {};
|
||||
const none = () => null;
|
||||
return createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: noop,
|
||||
stopConfigHotReload: noop,
|
||||
restorePreviousSecondarySubVisibility: noop,
|
||||
restoreMpvSubVisibility: noop,
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: noop,
|
||||
stopSubtitleWebsocket: noop,
|
||||
stopTexthookerService: noop,
|
||||
stopSyncAutoScheduler: noop,
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: noop,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: noop,
|
||||
getMainOverlayWindow: none,
|
||||
clearMainOverlayWindow: noop,
|
||||
getModalOverlayWindow: none,
|
||||
clearModalOverlayWindow: noop,
|
||||
getYomitanParserWindow: none,
|
||||
clearYomitanParserState: noop,
|
||||
getWindowTracker: none,
|
||||
flushMpvLog: noop,
|
||||
getMpvSocket: none,
|
||||
getReconnectTimer: none,
|
||||
clearReconnectTimerRef: noop,
|
||||
getSubtitleTimingTracker: none,
|
||||
getImmersionTracker: none,
|
||||
stopStatsServer: noop,
|
||||
clearImmersionTracker: noop,
|
||||
getAnkiIntegration: none,
|
||||
getAnilistSetupWindow: none,
|
||||
clearAnilistSetupWindow: noop,
|
||||
getJellyfinSetupWindow: none,
|
||||
clearJellyfinSetupWindow: noop,
|
||||
getFirstRunSetupWindow: none,
|
||||
clearFirstRunSetupWindow: noop,
|
||||
getYomitanSettingsWindow: none,
|
||||
clearYomitanSettingsWindow: noop,
|
||||
stopJellyfinRemoteSession: noop,
|
||||
cleanupInternalSubtitleTrackCache: noop,
|
||||
cleanupYoutubeSubtitleTempDirs: noop,
|
||||
cleanupYoutubeMediaCache: noop,
|
||||
cleanupRemoteMediaWindows: noop,
|
||||
cleanupJellyfinSubtitleCache: noop,
|
||||
stopDiscordPresenceService: noop,
|
||||
...overrides,
|
||||
})();
|
||||
}
|
||||
|
||||
function fakeWindow(calls: string[], destroyed: boolean) {
|
||||
return {
|
||||
isDestroyed: () => destroyed,
|
||||
destroy: () => calls.push('destroy'),
|
||||
};
|
||||
}
|
||||
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
stopSyncAutoScheduler: () => {
|
||||
calls.push('stop-sync-auto-scheduler');
|
||||
},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
}),
|
||||
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
|
||||
getModalOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-modal-overlay-window'),
|
||||
}),
|
||||
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
|
||||
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
}),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
|
||||
getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }),
|
||||
flushMpvLog: () => calls.push('flush-mpv-log'),
|
||||
getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }),
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
clearReconnectTimerRef: () => {
|
||||
reconnectTimer = null;
|
||||
calls.push('clear-reconnect-ref');
|
||||
},
|
||||
|
||||
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
|
||||
getImmersionTracker: () => immersionTracker,
|
||||
stopStatsServer: () => {
|
||||
calls.push('stop-stats-server');
|
||||
},
|
||||
clearImmersionTracker: () => {
|
||||
immersionTracker = null;
|
||||
calls.push('clear-immersion-ref');
|
||||
},
|
||||
getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }),
|
||||
|
||||
getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }),
|
||||
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
|
||||
getYomitanSettingsWindow: () => ({
|
||||
destroy: () => calls.push('destroy-yomitan-settings-window'),
|
||||
}),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
await cleanup();
|
||||
|
||||
assert.ok(calls.includes('destroy-tray'));
|
||||
assert.ok(calls.includes('destroy-main-overlay-window'));
|
||||
assert.ok(calls.includes('clear-main-overlay-window'));
|
||||
assert.ok(calls.includes('destroy-modal-overlay-window'));
|
||||
assert.ok(calls.includes('clear-modal-overlay-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-window'));
|
||||
assert.ok(calls.includes('flush-mpv-log'));
|
||||
assert.ok(calls.includes('destroy-socket'));
|
||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||
assert.ok(calls.includes('destroy-immersion'));
|
||||
assert.ok(calls.includes('clear-immersion-ref'));
|
||||
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref'));
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('cleanup-internal-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
test('cleanup deps builder runs the full quit cleanup with no runtime objects', async () => {
|
||||
await createOnWillQuitCleanupHandler(buildCleanupDeps())();
|
||||
});
|
||||
|
||||
test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
const calls: string[] = [];
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
stopSyncAutoScheduler: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
}),
|
||||
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
|
||||
getModalOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-modal-overlay-window'),
|
||||
}),
|
||||
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
}),
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
flushMpvLog: () => {},
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
stopStatsServer: () => {},
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
for (const destroyed of [false, true]) {
|
||||
test(`cleanup deps builder ${destroyed ? 'skips' : 'destroys and clears'} ${destroyed ? 'already destroyed' : 'live'} overlay windows`, () => {
|
||||
const calls: string[] = [];
|
||||
const deps = buildCleanupDeps({
|
||||
getMainOverlayWindow: () => fakeWindow(calls, destroyed),
|
||||
clearMainOverlayWindow: () => calls.push('clear'),
|
||||
getModalOverlayWindow: () => fakeWindow(calls, destroyed),
|
||||
clearModalOverlayWindow: () => calls.push('clear'),
|
||||
getYomitanParserWindow: () => fakeWindow(calls, destroyed),
|
||||
});
|
||||
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
|
||||
assert.deepEqual(calls, destroyed ? [] : ['destroy', 'clear', 'destroy', 'clear', 'destroy']);
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
}
|
||||
|
||||
test('cleanup deps builder skips global shortcut cleanup before app ready', () => {
|
||||
const calls: string[] = [];
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
const deps = buildCleanupDeps({
|
||||
isAppReady: () => false,
|
||||
unregisterAllGlobalShortcuts: () => {
|
||||
throw new Error('globalShortcut cannot be used before the app is ready');
|
||||
},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
stopSyncAutoScheduler: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
clearModalOverlayWindow: () => {},
|
||||
getYomitanParserWindow: () => null,
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
flushMpvLog: () => {},
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
stopStatsServer: () => {},
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['destroy-tray']);
|
||||
test('cleanup deps builder clears the reconnect timer and its reference', () => {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
assert.fail('reconnect timer should have been cleared');
|
||||
}, 0);
|
||||
const deps = buildCleanupDeps({
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
clearReconnectTimerRef: () => {
|
||||
reconnectTimer = null;
|
||||
},
|
||||
});
|
||||
|
||||
deps.clearReconnectTimer();
|
||||
|
||||
assert.equal(reconnectTimer, null);
|
||||
});
|
||||
|
||||
test('cleanup deps builder clears the immersion tracker only after destroy settles', async () => {
|
||||
const calls: string[] = [];
|
||||
let immersionTracker: { destroy: () => Promise<void> } | null = {
|
||||
destroy: async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('destroy');
|
||||
},
|
||||
};
|
||||
const deps = buildCleanupDeps({
|
||||
getImmersionTracker: () => immersionTracker,
|
||||
clearImmersionTracker: () => {
|
||||
immersionTracker = null;
|
||||
calls.push('clear');
|
||||
},
|
||||
});
|
||||
|
||||
await deps.destroyImmersionTracker();
|
||||
|
||||
assert.deepEqual(calls, ['destroy', 'clear']);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps';
|
||||
|
||||
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
|
||||
const calls: string[] = [];
|
||||
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
|
||||
ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'),
|
||||
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
|
||||
resolveKeybindings: () => calls.push('resolve-keybindings'),
|
||||
createMpvClient: () => calls.push('create-mpv-client'),
|
||||
reloadConfig: () => calls.push('reload-config'),
|
||||
getResolvedConfig: () => ({ websocket: {} }),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => calls.push('log-config-warning'),
|
||||
initRuntimeOptionsManager: () => calls.push('init-runtime-options'),
|
||||
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => calls.push('start-ws'),
|
||||
startAnnotationWebsocket: () => calls.push('start-annotation-ws'),
|
||||
startTexthooker: () => calls.push('start-texthooker'),
|
||||
log: () => calls.push('log'),
|
||||
setLogLevel: () => calls.push('set-log-level'),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
calls.push('create-mecab');
|
||||
},
|
||||
createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'),
|
||||
createImmersionTracker: () => calls.push('create-immersion'),
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('start-jellyfin');
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensure-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handle-first-run-setup');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarm-dicts');
|
||||
},
|
||||
startBackgroundWarmups: () => calls.push('start-warmups'),
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
handleInitialArgs: () => calls.push('handle-initial-args'),
|
||||
onCriticalConfigErrors: () => {
|
||||
throw new Error('should not call');
|
||||
},
|
||||
logDebug: () => calls.push('debug'),
|
||||
now: () => 123,
|
||||
})();
|
||||
|
||||
assert.equal(onReady.defaultSecondarySubMode, 'hover');
|
||||
assert.equal(onReady.defaultWebsocketPort, 5174);
|
||||
assert.equal(onReady.defaultAnnotationWebsocketPort, 6678);
|
||||
assert.equal(onReady.defaultTexthookerPort, 5174);
|
||||
assert.equal(onReady.texthookerOnlyMode, false);
|
||||
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
|
||||
assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true);
|
||||
assert.equal(onReady.now?.(), 123);
|
||||
onReady.loadSubtitlePosition();
|
||||
onReady.resolveKeybindings();
|
||||
onReady.createMpvClient();
|
||||
await onReady.createMecabTokenizerAndCheck();
|
||||
await onReady.loadYomitanExtension();
|
||||
await onReady.ensureYomitanExtensionLoaded?.();
|
||||
await onReady.handleFirstRunSetup();
|
||||
await onReady.prewarmSubtitleDictionaries?.();
|
||||
onReady.startBackgroundWarmups();
|
||||
onReady.startTexthooker(5174);
|
||||
onReady.setVisibleOverlayVisible(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'load-subtitle-position',
|
||||
'resolve-keybindings',
|
||||
'create-mpv-client',
|
||||
'create-mecab',
|
||||
'load-yomitan',
|
||||
'ensure-yomitan',
|
||||
'handle-first-run-setup',
|
||||
'prewarm-dicts',
|
||||
'start-warmups',
|
||||
'start-texthooker',
|
||||
'set-visible-overlay',
|
||||
]);
|
||||
});
|
||||
@@ -1,11 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildDestroyTrayMainDepsHandler,
|
||||
createBuildEnsureTrayMainDepsHandler,
|
||||
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler,
|
||||
createBuildOpenYomitanSettingsMainDepsHandler,
|
||||
} from './app-runtime-main-deps';
|
||||
import { createBuildEnsureTrayMainDepsHandler } from './app-runtime-main-deps';
|
||||
|
||||
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
|
||||
const calls: string[] = [];
|
||||
@@ -28,79 +23,3 @@ test('ensure tray main deps trigger overlay bootstrap on tray click when runtime
|
||||
deps.ensureOverlayVisibleFromTrayClick();
|
||||
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
|
||||
});
|
||||
|
||||
test('destroy tray main deps map passthrough getters/setters', () => {
|
||||
let tray: unknown = { id: 'tray' };
|
||||
const deps = createBuildDestroyTrayMainDepsHandler({
|
||||
getTray: () => tray,
|
||||
setTray: (next) => {
|
||||
tray = next;
|
||||
},
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getTray(), { id: 'tray' });
|
||||
deps.setTray(null);
|
||||
assert.equal(tray, null);
|
||||
});
|
||||
|
||||
test('initialize overlay runtime main deps map build options and callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const options = { id: 'opts' };
|
||||
const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntimeCore: (value) => {
|
||||
calls.push(`core:${JSON.stringify(value)}`);
|
||||
},
|
||||
buildOptions: () => options,
|
||||
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
||||
assert.equal(deps.buildOptions(), options);
|
||||
assert.equal(deps.initializeOverlayRuntimeCore(options), undefined);
|
||||
deps.setOverlayRuntimeInitialized(true);
|
||||
deps.startBackgroundWarmups();
|
||||
assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']);
|
||||
});
|
||||
|
||||
test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentWindow: unknown = null;
|
||||
const extension = { id: 'ext' };
|
||||
const startupLoad = Promise.resolve(extension);
|
||||
const yomitanSession = { id: 'session' };
|
||||
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => extension,
|
||||
getYomitanExtension: () => extension,
|
||||
getYomitanExtensionLoadInFlight: () => startupLoad,
|
||||
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
|
||||
calls.push(
|
||||
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
|
||||
),
|
||||
getExistingWindow: () => currentWindow,
|
||||
setWindow: (window) => {
|
||||
currentWindow = window;
|
||||
calls.push('set-window');
|
||||
},
|
||||
getYomitanSession: () => yomitanSession,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
|
||||
assert.equal(deps.getYomitanExtension?.(), extension);
|
||||
assert.equal(deps.getYomitanExtensionLoadInFlight?.(), startupLoad);
|
||||
assert.equal(deps.getExistingWindow(), null);
|
||||
deps.setWindow({ id: 'win' });
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window) => deps.setWindow(window),
|
||||
yomitanSession: deps.getYomitanSession(),
|
||||
});
|
||||
deps.logWarn('warn');
|
||||
deps.logError('error', new Error('boom'));
|
||||
assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']);
|
||||
assert.deepEqual(currentWindow, { id: 'win' });
|
||||
});
|
||||
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user