mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-20 03:13:31 -07:00
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
NoteUpdateWorkflow,
|
|
type NoteUpdateWorkflowDeps,
|
|
type NoteUpdateWorkflowNoteInfo,
|
|
} from './note-update-workflow';
|
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
|
|
|
function setWordAndSentenceCardTypeFields(
|
|
updatedFields: Record<string, string>,
|
|
availableFieldNames: string[],
|
|
cardKind: 'word-and-sentence',
|
|
): void {
|
|
assert.equal(cardKind, 'word-and-sentence');
|
|
const resolveFieldName = (preferredName: string): string | null =>
|
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
|
|
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
|
if (!wordAndSentenceFlag) return;
|
|
|
|
updatedFields[wordAndSentenceFlag] = 'x';
|
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
const resolved = resolveFieldName(flagName);
|
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
updatedFields[resolved] = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function createWorkflowHarness() {
|
|
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
|
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
|
const warnings: string[] = [];
|
|
|
|
const deps: NoteUpdateWorkflowDeps = {
|
|
client: {
|
|
notesInfo: async (_noteIds: number[]) =>
|
|
[
|
|
{
|
|
noteId: 42,
|
|
fields: {
|
|
Expression: { value: 'taberu' },
|
|
Sentence: { value: '' },
|
|
},
|
|
},
|
|
] satisfies NoteUpdateWorkflowNoteInfo[],
|
|
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
|
|
updates.push({ noteId, fields });
|
|
},
|
|
storeMediaFile: async () => undefined,
|
|
},
|
|
getConfig: () => ({
|
|
fields: {
|
|
sentence: 'Sentence',
|
|
},
|
|
media: {},
|
|
behavior: {},
|
|
}),
|
|
getCurrentSubtitleText: () => 'subtitle-text',
|
|
getCurrentSubtitleStart: () => 12.3,
|
|
getEffectiveSentenceCardConfig: () => ({
|
|
sentenceField: 'Sentence',
|
|
lapisEnabled: false,
|
|
kikuEnabled: false,
|
|
kikuFieldGrouping: 'disabled' as const,
|
|
}),
|
|
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
|
extractFields: (fields: Record<string, { value: string }>) => {
|
|
const out: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
out[key.toLowerCase()] = value.value;
|
|
}
|
|
return out;
|
|
},
|
|
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
|
|
handleFieldGroupingAuto: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
|
undefined,
|
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
|
false,
|
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
|
if (!preferred) return null;
|
|
const names = Object.keys(noteInfo.fields);
|
|
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
|
|
},
|
|
getResolvedSentenceAudioFieldName: () => null,
|
|
getAnimatedImageLeadInSeconds: async () => 0,
|
|
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
|
|
generateAudioFilename: () => 'audio_1.mp3',
|
|
generateAudio: async () => null,
|
|
generateImageFilename: () => 'image_1.jpg',
|
|
generateImage: async () => null,
|
|
formatMiscInfoPattern: () => '',
|
|
addConfiguredTagsToNote: async () => undefined,
|
|
showNotification: async (noteId: number, label: string | number) => {
|
|
notifications.push({ noteId, label });
|
|
},
|
|
showOsdNotification: (_text: string) => undefined,
|
|
beginUpdateProgress: (_text: string) => undefined,
|
|
endUpdateProgress: () => undefined,
|
|
logWarn: (message: string, ..._args: unknown[]) => warnings.push(message),
|
|
logInfo: (_message: string) => undefined,
|
|
logError: (_message: string) => undefined,
|
|
};
|
|
|
|
return {
|
|
workflow: new NoteUpdateWorkflow(deps),
|
|
updates,
|
|
notifications,
|
|
warnings,
|
|
deps,
|
|
};
|
|
}
|
|
|
|
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
|
|
const harness = createWorkflowHarness();
|
|
|
|
await harness.workflow.execute(42);
|
|
|
|
assert.equal(harness.updates.length, 1);
|
|
assert.equal(harness.updates[0]?.noteId, 42);
|
|
assert.equal(harness.updates[0]?.fields.Sentence, 'subtitle-text');
|
|
assert.equal(harness.notifications.length, 1);
|
|
});
|
|
|
|
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
|
const harness = createWorkflowHarness();
|
|
harness.deps.client.notesInfo = async () =>
|
|
[
|
|
{
|
|
noteId: 42,
|
|
fields: {
|
|
Expression: { value: 'tokugi' },
|
|
Sentence: { value: '' },
|
|
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
|
|
},
|
|
},
|
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
|
|
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
|
|
|
|
await harness.workflow.execute(42);
|
|
|
|
assert.equal(harness.updates.length, 1);
|
|
assert.deepEqual(harness.updates[0]?.fields, {
|
|
Sentence: 'subtitle-text',
|
|
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
|
|
});
|
|
});
|
|
|
|
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
|
|
const harness = createWorkflowHarness();
|
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
sentenceField: 'Sentence',
|
|
lapisEnabled: false,
|
|
kikuEnabled: true,
|
|
kikuFieldGrouping: 'manual',
|
|
});
|
|
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',
|
|
IsWordAndSentenceCard: 'x',
|
|
IsSentenceCard: '',
|
|
IsAudioCard: '',
|
|
});
|
|
});
|
|
|
|
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,
|
|
kikuFieldGrouping: '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 () => [];
|
|
|
|
await harness.workflow.execute(777);
|
|
|
|
assert.equal(harness.updates.length, 0);
|
|
assert.equal(harness.notifications.length, 0);
|
|
assert.equal(harness.warnings.length, 1);
|
|
});
|
|
|
|
test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => {
|
|
const harness = createWorkflowHarness();
|
|
const callOrder: string[] = [];
|
|
let notesInfoCallCount = 0;
|
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
sentenceField: 'Sentence',
|
|
lapisEnabled: false,
|
|
kikuEnabled: true,
|
|
kikuFieldGrouping: 'auto',
|
|
});
|
|
harness.deps.findDuplicateNote = async () => 99;
|
|
harness.deps.client.notesInfo = async () => {
|
|
notesInfoCallCount += 1;
|
|
if (notesInfoCallCount === 1) {
|
|
return [
|
|
{
|
|
noteId: 42,
|
|
fields: {
|
|
Expression: { value: 'taberu' },
|
|
Sentence: { value: '' },
|
|
},
|
|
},
|
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
}
|
|
return [
|
|
{
|
|
noteId: 42,
|
|
fields: {
|
|
Expression: { value: 'taberu' },
|
|
Sentence: { value: 'subtitle-text' },
|
|
},
|
|
},
|
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
};
|
|
harness.deps.client.updateNoteFields = async (noteId, fields) => {
|
|
callOrder.push('update');
|
|
harness.updates.push({ noteId, fields });
|
|
};
|
|
harness.deps.handleFieldGroupingAuto = async (
|
|
_originalNoteId,
|
|
_newNoteId,
|
|
newNoteInfo,
|
|
_expression,
|
|
) => {
|
|
callOrder.push('auto');
|
|
assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text');
|
|
};
|
|
|
|
await harness.workflow.execute(42);
|
|
|
|
assert.deepEqual(callOrder, ['update', 'auto']);
|
|
assert.equal(harness.updates.length, 1);
|
|
});
|
|
|
|
test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word audio', async () => {
|
|
const harness = createWorkflowHarness();
|
|
let receivedLeadInSeconds = 0;
|
|
|
|
harness.deps.client.notesInfo = async () =>
|
|
[
|
|
{
|
|
noteId: 42,
|
|
fields: {
|
|
Expression: { value: 'taberu' },
|
|
ExpressionAudio: { value: '[sound:word.mp3]' },
|
|
Sentence: { value: '' },
|
|
Picture: { value: '' },
|
|
},
|
|
},
|
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
harness.deps.getConfig = () => ({
|
|
fields: {
|
|
sentence: 'Sentence',
|
|
image: 'Picture',
|
|
},
|
|
media: {
|
|
generateImage: true,
|
|
imageType: 'avif',
|
|
syncAnimatedImageToWordAudio: true,
|
|
},
|
|
behavior: {},
|
|
});
|
|
harness.deps.getAnimatedImageLeadInSeconds = async () => 1.25;
|
|
harness.deps.generateImage = async (leadInSeconds?: number) => {
|
|
receivedLeadInSeconds = leadInSeconds ?? 0;
|
|
return Buffer.from('image');
|
|
};
|
|
|
|
await harness.workflow.execute(42);
|
|
|
|
assert.equal(receivedLeadInSeconds, 1.25);
|
|
});
|
|
|
|
test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => {
|
|
const harness = createWorkflowHarness();
|
|
const sidebarContext = {
|
|
source: 'subtitle-sidebar' as const,
|
|
text: 'sidebar previous line',
|
|
startTime: 10,
|
|
endTime: 12,
|
|
capturedAtMs: 123,
|
|
};
|
|
let audioContext: unknown = null;
|
|
let imageContext: unknown = null;
|
|
let miscInfoStartTime: number | undefined;
|
|
|
|
harness.deps.client.notesInfo = async () =>
|
|
[
|
|
{
|
|
noteId: 42,
|
|
fields: {
|
|
Expression: { value: 'taberu' },
|
|
Sentence: { value: 'sidebar previous line' },
|
|
SentenceAudio: { value: '' },
|
|
Picture: { value: '' },
|
|
MiscInfo: { value: '' },
|
|
},
|
|
},
|
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
harness.deps.getConfig = () => ({
|
|
fields: {
|
|
sentence: 'Sentence',
|
|
image: 'Picture',
|
|
miscInfo: 'MiscInfo',
|
|
},
|
|
media: {
|
|
generateAudio: true,
|
|
generateImage: true,
|
|
imageType: 'avif',
|
|
},
|
|
behavior: {},
|
|
});
|
|
harness.deps.getCurrentSubtitleText = () => 'current primary line';
|
|
harness.deps.getCurrentSubtitleStart = () => 20;
|
|
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
|
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
|
audioContext = context ?? null;
|
|
return Buffer.from('audio');
|
|
};
|
|
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
|
|
imageContext = context ?? null;
|
|
return Buffer.from('image');
|
|
};
|
|
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
|
|
miscInfoStartTime = startTimeSeconds;
|
|
return `start:${startTimeSeconds}`;
|
|
};
|
|
(
|
|
harness.deps as NoteUpdateWorkflowDeps & {
|
|
consumeSubtitleMiningContext: () => typeof sidebarContext | null;
|
|
}
|
|
).consumeSubtitleMiningContext = () => sidebarContext;
|
|
|
|
await harness.workflow.execute(42);
|
|
|
|
assert.equal(harness.updates.length, 1);
|
|
assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line');
|
|
assert.deepEqual(audioContext, sidebarContext);
|
|
assert.deepEqual(imageContext, sidebarContext);
|
|
assert.equal(miscInfoStartTime, 10);
|
|
});
|