mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
FieldGroupingMergeCollaborator,
|
|
type FieldGroupingMergeNoteInfo,
|
|
} from './field-grouping-merge';
|
|
import type { AnkiConnectConfig } from '../types/anki';
|
|
|
|
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
|
return (
|
|
availableFieldNames.find(
|
|
(name) => name === preferredName || name.toLowerCase() === preferredName.toLowerCase(),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function createCollaborator(
|
|
options: {
|
|
config?: Partial<AnkiConnectConfig>;
|
|
currentSubtitleText?: string;
|
|
generatedMedia?: {
|
|
audioField?: string;
|
|
audioValue?: string;
|
|
imageField?: string;
|
|
imageValue?: string;
|
|
miscInfoValue?: string;
|
|
};
|
|
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
|
|
fieldGroupingProvider?: 'kiku' | 'senren' | null;
|
|
} = {},
|
|
) {
|
|
const warnings = options.warnings ?? [];
|
|
const config = {
|
|
fields: {
|
|
sentence: 'Sentence',
|
|
audio: 'ExpressionAudio',
|
|
image: 'Picture',
|
|
miscInfo: 'MiscInfo',
|
|
...(options.config?.fields ?? {}),
|
|
},
|
|
...(options.config ?? {}),
|
|
} as AnkiConnectConfig;
|
|
|
|
return {
|
|
collaborator: new FieldGroupingMergeCollaborator({
|
|
getConfig: () => config,
|
|
getEffectiveSentenceCardConfig: () => ({
|
|
sentenceField: 'Sentence',
|
|
audioField: 'SentenceAudio',
|
|
fieldGroupingProvider:
|
|
options.fieldGroupingProvider === undefined ? 'kiku' : options.fieldGroupingProvider,
|
|
}),
|
|
getCurrentSubtitleText: () => options.currentSubtitleText,
|
|
resolveFieldName,
|
|
resolveNoteFieldName: (noteInfo, preferredName) => {
|
|
if (!preferredName) return null;
|
|
return resolveFieldName(Object.keys(noteInfo.fields), preferredName);
|
|
},
|
|
extractFields: (fields) =>
|
|
Object.fromEntries(
|
|
Object.entries(fields).map(([key, value]) => [key.toLowerCase(), value.value || '']),
|
|
),
|
|
processSentence: (mpvSentence) => `${mpvSentence}::processed`,
|
|
generateMediaForMerge: async () => options.generatedMedia ?? {},
|
|
warnFieldParseOnce: (fieldName, reason, detail) => {
|
|
warnings.push({ fieldName, reason, detail });
|
|
},
|
|
}),
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
function makeNote(noteId: number, fields: Record<string, string>): FieldGroupingMergeNoteInfo {
|
|
return {
|
|
noteId,
|
|
fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, { value }])),
|
|
};
|
|
}
|
|
|
|
test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => {
|
|
const { collaborator } = createCollaborator({
|
|
config: {
|
|
fields: {
|
|
image: 'Illustration',
|
|
sentence: 'SentenceText',
|
|
audio: 'CustomWordAudio',
|
|
miscInfo: 'ExtraInfo',
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(collaborator.getGroupableFieldNames(), [
|
|
'Sentence',
|
|
'SentenceAudio',
|
|
'Picture',
|
|
'Illustration',
|
|
'SentenceText',
|
|
'ExtraInfo',
|
|
'SentenceFurigana',
|
|
]);
|
|
});
|
|
|
|
test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => {
|
|
const { collaborator } = createCollaborator();
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
Sentence: 'original sentence',
|
|
SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]',
|
|
Picture: '<img src="original.png">',
|
|
MiscInfo: 'original misc',
|
|
ExpressionAudio: '[sound:word.mp3]',
|
|
}),
|
|
makeNote(200, {
|
|
Sentence: 'new sentence',
|
|
SentenceAudio: '[sound:new.mp3]',
|
|
Picture: '<img src="new.png">',
|
|
MiscInfo: 'new misc',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.Sentence,
|
|
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
|
|
);
|
|
assert.equal(
|
|
merged.SentenceAudio,
|
|
'<span data-group-id="300">[sound:original-a.mp3] [sound:original-b.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
|
|
);
|
|
assert.equal(
|
|
merged.Picture,
|
|
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
|
|
);
|
|
assert.equal(
|
|
merged.MiscInfo,
|
|
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
|
|
);
|
|
assert.equal('ExpressionAudio' in merged, false);
|
|
});
|
|
|
|
test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => {
|
|
const { collaborator } = createCollaborator();
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
200,
|
|
300,
|
|
makeNote(200, {
|
|
Sentence: 'new sentence',
|
|
SentenceAudio: '[sound:new.mp3]',
|
|
Picture: '<img src="new.png">',
|
|
MiscInfo: 'new misc',
|
|
}),
|
|
makeNote(300, {
|
|
Sentence: 'original sentence',
|
|
SentenceAudio: '[sound:original.mp3]',
|
|
Picture: '<img src="original.png">',
|
|
MiscInfo: 'original misc',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.Sentence,
|
|
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
|
|
);
|
|
assert.equal(
|
|
merged.SentenceAudio,
|
|
'<span data-group-id="300">[sound:original.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
|
|
);
|
|
assert.equal(
|
|
merged.Picture,
|
|
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
|
|
);
|
|
assert.equal(
|
|
merged.MiscInfo,
|
|
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
|
|
);
|
|
});
|
|
|
|
test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => {
|
|
const { collaborator, warnings } = createCollaborator({
|
|
currentSubtitleText: 'subtitle line',
|
|
});
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
3,
|
|
4,
|
|
makeNote(3, {
|
|
Sentence: '<span data-group-id="abc">keep sentence</span>',
|
|
SentenceAudio: '',
|
|
}),
|
|
makeNote(4, {
|
|
Sentence: 'source sentence',
|
|
SentenceAudio: '[sound:source.mp3]',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.Sentence,
|
|
'<span data-group-id="4">source sentence</span><span data-group-id="3"><span data-group-id="abc">keep sentence</span></span>',
|
|
);
|
|
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
|
|
assert.equal(warnings.length, 4);
|
|
assert.deepEqual(
|
|
warnings.map((entry) => entry.reason),
|
|
['invalid-group-id', 'no-usable-span-entries', 'invalid-group-id', 'no-usable-span-entries'],
|
|
);
|
|
});
|
|
|
|
test('computeFieldGroupingMergedFields uses generated media only when includeGeneratedMedia is true', async () => {
|
|
const generatedMedia = {
|
|
audioField: 'SentenceAudio',
|
|
audioValue: '[sound:generated.mp3]',
|
|
imageField: 'Picture',
|
|
imageValue: '<img src="generated.png">',
|
|
miscInfoValue: 'generated misc',
|
|
};
|
|
const { collaborator: withoutGenerated } = createCollaborator({ generatedMedia });
|
|
const { collaborator: withGenerated } = createCollaborator({ generatedMedia });
|
|
|
|
const keep = makeNote(10, {
|
|
SentenceAudio: '',
|
|
Picture: '',
|
|
MiscInfo: '',
|
|
});
|
|
const source = makeNote(11, {
|
|
SentenceAudio: '',
|
|
Picture: '',
|
|
MiscInfo: '',
|
|
});
|
|
|
|
const without = await withoutGenerated.computeFieldGroupingMergedFields(
|
|
10,
|
|
11,
|
|
keep,
|
|
source,
|
|
false,
|
|
);
|
|
const withMedia = await withGenerated.computeFieldGroupingMergedFields(
|
|
10,
|
|
11,
|
|
keep,
|
|
source,
|
|
true,
|
|
);
|
|
|
|
assert.deepEqual(without, {});
|
|
assert.equal(withMedia.SentenceAudio, '<span data-group-id="11">[sound:generated.mp3]</span>');
|
|
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
|
|
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
|
|
});
|
|
|
|
test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
word: '語',
|
|
sentence: '<span class="group">前<span class="highlight">語</span>後</span>',
|
|
sentenceAudio: '[sound:original.opus]',
|
|
picture: '<img src="original.webp">',
|
|
miscInfo: '<span class="group">Show EP1 (0:01:00)</span>',
|
|
}),
|
|
makeNote(200, {
|
|
word: '語',
|
|
sentence: '<span class="group">次<span class="highlight">語</span>文</span>',
|
|
sentenceAudio: '[sound:new.opus]',
|
|
picture: '<img src="new.webp">',
|
|
miscInfo: 'Show EP2 (0:02:00)',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.sentence,
|
|
'<span class="group">前<span class="highlight">語</span>後</span>' +
|
|
'<span class="group2">次<span class="highlight">語</span>文</span>',
|
|
);
|
|
assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]');
|
|
assert.equal(merged.picture, '<img src="original.webp"><img src="new.webp">');
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group">Show EP1 (0:01:00)</span><span class="group2">Show EP2 (0:02:00)</span>',
|
|
);
|
|
});
|
|
|
|
test('Senren merge warns for invalid source audio when kept audio is empty', async () => {
|
|
const warnings: Array<{ fieldName: string; reason: string; detail?: string }> = [];
|
|
const { collaborator } = createCollaborator({
|
|
fieldGroupingProvider: 'senren',
|
|
warnings,
|
|
});
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, { SentenceAudio: '' }),
|
|
makeNote(200, { SentenceAudio: 'invalid audio' }),
|
|
false,
|
|
);
|
|
|
|
assert.equal(merged.SentenceAudio, 'invalid audio');
|
|
assert.deepEqual(warnings, [
|
|
{
|
|
fieldName: 'SentenceAudio',
|
|
reason: 'missing-sound-tag',
|
|
detail: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
sentence: 'plain legacy sentence',
|
|
sentenceAudio: '[sound:a.opus][sound:b.opus]',
|
|
miscInfo: '<span class="group2">pinned</span> stray text',
|
|
}),
|
|
makeNote(200, {
|
|
sentence: '<span class="group">new sentence</span>',
|
|
sentenceAudio: '[sound:c.opus]',
|
|
miscInfo: '<span class="group">new misc</span>',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.sentence,
|
|
'<span class="group">plain legacy sentence</span><span class="group3">new sentence</span>',
|
|
);
|
|
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]');
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group2">pinned</span><span class="group">stray text</span>' +
|
|
'<span class="group3">new misc</span>',
|
|
);
|
|
});
|
|
|
|
test('Senren merge rebases numbered groups from an appended source note', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
|
|
miscInfo: '<span class="group">keep one</span><span class="group">keep two</span>',
|
|
}),
|
|
makeNote(200, {
|
|
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
|
|
miscInfo: '<span class="group2">source two</span>',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.sentenceAudio,
|
|
'[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]',
|
|
);
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group">keep one</span><span class="group">keep two</span>' +
|
|
'<span class="group4">source two</span>',
|
|
);
|
|
});
|
|
|
|
test('Senren merge rebases plain source groups after empty and sparse kept fields', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
|
|
sentence: '',
|
|
miscInfo: '<span class="group">keep first</span>',
|
|
}),
|
|
makeNote(200, {
|
|
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
|
|
sentence: '<span class="group">source first</span>',
|
|
miscInfo: '<span class="group">source first</span><span class="group2">source second</span>',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(merged.sentence, '<span class="group3">source first</span>');
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group">keep first</span><span class="group3">source first</span>' +
|
|
'<span class="group4">source second</span>',
|
|
);
|
|
});
|
|
|
|
test('Senren merge keeps ungrouped text in place around an existing group span', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
miscInfo: 'leading<span class="group">middle</span>trailing',
|
|
sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]',
|
|
}),
|
|
makeNote(200, {
|
|
miscInfo: '<span class="group">appended</span>',
|
|
sentenceAudio: '[sound:d.opus]',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
// Order must follow the source field, and the two ungrouped runs must stay separate.
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group">leading</span><span class="group">middle</span>' +
|
|
'<span class="group">trailing</span><span class="group4">appended</span>',
|
|
);
|
|
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]');
|
|
});
|
|
|
|
test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, { miscInfo: '<span class="group">a<span class="highlight">b' }),
|
|
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group">a<span class="highlight">b</span></span><span class="group">next</span>',
|
|
);
|
|
const openTags = merged.miscInfo!.match(/<span\b/g)?.length ?? 0;
|
|
const closeTags = merged.miscInfo!.match(/<\/span>/g)?.length ?? 0;
|
|
assert.equal(openTags, closeTags);
|
|
});
|
|
|
|
test('Senren merge closes unclosed trailing markup before appending later scenes', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, { miscInfo: 'leading<span class="highlight">tail' }),
|
|
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
|
|
false,
|
|
);
|
|
|
|
assert.equal(
|
|
merged.miscInfo,
|
|
'<span class="group">leading<span class="highlight">tail</span></span>' +
|
|
'<span class="group">next</span>',
|
|
);
|
|
});
|
|
|
|
test('Kiku merge clears SentenceFurigana when either note lacks it', async () => {
|
|
const { collaborator } = createCollaborator();
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
SentenceFurigana: 'original furigana',
|
|
}),
|
|
makeNote(200, {
|
|
SentenceFurigana: '',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(merged.SentenceFurigana, '');
|
|
});
|
|
|
|
test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => {
|
|
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
|
|
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
|
300,
|
|
200,
|
|
makeNote(300, {
|
|
SentenceFurigana: '',
|
|
}),
|
|
makeNote(200, {
|
|
SentenceFurigana: 'duplicate furigana',
|
|
}),
|
|
false,
|
|
);
|
|
|
|
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
|
|
});
|