import test from 'node:test'; import assert from 'node:assert/strict'; import { copyCurrentSubtitle, handleMineSentenceDigit, handleMultiCopyDigit, mineSentenceCard, } from './mining'; import { SubtitleTimingTracker } from '../../subtitle-timing-tracker'; test('copyCurrentSubtitle reports tracker and subtitle guards', () => { const osd: string[] = []; const copied: string[] = []; copyCurrentSubtitle({ subtitleTimingTracker: null, writeClipboardText: (text) => copied.push(text), showMpvOsd: (text) => osd.push(text), }); assert.equal(osd.at(-1), 'Subtitle tracker not available'); copyCurrentSubtitle({ subtitleTimingTracker: { getRecentBlocks: () => [], getCurrentSubtitle: () => null, findTiming: () => null, }, writeClipboardText: (text) => copied.push(text), showMpvOsd: (text) => osd.push(text), }); assert.equal(osd.at(-1), 'No current subtitle'); assert.deepEqual(copied, []); }); test('copyCurrentSubtitle copies current subtitle text', () => { const osd: string[] = []; const copied: string[] = []; copyCurrentSubtitle({ subtitleTimingTracker: { getRecentBlocks: () => [], getCurrentSubtitle: () => 'hello world', findTiming: () => null, }, writeClipboardText: (text) => copied.push(text), showMpvOsd: (text) => osd.push(text), }); assert.deepEqual(copied, ['hello world']); assert.equal(osd.at(-1), 'Copied subtitle'); }); test('mineSentenceCard handles missing integration and disconnected mpv', async () => { const osd: string[] = []; assert.equal( await mineSentenceCard({ ankiIntegration: null, mpvClient: null, showMpvOsd: (text) => osd.push(text), }), false, ); assert.equal(osd.at(-1), 'AnkiConnect integration not enabled'); assert.equal( await mineSentenceCard({ ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async () => false, }, mpvClient: { connected: false, currentSubText: 'line', currentSubStart: 1, currentSubEnd: 2, }, showMpvOsd: (text) => osd.push(text), }), false, ); assert.equal(osd.at(-1), 'MPV not connected'); }); test('mineSentenceCard creates sentence card from mpv subtitle state', async () => { const created: Array<{ sentence: string; startTime: number; endTime: number; secondarySub?: string; }> = []; const createdCard = await mineSentenceCard({ ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { created.push({ sentence, startTime, endTime, secondarySub }); return true; }, }, mpvClient: { connected: true, currentSubText: 'subtitle line', currentSubStart: 10, currentSubEnd: 12, currentSecondarySubText: 'secondary line', }, showMpvOsd: () => {}, }); assert.equal(createdCard, true); assert.deepEqual(created, [ { sentence: 'subtitle line', startTime: 10, endTime: 12, secondarySub: 'secondary line', }, ]); }); test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => { const created: Array<{ sentence: string; secondarySub?: string }> = []; const requestedProperties: string[] = []; await mineSentenceCard({ ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { created.push({ sentence, secondarySub }); return true; }, }, mpvClient: { connected: true, currentSubText: '日本語字幕', currentSubStart: 10, currentSubEnd: 12, currentSecondarySubText: '日本語字幕', requestProperty: async (name: string) => { requestedProperties.push(name); return name === 'secondary-sub-text' ? 'English subtitle' : null; }, }, showMpvOsd: () => {}, }); assert.deepEqual(requestedProperties, ['secondary-sub-text']); assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]); }); test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => { const created: Array<{ sentence: string; secondarySub?: string }> = []; await mineSentenceCard({ ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { created.push({ sentence, secondarySub }); return true; }, }, mpvClient: { connected: true, currentSubText: '日本語字幕', currentSubStart: 10, currentSubEnd: 12, currentSecondarySubText: 'stale cached subtitle', requestProperty: async (name: string) => { if (name === 'secondary-sub-text') { return ''; } return null; }, }, showMpvOsd: () => {}, }); assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: undefined }]); }); test('handleMultiCopyDigit copies available history and reports truncation', () => { const osd: string[] = []; const copied: string[] = []; handleMultiCopyDigit(5, { subtitleTimingTracker: { getRecentBlocks: (count) => ['a', 'b'].slice(0, count), getCurrentSubtitle: () => null, findTiming: () => null, }, writeClipboardText: (text) => copied.push(text), showMpvOsd: (text) => osd.push(text), }); assert.deepEqual(copied, ['a\n\nb']); assert.equal(osd.at(-1), 'Only 2 lines available, copied 2'); }); test('handleMineSentenceDigit reports async create failures', async () => { const osd: string[] = []; const logs: Array<{ message: string; err: unknown }> = []; let cardsMined = 0; handleMineSentenceDigit(2, { subtitleTimingTracker: { getRecentBlocks: () => ['one', 'two'], getCurrentSubtitle: () => null, findTiming: (text) => text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 }, }, ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async () => { throw new Error('mine boom'); }, }, getCurrentSecondarySubText: () => 'sub2', showMpvOsd: (text) => osd.push(text), logError: (message, err) => logs.push({ message, err }), onCardsMined: (count) => { cardsMined += count; }, }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(logs.length, 1); assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:'); assert.equal((logs[0]?.err as Error).message, 'mine boom'); assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom'))); assert.equal(cardsMined, 0); }); test('handleMineSentenceDigit increments successful card count', async () => { const osd: string[] = []; let cardsMined = 0; handleMineSentenceDigit(2, { subtitleTimingTracker: { getRecentBlocks: () => ['one', 'two'], getCurrentSubtitle: () => null, findTiming: (text) => text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 }, }, ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async () => true, }, getCurrentSecondarySubText: () => 'sub2', showMpvOsd: (text) => osd.push(text), logError: () => {}, onCardsMined: (count) => { cardsMined += count; }, }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(cardsMined, 1); }); test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => { const created: Array<{ sentence: string; startTime: number; endTime: number }> = []; const tracker = new SubtitleTimingTracker(); try { tracker.recordSubtitle('same', 1, 2); tracker.recordSubtitle('other', 3, 4); tracker.recordSubtitle('same', 5, 6); handleMineSentenceDigit(3, { subtitleTimingTracker: tracker, ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async (sentence, startTime, endTime) => { created.push({ sentence, startTime, endTime }); return true; }, }, getCurrentSecondarySubText: () => undefined, showMpvOsd: () => {}, logError: () => {}, }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(created, [{ sentence: 'same other same', startTime: 1, endTime: 6 }]); } finally { tracker.destroy(); } }); test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => { const created: Array<{ sentence: string; secondarySub?: string }> = []; const tracker = new SubtitleTimingTracker(); const recordSubtitleWithSecondary = tracker.recordSubtitle as ( text: string, startTime: number, endTime: number, secondaryText?: string, ) => void; try { recordSubtitleWithSecondary.call(tracker, 'one', 1, 2, 'translation one'); recordSubtitleWithSecondary.call(tracker, 'two', 3, 4, 'translation two'); handleMineSentenceDigit(2, { subtitleTimingTracker: tracker, ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { created.push({ sentence, secondarySub }); return true; }, }, getCurrentSecondarySubText: () => 'current translation only', showMpvOsd: () => {}, logError: () => {}, }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(created, [ { sentence: 'one two', secondarySub: 'translation one translation two' }, ]); } finally { tracker.destroy(); } });