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 { countTermsFindLookups, createDeps, createScanDeps, runInjectedYomitanScript, } from './yomitan-scan-test-harness'; import { addYomitanNoteViaSearch, clearYomitanParserCachesForWindow, extractYomitanCurrentAnkiDeckName, getYomitanDictionaryInfo, importYomitanDictionaryFromZip, deleteYomitanDictionaryByTitle, removeYomitanDictionarySettings, requestYomitanScanTokens, requestYomitanTermFrequencies, syncYomitanDefaultAnkiServer, upsertYomitanDictionarySettings, } from './yomitan-parser-runtime'; test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => { let scriptValue = ''; const deps = createDeps(async (script) => { scriptValue = script; return { updated: true }; }); const infoLogs: string[] = []; const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { error: () => undefined, info: (message) => infoLogs.push(message), }); assert.equal(updated, true); assert.match(scriptValue, /optionsGetFull/); assert.match(scriptValue, /setAllSettings/); assert.match(scriptValue, /profileCurrent/); assert.match(scriptValue, /forceOverride = false/); assert.equal(infoLogs.length, 1); }); test('syncYomitanDefaultAnkiServer returns true when script reports no change', async () => { const deps = createDeps(async () => ({ updated: false })); let infoLogCount = 0; const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { error: () => undefined, info: () => { infoLogCount += 1; }, }); assert.equal(synced, true); assert.equal(infoLogCount, 0); }); test('syncYomitanDefaultAnkiServer returns false when existing non-default server blocks update', async () => { const deps = createDeps(async () => ({ updated: false, matched: false, reason: 'blocked-existing-server', })); const infoLogs: string[] = []; const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { error: () => undefined, info: (message) => infoLogs.push(message), }); assert.equal(synced, false); assert.equal(infoLogs.length, 1); assert.match(infoLogs[0] ?? '', /blocked-existing-server/); }); test('syncYomitanDefaultAnkiServer injects force override when enabled', async () => { let scriptValue = ''; const deps = createDeps(async (script) => { scriptValue = script; return { updated: false, matched: true }; }); const synced = await syncYomitanDefaultAnkiServer( 'http://127.0.0.1:8766', deps, { error: () => undefined, info: () => undefined, }, { forceOverride: true }, ); assert.equal(synced, true); assert.match(scriptValue, /forceOverride = true/); }); test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => { const optionsFull = { profileCurrent: 0, profiles: [ { options: { anki: { server: 'http://127.0.0.1:8766', cardFormats: [ { type: 'term', deck: 'Default', model: 'Mining Note', fields: {} }, { type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} }, ], terms: { deck: 'Default', model: 'Legacy Note', fields: {} }, }, }, }, ], }; let savedOptions: typeof optionsFull | null = null; const deps = createDeps((script) => runInjectedYomitanScript(script, (action, params) => { if (action === 'optionsGetFull') { return JSON.parse(JSON.stringify(optionsFull)); } if (action === 'setAllSettings') { savedOptions = (params as { value: typeof optionsFull }).value; return true; } throw new Error(`Unexpected action: ${action}`); }), ); const synced = await syncYomitanDefaultAnkiServer( 'http://127.0.0.1:8766', deps, { error: () => undefined, info: () => undefined, }, { deck: 'Minecraft', forceOverride: true }, ); assert.equal(synced, true); assert.ok(savedOptions); const saved = savedOptions as typeof optionsFull; assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft'); assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji'); assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft'); }); test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => { const deps = createDeps(async () => { throw new Error('execute failed'); }); const errorLogs: string[] = []; const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { error: (message) => errorLogs.push(message), info: () => undefined, }); assert.equal(updated, false); assert.equal(errorLogs.length, 1); }); test('syncYomitanDefaultAnkiServer no-ops for empty target url', async () => { let executeCount = 0; const deps = createDeps(async () => { executeCount += 1; return { updated: true }; }); const updated = await syncYomitanDefaultAnkiServer(' ', deps, { error: () => undefined, info: () => undefined, }); assert.equal(updated, false); assert.equal(executeCount, 0); }); test('extractYomitanCurrentAnkiDeckName prefers the active profile first term card format deck', () => { assert.equal( extractYomitanCurrentAnkiDeckName({ profileCurrent: 1, profiles: [ { options: { anki: { cardFormats: [{ type: 'term', deck: 'Inactive' }], }, }, }, { options: { anki: { cardFormats: [ { type: 'kanji', deck: 'Kanji' }, { type: 'term', deck: 'Mining' }, ], }, }, }, ], }), 'Mining', ); }); test('extractYomitanCurrentAnkiDeckName ignores disabled card format decks', () => { assert.equal( extractYomitanCurrentAnkiDeckName({ profiles: [ { options: { anki: { cardFormats: [ { type: 'term', deck: 'Disabled Term', enabled: false }, { type: 'kanji', deck: 'Disabled Kanji', enabled: false }, { type: 'term', deck: 'Mining', enabled: true }, ], }, }, }, ], }), 'Mining', ); }); test('extractYomitanCurrentAnkiDeckName falls back to legacy term deck', () => { assert.equal( extractYomitanCurrentAnkiDeckName({ profiles: [ { options: { anki: { terms: { deck: 'Legacy Mining' }, }, }, }, ], }), 'Legacy Mining', ); }); test('requestYomitanTermFrequencies returns normalized frequency entries', async () => { let scriptValue = ''; const deps = createDeps(async (script) => { scriptValue = script; return [ { term: '猫', reading: 'ねこ', hasReading: true, dictionary: 'freq-dict', dictionaryPriority: 0, frequency: 77, displayValue: '77', displayValueParsed: true, }, { term: '鍛える', reading: 'きたえる', hasReading: false, dictionary: 'freq-dict', dictionaryPriority: 1, frequency: 46961, displayValue: '2847,46961', displayValueParsed: true, }, { term: 'invalid', dictionary: 'freq-dict', frequency: 0, }, ]; }); const result = await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { error: () => undefined, }); assert.equal(result.length, 2); assert.equal(result[0]?.term, '猫'); assert.equal(result[0]?.hasReading, true); assert.equal(result[0]?.frequency, 77); assert.equal(result[0]?.dictionaryPriority, 0); assert.equal(result[1]?.term, '鍛える'); assert.equal(result[1]?.hasReading, false); assert.equal(result[1]?.frequency, 2847); assert.match(scriptValue, /getTermFrequencies/); assert.match(scriptValue, /optionsGetFull/); }); test('requestYomitanTermFrequencies prefers primary rank from displayValue array pair', async () => { const deps = createDeps(async () => [ { term: '無人', reading: 'むじん', dictionary: 'freq-dict', dictionaryPriority: 0, frequency: 157632, displayValue: [7141, 157632], displayValueParsed: true, }, ]); const result = await requestYomitanTermFrequencies([{ term: '無人', reading: 'むじん' }], deps, { error: () => undefined, }); assert.equal(result.length, 1); assert.equal(result[0]?.term, '無人'); assert.equal(result[0]?.frequency, 7141); }); test('requestYomitanTermFrequencies prefers primary rank from displayValue string pair when raw frequency matches trailing count', async () => { const deps = createDeps(async () => [ { term: '潜む', reading: 'ひそむ', dictionary: 'freq-dict', dictionaryPriority: 0, frequency: 121, displayValue: '118,121', displayValueParsed: false, }, ]); const result = await requestYomitanTermFrequencies([{ term: '潜む', reading: 'ひそむ' }], deps, { error: () => undefined, }); assert.equal(result.length, 1); assert.equal(result[0]?.term, '潜む'); assert.equal(result[0]?.frequency, 118); }); test('requestYomitanTermFrequencies uses leading display digits for displayValue strings', async () => { const deps = createDeps(async () => [ { term: '例', reading: 'れい', dictionary: 'freq-dict', dictionaryPriority: 0, frequency: 1234, displayValue: '1,234', displayValueParsed: false, }, ]); const result = await requestYomitanTermFrequencies([{ term: '例', reading: 'れい' }], deps, { error: () => undefined, }); assert.equal(result.length, 1); assert.equal(result[0]?.term, '例'); assert.equal(result[0]?.frequency, 1); }); test('requestYomitanTermFrequencies ignores occurrence-based dictionaries for rank tagging', async () => { let metadataScript = ''; const deps = createDeps(async (script) => { if (script.includes('getTermFrequencies')) { return [ { term: '潜む', reading: 'ひそむ', dictionary: 'CC100', frequency: 118121, displayValue: null, displayValueParsed: false, }, ]; } if (script.includes('optionsGetFull')) { metadataScript = script; return { profileCurrent: 0, profileIndex: 0, scanLength: 40, dictionaries: ['CC100'], dictionaryPriorityByName: { CC100: 0 }, dictionaryFrequencyModeByName: { CC100: 'occurrence-based' }, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'CC100', enabled: true, id: 0 }], }, }, ], }; } return []; }); const result = await requestYomitanTermFrequencies([{ term: '潜む', reading: 'ひそむ' }], deps, { error: () => undefined, }); assert.deepEqual(result, []); assert.match(metadataScript, /getDictionaryInfo/); }); test('requestYomitanTermFrequencies requests term-only fallback only after reading miss', async () => { const frequencyScripts: string[] = []; const deps = createDeps(async (script) => { if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], }, }, ], }; } if (!script.includes('getTermFrequencies')) { return []; } frequencyScripts.push(script); if (script.includes('"term":"断じて","reading":"だん"')) { return []; } if (script.includes('"term":"断じて","reading":null')) { return [ { term: '断じて', reading: null, dictionary: 'freq-dict', frequency: 7082, displayValue: '7082', displayValueParsed: true, }, ]; } return []; }); const result = await requestYomitanTermFrequencies([{ term: '断じて', reading: 'だん' }], deps, { error: () => undefined, }); assert.equal(result.length, 1); assert.equal(result[0]?.frequency, 7082); assert.equal(frequencyScripts.length, 2); assert.match(frequencyScripts[0] ?? '', /"term":"断じて","reading":"だん"/); assert.doesNotMatch(frequencyScripts[0] ?? '', /"term":"断じて","reading":null/); assert.match(frequencyScripts[1] ?? '', /"term":"断じて","reading":null/); }); test('requestYomitanTermFrequencies avoids term-only fallback request when reading lookup succeeds', async () => { const frequencyScripts: string[] = []; const deps = createDeps(async (script) => { if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], }, }, ], }; } if (!script.includes('getTermFrequencies')) { return []; } frequencyScripts.push(script); return [ { term: '鍛える', reading: 'きたえる', dictionary: 'freq-dict', frequency: 2847, displayValue: '2847', displayValueParsed: true, }, ]; }); const result = await requestYomitanTermFrequencies([{ term: '鍛える', reading: 'きた' }], deps, { error: () => undefined, }); assert.equal(result.length, 1); assert.equal(frequencyScripts.length, 1); assert.match(frequencyScripts[0] ?? '', /"term":"鍛える","reading":"きた"/); assert.doesNotMatch(frequencyScripts[0] ?? '', /"term":"鍛える","reading":null/); }); test('requestYomitanTermFrequencies caches profile metadata between calls', async () => { const scripts: string[] = []; const deps = createDeps(async (script) => { scripts.push(script); if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], }, }, ], }; } if (script.includes('"term":"犬"')) { return [ { term: '犬', reading: 'いぬ', dictionary: 'freq-dict', frequency: 12, displayValue: '12', displayValueParsed: true, }, ]; } return [ { term: '猫', reading: 'ねこ', dictionary: 'freq-dict', frequency: 77, displayValue: '77', displayValueParsed: true, }, ]; }); await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { error: () => undefined, }); await requestYomitanTermFrequencies([{ term: '犬', reading: 'いぬ' }], deps, { error: () => undefined, }); const optionsCalls = scripts.filter((script) => script.includes('optionsGetFull')).length; assert.equal(optionsCalls, 1); }); test('requestYomitanTermFrequencies caches repeated term+reading lookups', async () => { const scripts: string[] = []; const deps = createDeps(async (script) => { scripts.push(script); if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], }, }, ], }; } return [ { term: '猫', reading: 'ねこ', dictionary: 'freq-dict', frequency: 77, displayValue: '77', displayValueParsed: true, }, ]; }); await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { error: () => undefined, }); await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { error: () => undefined, }); const frequencyCalls = scripts.filter((script) => script.includes('getTermFrequencies')).length; assert.equal(frequencyCalls, 1); }); test('requestYomitanScanTokens tokenizes with the in-window scanner and no parseText request', async () => { const scripts: string[] = []; const actions: string[] = []; const deps = createScanDeps( (action, params) => { actions.push(action); if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } if (action === 'termsFind') { const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('取り組んで')) { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 5, dictionaryEntries: [ { headwords: [ { term: '取り組む', reading: 'とりくむ', sources: [{ originalText: '取り組んで', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } throw new Error(`unexpected action: ${action}`); }, { onScript: (script) => scripts.push(script) }, ); const result = await requestYomitanScanTokens('取り組んで', deps, { error: () => undefined, }); assert.deepEqual(result, [ { surface: '取り組んで', reading: 'とりくんで', headword: '取り組む', headwordReading: 'とりくむ', startPos: 0, endPos: 5, isNameMatch: false, frequencyRank: undefined, }, ]); // The duplicate full parse per line is gone: the scanner walk is the only // tokenization request. assert.ok(!actions.includes('parseText')); const installScript = scripts.find((script) => script.includes('termsFind')); assert.ok(installScript, 'expected the scan runtime install script'); assert.match(installScript ?? '', /matchType:\s*"exact"/); assert.match(installScript ?? '', /deinflect:\s*true/); }); test('requestYomitanScanTokens warns when active Yomitan profile has no dictionaries', async () => { const warnings: Array<{ message: string; details: unknown }> = []; const deps = createScanDeps((action) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } return { originalTextLength: 0, dictionaryEntries: [] }; }); await requestYomitanScanTokens('字幕', deps, { error: () => undefined, warn: (message, details) => warnings.push({ message, details }), }); assert.equal(warnings.length, 1); assert.match(warnings[0]!.message, /no enabled dictionaries/); assert.deepEqual(warnings[0]!.details, { profileIndex: 0, scanLength: 40, dictionaryCount: 0, dictionaries: [], omittedDictionaryCount: 0, }); }); test('requestYomitanScanTokens keeps reading aligned when a kana run extends the previous token', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } const text = (params as { text?: string } | undefined)?.text ?? ''; // 待ち合わせ matches, the trailing る does not, so the kana run extends the // previous token instead of becoming its own filler token. if (text.startsWith('待ち合わせ')) { return { originalTextLength: 5, dictionaryEntries: [ { headwords: [ { term: '待ち合わせる', reading: 'まちあわせる', sources: [{ originalText: '待ち合わせ', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } return { originalTextLength: 0, dictionaryEntries: [] }; }); const result = await requestYomitanScanTokens('待ち合わせる', deps, { error: () => undefined, }); assert.equal(result?.length, 1); assert.equal(result?.[0]?.surface, '待ち合わせる'); assert.equal(result?.[0]?.endPos, 6); // The reading must grow with the surface: a short reading fails // isCompleteReadingForSurface and silently disables the known-word reading // fallback downstream. assert.equal(result?.[0]?.reading, 'まちあわせる'); }); test('requestYomitanScanTokens emits unparsed filler runs for text the scanner skips', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } const text = (params as { text?: string } | undefined)?.text ?? ''; const singleCharEntry = (term: string, reading: string) => ({ originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term, reading, sources: [{ originalText: text[0], isPrimary: true, matchType: 'exact' }], }, ], }, ], }); if (text.startsWith('や')) { return singleCharEntry('や', 'や'); } if (text.startsWith('ほ')) { return singleCharEntry('帆', 'ほ'); } if (text.startsWith('ミナト')) { return { originalTextLength: 3, dictionaryEntries: [ { headwords: [ { term: 'ミナト', reading: 'みなと', sources: [{ originalText: 'ミナト', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } return { originalTextLength: 0, dictionaryEntries: [] }; }); const result = await requestYomitanScanTokens('やほっ ミナト', deps, { error: () => undefined, }); assert.deepEqual( result?.map(({ surface, headword, startPos, endPos, isUnparsedRun }) => ({ surface, headword, startPos, endPos, isUnparsedRun, })), [ { surface: 'や', headword: 'や', startPos: 0, endPos: 1, isUnparsedRun: undefined }, { surface: 'ほ', headword: '帆', startPos: 1, endPos: 2, isUnparsedRun: undefined }, // The unmatched っ + space becomes a hoverable filler run, replacing the // parseText filler chunks the pipeline used to rely on. { surface: 'っ ', headword: 'っ ', startPos: 2, endPos: 4, isUnparsedRun: true }, { surface: 'ミナト', headword: 'ミナト', startPos: 4, endPos: 7, isUnparsedRun: undefined }, ], ); assert.equal(result?.[2]?.reading, ''); }); test('requestYomitanScanTokens extracts best frequency rank from selected termsFind entry', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JPDBv2㋕', enabled: true, id: 0 }, { name: 'Jiten', enabled: true, id: 1 }, { name: 'CC100', enabled: true, id: 2 }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('潜み')) { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: '潜む', reading: 'ひそむ', sources: [{ originalText: '潜み', isPrimary: true, matchType: 'exact' }], }, ], frequencies: [ { headwordIndex: 0, dictionary: 'JPDBv2㋕', frequency: 20181, displayValue: '4073,20181句', }, { headwordIndex: 0, dictionary: 'Jiten', frequency: 28594, displayValue: '4592,28594句', }, { headwordIndex: 0, dictionary: 'CC100', frequency: 118121, displayValue: null, }, ], }, ], }; }); const result = await requestYomitanScanTokens('潜み', deps, { error: () => undefined, }); assert.deepEqual(result, [ { surface: '潜み', reading: 'ひそみ', headword: '潜む', headwordReading: 'ひそむ', startPos: 0, endPos: 2, isNameMatch: false, frequencyRank: 4073, }, ]); }); test('requestYomitanScanTokens retries shorter windows when a greedy match has no exact-source headword', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'JMdict', enabled: true, id: 0 }], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('平')) { return { originalTextLength: 0, dictionaryEntries: [] }; } if (text.length >= 4) { // Simulates Yomitan normalization consuming punctuation/whitespace: // the greedy match spans 平 (平 but no headword source equals it. return { originalTextLength: 4, dictionaryEntries: [ { headwords: [ { term: '平々', reading: 'へいへい', sources: [{ originalText: '平平', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: '平', reading: 'たいら', sources: [{ originalText: '平', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; }); const result = await requestYomitanScanTokens('平 (平)', deps, { error: () => undefined, }); assert.deepEqual(result, [ { surface: '平', reading: 'たいら', headword: '平', headwordReading: 'たいら', startPos: 0, endPos: 1, isNameMatch: false, frequencyRank: undefined, }, { surface: '平', reading: 'たいら', headword: '平', headwordReading: 'たいら', startPos: 3, endPos: 4, isNameMatch: false, frequencyRank: undefined, }, ]); }); test('requestYomitanScanTokens emits complete readings for kanji-kana compounds', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'JPDBv2㋕', enabled: true, id: 0 }], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('待ち合わせてる')) { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 7, dictionaryEntries: [ { headwords: [ { term: '待ち合わせる', reading: 'まちあわせる', sources: [{ originalText: '待ち合わせてる', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; }); const result = await requestYomitanScanTokens('待ち合わせてる', deps, { error: () => undefined, }); assert.deepEqual(result, [ { surface: '待ち合わせてる', reading: 'まちあわせてる', headword: '待ち合わせる', headwordReading: 'まちあわせる', startPos: 0, endPos: 7, isNameMatch: false, frequencyRank: undefined, }, ]); }); test('requestYomitanScanTokens uses frequency from later exact-match entry when first exact entry has none', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JPDBv2㋕', enabled: true, id: 0 }, { name: 'Jiten', enabled: true, id: 1 }, { name: 'CC100', enabled: true, id: 2 }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('者')) { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: '者', reading: 'もの', sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }], }, ], frequencies: [], }, { headwords: [ { term: '者', reading: 'もの', sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }], }, ], frequencies: [ { headwordIndex: 0, dictionary: 'JPDBv2㋕', frequency: 79601, displayValue: '475,79601句', }, { headwordIndex: 0, dictionary: 'Jiten', frequency: 338, displayValue: '338', }, ], }, ], }; }); const result = await requestYomitanScanTokens('者', deps, { error: () => undefined, }); assert.deepEqual(result, [ { surface: '者', reading: 'もの', headword: '者', headwordReading: 'もの', startPos: 0, endPos: 1, isNameMatch: false, frequencyRank: 475, }, ]); }); test('requestYomitanScanTokens can use frequency from later exact secondary-match entry', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JPDBv2㋕', enabled: true, id: 0 }, { name: 'Jiten', enabled: true, id: 1 }, { name: 'CC100', enabled: true, id: 2 }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('者')) { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: '者', reading: 'もの', sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }], }, ], frequencies: [], }, { headwords: [ { term: '者', reading: 'もの', sources: [{ originalText: '者', isPrimary: false, matchType: 'exact' }], }, ], frequencies: [ { headwordIndex: 0, dictionary: 'JPDBv2㋕', frequency: 79601, displayValue: '475,79601句', }, ], }, ], }; }); const result = await requestYomitanScanTokens('者', deps, { error: () => undefined, }); assert.deepEqual(result, [ { surface: '者', reading: 'もの', headword: '者', headwordReading: 'もの', startPos: 0, endPos: 1, isNameMatch: false, frequencyRank: 475, }, ]); }); test('requestYomitanScanTokens uses exact frequency entry when selected reading differs', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JPDBv2㋕', enabled: true, id: 0 }, { name: 'Jiten', enabled: true, id: 1 }, { name: 'CC100', enabled: true, id: 2 }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (!text.startsWith('第二')) { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: '第二', reading: 'だいに', sources: [{ originalText: '第二', isPrimary: true, matchType: 'exact' }], }, ], frequencies: [], }, { headwords: [ { term: '第二', reading: '', sources: [{ originalText: '第二', isPrimary: false, matchType: 'exact' }], }, ], frequencies: [ { headwordIndex: 0, dictionary: 'JPDBv2㋕', frequency: 189513, displayValue: '1820,189513句', }, ], }, ], }; }); const result = await requestYomitanScanTokens('第二走者', deps, { error: () => undefined, }); assert.deepEqual(result?.[0], { surface: '第二', reading: 'だいに', headword: '第二', headwordReading: 'だいに', startPos: 0, endPos: 2, isNameMatch: false, frequencyRank: 1820, }); }); test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => { const deps = createDeps(async (script) => { if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } return [ { surface: 'アクア', reading: 'あくあ', headword: 'アクア', startPos: 0, endPos: 3, isNameMatch: true, }, { surface: 'です', reading: 'です', headword: 'です', startPos: 3, endPos: 5, isNameMatch: false, }, ]; }); const result = await requestYomitanScanTokens('アクアです', deps, { error: () => undefined, }); assert.equal(result?.length, 2); assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true); assert.equal((result?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false); }); test('requestYomitanScanTokens skips name-match work when disabled', async () => { let scanCallScript = ''; const deps = createDeps(async (script) => { if (script.includes('__subminerYomitanScan(')) { scanCallScript = script; } if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } return [ { surface: 'アクア', reading: 'あくあ', headword: 'アクア', startPos: 0, endPos: 3, }, ]; }); const result = await requestYomitanScanTokens( 'アクア', deps, { error: () => undefined }, { includeNameMatchMetadata: false }, ); assert.equal(result?.length, 1); assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, undefined); assert.match(scanCallScript, /"includeNameMatchMetadata":false/); }); test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary alias only exists on definitions', async () => { const scripts: string[] = []; const deps = createScanDeps( (action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action === 'termsFind') { const text = (params as { text?: string } | undefined)?.text; if (text === 'カズマ') { return { originalTextLength: 3, dictionaryEntries: [ { dictionaryAlias: '', headwords: [ { term: 'カズマ', reading: 'かずま', sources: [{ originalText: 'カズマ', isPrimary: true, matchType: 'exact' }], }, ], definitions: [ { dictionary: 'JMdict', dictionaryAlias: 'JMdict' }, { dictionary: 'SubMiner Character Dictionary (AniList 130298)', dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', }, ], }, ], }; } return { originalTextLength: 0, dictionaryEntries: [] }; } throw new Error(`unexpected action: ${action}`); }, { onScript: (script) => scripts.push(script) }, ); const result = await requestYomitanScanTokens( 'カズマ', deps, { error: () => undefined }, { includeNameMatchMetadata: true }, ); assert.ok(scripts.some((script) => script.includes('getPreferredHeadword'))); assert.equal(Array.isArray(result), true); assert.equal((result as { length?: number } | null)?.length, 1); assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'カズマ'); assert.equal((result as Array<{ headword?: string }>)[0]?.headword, 'カズマ'); assert.equal((result as Array<{ startPos?: number }>)[0]?.startPos, 0); assert.equal((result as Array<{ endPos?: number }>)[0]?.endPos, 3); assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); }); test('requestYomitanScanTokens ignores SubMiner character entries from other media', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text; if (text !== 'カズ') { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: 'カズ', reading: 'かず', sources: [{ originalText: 'カズ', isPrimary: true, matchType: 'exact' }], }, ], definitions: [ { dictionary: 'SubMiner Character Dictionary', dictionaryAlias: 'SubMiner Character Dictionary', entries: [ { type: 'structured-content', content: { tag: 'img', path: 'img/m115230-c9.png', alt: 'Kaz', }, }, ], }, ], }, ], }; }); const result = await requestYomitanScanTokens( 'カズ', deps, { error: () => undefined }, { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21202 }, ); // No dictionary-backed token survives (the only match belongs to another // media's character dictionary), so the line reports no tokenization. assert.equal(result, null); }); test('requestYomitanScanTokens accepts SubMiner character entries with structured-content media data', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text; if (text !== 'アクア') { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 3, dictionaryEntries: [ { headwords: [ { term: 'アクア', reading: 'あくあ', sources: [{ originalText: 'アクア', isPrimary: true, matchType: 'exact' }], }, ], definitions: [ { dictionary: 'SubMiner Character Dictionary', dictionaryAlias: 'SubMiner Character Dictionary', entries: [ { type: 'structured-content', content: { tag: 'div', data: { subminerMediaId: '21699' }, content: [ { tag: 'img', path: 'img/m115230-c1.png', alt: 'アクア', }, ], }, }, ], }, ], }, ], }; }); const result = await requestYomitanScanTokens( 'アクア', deps, { error: () => undefined }, { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21699 }, ); assert.equal(Array.isArray(result), true); assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'アクア'); assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); }); test('requestYomitanScanTokens greedily tokenizes character names before longer generic matches', async () => { let scanCallScript = ''; const nameEntry = (term: string, reading: string) => ({ headwords: [ { term, reading, sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }], }, ], definitions: [ { dictionary: 'SubMiner Character Dictionary (AniList 130298)', dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', }, ], }); const jmdictEntry = (term: string, reading: string, originalText: string) => ({ headwords: [ { term, reading, sources: [{ originalText, isPrimary: true, matchType: 'exact' }], }, ], definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }], }); const deps = createScanDeps( (action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JMdict', enabled: true }, { name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (text.startsWith('美姫')) { return { originalTextLength: 2, dictionaryEntries: [nameEntry('美姫', 'みき')] }; } if (text.startsWith('とヨータ')) { // Greedy generic match: とヨー normalizes to とよう (渡洋). Without the // name pre-pass this consumes the ヨ of ヨータ. return { originalTextLength: 3, dictionaryEntries: [ jmdictEntry('渡洋', 'とよう', 'とヨー'), jmdictEntry('と', 'と', 'と'), ], }; } if (text.startsWith('ヨータ')) { return { originalTextLength: 3, dictionaryEntries: [nameEntry('ヨータ', 'よーた')] }; } if (text === 'と') { return { originalTextLength: 1, dictionaryEntries: [jmdictEntry('と', 'と', 'と')] }; } return { originalTextLength: 0, dictionaryEntries: [] }; }, { onScript: (script) => { if (script.includes('__subminerYomitanScan(')) { scanCallScript = script; } }, }, ); const result = await requestYomitanScanTokens( '美姫とヨータ', deps, { error: () => undefined }, { includeNameMatchMetadata: true }, ); assert.match(scanCallScript, /"greedyNameScanEnabled":true/); assert.equal(Array.isArray(result), true); assert.deepEqual( result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ surface, headword, startPos, endPos, isNameMatch, })), [ { surface: '美姫', headword: '美姫', startPos: 0, endPos: 2, isNameMatch: true }, { surface: 'と', headword: 'と', startPos: 2, endPos: 3, isNameMatch: false }, { surface: 'ヨータ', headword: 'ヨータ', startPos: 3, endPos: 6, isNameMatch: true }, ], ); }); test('requestYomitanScanTokens lets a longer generic word beat a shorter name at the same position', async () => { const nameEntry = (term: string, reading: string) => ({ headwords: [ { term, reading, sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }], }, ], definitions: [ { dictionary: 'SubMiner Character Dictionary (AniList 130298)', dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', }, ], }); const jmdictEntry = (term: string, reading: string, originalText: string) => ({ headwords: [ { term, reading, sources: [{ originalText, isPrimary: true, matchType: 'exact' }], }, ], definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }], }); const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JMdict', enabled: true }, { name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (text.startsWith('空気')) { // A character named 空 matches here, but the generic 空気 is longer and // must win the position. return { originalTextLength: 2, dictionaryEntries: [nameEntry('空', 'くう'), jmdictEntry('空気', 'くうき', '空気')], }; } if (text.startsWith('変わって')) { return { originalTextLength: 4, dictionaryEntries: [jmdictEntry('変わる', 'かわる', '変わって')], }; } return { originalTextLength: 0, dictionaryEntries: [] }; }); const result = await requestYomitanScanTokens( '空気変わって', deps, { error: () => undefined }, { includeNameMatchMetadata: true }, ); assert.equal(Array.isArray(result), true); assert.deepEqual( result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ surface, headword, startPos, endPos, isNameMatch, })), [ { surface: '空気', headword: '空気', startPos: 0, endPos: 2, isNameMatch: false }, { surface: '変わって', headword: '変わる', startPos: 2, endPos: 6, isNameMatch: false }, ], ); }); test('requestYomitanScanTokens lets a generic word beat a name it fully contains', async () => { const nameEntry = (term: string, reading: string) => ({ headwords: [ { term, reading, sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }], }, ], definitions: [ { dictionary: 'SubMiner Character Dictionary (AniList 130298)', dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', }, ], }); const jmdictEntry = (term: string, reading: string, originalText: string) => ({ headwords: [ { term, reading, sources: [{ originalText, isPrimary: true, matchType: 'exact' }], }, ], definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }], }); const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [ { name: 'JMdict', enabled: true }, { name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true }, ], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; if (text.startsWith('写真')) { return { originalTextLength: 2, dictionaryEntries: [jmdictEntry('写真', 'しゃしん', '写真')], }; } if (text.startsWith('写')) { return { originalTextLength: 1, dictionaryEntries: [jmdictEntry('写', 'しゃ', '写')] }; } if (text.startsWith('真')) { // The given name of 安田真 also matches the second half of 写真. return { originalTextLength: 1, dictionaryEntries: [nameEntry('真', 'しん'), jmdictEntry('真', 'しん', '真')], }; } if (text.startsWith('は')) { return { originalTextLength: 1, dictionaryEntries: [jmdictEntry('は', 'は', 'は')] }; } return { originalTextLength: 0, dictionaryEntries: [] }; }); const result = await requestYomitanScanTokens( '写真は', deps, { error: () => undefined }, { includeNameMatchMetadata: true }, ); assert.equal(Array.isArray(result), true); assert.deepEqual( result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ surface, headword, startPos, endPos, isNameMatch, })), [ { surface: '写真', headword: '写真', startPos: 0, endPos: 2, isNameMatch: false }, { surface: 'は', headword: 'は', startPos: 2, endPos: 3, isNameMatch: false }, ], ); }); test('requestYomitanScanTokens skips greedy name scan without an enabled character dictionary', async () => { let scanCallScript = ''; const deps = createScanDeps( (action) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, dictionaries: [{ name: 'JMdict', enabled: true }], }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } return { originalTextLength: 0, dictionaryEntries: [] }; }, { onScript: (script) => { if (script.includes('__subminerYomitanScan(')) { scanCallScript = script; } }, }, ); await requestYomitanScanTokens( 'アクア', deps, { error: () => undefined }, { includeNameMatchMetadata: true }, ); assert.match(scanCallScript, /"greedyNameScanEnabled":false/); }); test('requestYomitanScanTokens preserves matched headword word classes', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text; if (text !== 'は') { return { originalTextLength: 0, dictionaryEntries: [] }; } return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: 'は', reading: 'は', wordClasses: ['prt'], sources: [{ originalText: 'は', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; }); const result = await requestYomitanScanTokens('は', deps, { error: () => undefined }); assert.deepEqual((result as Array<{ wordClasses?: string[] }>)[0]?.wordClasses, ['prt']); }); test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => { const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [ { options: { scanning: { length: 40 }, }, }, ], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } { const text = (params as { text?: string } | undefined)?.text ?? ''; if (text.startsWith('だが ')) { return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: 'だが', reading: 'だが', sources: [{ originalText: 'だが', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } if (text.startsWith('それでも')) { return { originalTextLength: 4, dictionaryEntries: [ { headwords: [ { term: 'それでも', reading: 'それでも', sources: [{ originalText: 'それでも', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } if (text.startsWith('届かぬ')) { return { originalTextLength: 3, dictionaryEntries: [ { headwords: [ { term: '届く', reading: 'とどく', sources: [{ originalText: '届かぬ', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } if (text.startsWith('高み')) { return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: '高み', reading: 'たかみ', sources: [{ originalText: '高み', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } if (text.startsWith('があった')) { return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: 'があ', reading: '', sources: [{ originalText: 'が', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } if (text.startsWith('あった')) { return { originalTextLength: 3, dictionaryEntries: [ { headwords: [ { term: 'ある', reading: 'ある', sources: [{ originalText: 'あった', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } return { originalTextLength: 0, dictionaryEntries: [] }; } }); const result = await requestYomitanScanTokens('だが それでも届かぬ高みがあった', deps, { error: () => undefined, }); assert.deepEqual( result?.map((token) => ({ surface: token.surface, headword: token.headword, startPos: token.startPos, endPos: token.endPos, })), [ { surface: 'だが', headword: 'だが', startPos: 0, endPos: 2, }, { surface: 'それでも', headword: 'それでも', startPos: 3, endPos: 7, }, { surface: '届かぬ', headword: '届く', startPos: 7, endPos: 10, }, { surface: '高み', headword: '高み', startPos: 10, endPos: 12, }, // が has no exact primary source match, so it survives only as an // unparsed filler run (the parseText segmentation used to supply this). { surface: 'が', headword: 'が', startPos: 12, endPos: 13, }, { surface: 'あった', headword: 'ある', startPos: 13, endPos: 16, }, ], ); assert.equal(result?.[4]?.isUnparsedRun, true); }); function createSingleTermScanHandler(lookups: string[]) { return (action: string, params: unknown): unknown => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } if (action !== 'termsFind') { throw new Error(`unexpected action: ${action}`); } const text = (params as { text?: string } | undefined)?.text ?? ''; lookups.push(text); if (text.startsWith('猫')) { return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: '猫', reading: 'ねこ', sources: [{ originalText: '猫', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } return { originalTextLength: 0, dictionaryEntries: [] }; }; } test('requestYomitanScanTokens reuses the cross-line termsFind cache for repeated lookups', async () => { const lookups: string[] = []; const deps = createScanDeps(createSingleTermScanHandler(lookups)); const first = await requestYomitanScanTokens('猫', deps, { error: () => undefined }); const second = await requestYomitanScanTokens('猫', deps, { error: () => undefined }); assert.equal(first?.length, 1); assert.equal(second?.length, 1); // The second line hits the window-persistent cache: no new backend lookup. assert.equal(countTermsFindLookups(lookups, '猫'), 1); }); test('clearYomitanParserCachesForWindow invalidates the cross-line termsFind cache', async () => { const lookups: string[] = []; const deps = createScanDeps(createSingleTermScanHandler(lookups)); await requestYomitanScanTokens('猫', deps, { error: () => undefined }); clearYomitanParserCachesForWindow(deps.getYomitanParserWindow() as never); await requestYomitanScanTokens('猫', deps, { error: () => undefined }); assert.equal(countTermsFindLookups(lookups, '猫'), 2); }); test('an oversized termsFind result is dropped from the cache instead of being reused', async () => { const lookups: string[] = []; // One entry over the runtime's 20,000 retained-entry budget: the weight is // only known once the lookup resolves, so the cache has to re-check then. const oversizedEntries = Array.from({ length: 20_001 }, () => ({ headwords: [ { term: '猫', reading: 'ねこ', sources: [{ originalText: '猫', isPrimary: true, matchType: 'exact' }], }, ], })); const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } const text = (params as { text?: string } | undefined)?.text ?? ''; lookups.push(text); if (text.startsWith('猫')) { return { originalTextLength: 1, dictionaryEntries: oversizedEntries }; } return { originalTextLength: 0, dictionaryEntries: [] }; }); await requestYomitanScanTokens('猫', deps, { error: () => undefined }); await requestYomitanScanTokens('猫', deps, { error: () => undefined }); assert.equal(countTermsFindLookups(lookups, '猫'), 2); }); test('scanner tokens survive a retry-budget escalation whose parseText finds nothing', async () => { const parsedTexts: string[] = []; const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } const text = (params as { text?: string } | undefined)?.text ?? ''; if (action === 'parseText') { parsedTexts.push(text); return []; } if (text.startsWith('猫')) { return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: '猫', reading: 'ねこ', sources: [{ originalText: '猫', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } // The rest of the line burns the blind-retry budget at every position. return { originalTextLength: text.length, dictionaryEntries: [ { headwords: [ { term: 'ミスマッチ', reading: 'みすまっち', sources: [{ originalText: 'ZZZ', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; }); const result = await requestYomitanScanTokens('猫あいうえおかきくけこ', deps, { error: () => undefined, }); // The escalation ran exactly once and found nothing, so the tokens the // scanner did resolve are kept instead of dropping the line to raw text. assert.deepEqual(parsedTexts, ['猫あいうえおかきくけこ']); assert.equal(result?.[0]?.surface, '猫'); }); test('requestYomitanScanTokens skips termsFind lookups at punctuation and whitespace positions', async () => { const lookups: string[] = []; const deps = createScanDeps(createSingleTermScanHandler(lookups)); const result = await requestYomitanScanTokens('「猫」…♪', deps, { error: () => undefined }); assert.equal(result?.length, 1); assert.equal(result?.[0]?.surface, '猫'); assert.equal(countTermsFindLookups(lookups, '猫'), 1); for (const skipped of ['「', '」', '…', '♪']) { assert.equal(countTermsFindLookups(lookups, skipped), 0, `expected no lookup at ${skipped}`); } }); test('requestYomitanScanTokens caps blind retries and escalates the line to parseText', async () => { const lookups: string[] = []; const parsedTexts: string[] = []; const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } const text = (params as { text?: string } | undefined)?.text ?? ''; if (action === 'parseText') { parsedTexts.push(text); return [ { source: 'scanning-parser', index: 0, content: [ [{ text: 'あいうえお', reading: 'あいうえお', headwords: [[{ term: 'あい' }]] }], ], }, ]; } lookups.push(text); // Every window "matches" its whole length but never yields an // exact-source headword, the worst case for the retry ladder: each step // down is a blind guess with nothing shorter reported to aim at. return { originalTextLength: text.length, dictionaryEntries: [ { headwords: [ { term: 'ミスマッチ', reading: 'みすまっち', sources: [{ originalText: 'ZZZ', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; }); const result = await requestYomitanScanTokens('あいうえおかきくけこ', deps, { error: () => undefined, }); // Position 0: one initial window lookup plus at most four blind retries, so // the ladder cannot degrade into a lookup per window length. assert.equal(countTermsFindLookups(lookups, 'あいうえお'), 5); // Giving up there would leave the line unparsed, so it escalates to the one // full parse the scanner normally replaces. assert.deepEqual(parsedTexts, ['あいうえおかきくけこ']); assert.equal(result?.[0]?.headword, 'あい'); }); test('requestYomitanScanTokens keeps shrinking while the backend guides the retry ladder', async () => { const lookups: string[] = []; const deps = createScanDeps((action, params) => { if (action === 'optionsGetFull') { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (action === 'getDictionaryInfo') { return []; } const text = (params as { text?: string } | undefined)?.text ?? ''; lookups.push(text); // Normalization keeps eating one character past the term, so every window // reports a shorter consumed length: informative steps that must not be // spent from the blind-retry budget. The term only surfaces at length 2, // six lookups down the ladder. if (text.length === 2) { return { originalTextLength: 2, dictionaryEntries: [ { headwords: [ { term: 'あい', reading: 'あい', sources: [{ originalText: 'あい', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; } return { originalTextLength: Math.max(text.length - 1, 0), dictionaryEntries: [ { headwords: [ { term: 'ミスマッチ', reading: 'みすまっち', sources: [{ originalText: 'ZZZ', isPrimary: true, matchType: 'exact' }], }, ], }, ], }; }); const result = await requestYomitanScanTokens('あいうえおかきくけこさしすせ', deps, { error: () => undefined, }); assert.equal(result?.[0]?.surface, 'あい'); // Windows of 14, 12, 10, 8, 6, 4 characters, then the match at 2: a ladder // capped at four lookups would stop at 6 and leave the line unparsed. assert.equal(countTermsFindLookups(lookups, 'あい'), 7); }); test('requestYomitanScanTokens falls back to parseText when the scanner eval fails', async () => { const deps = createDeps(async (script) => { if (script.includes('optionsGetFull')) { return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 40 } } }], }; } if (script.includes('__subminerYomitanScan(')) { throw new Error('eval failed'); } if (script.includes('parseText')) { return [ { source: 'scanning-parser', index: 0, content: [ [ { text: '取り組んで', reading: 'とりくんで', headwords: [[{ term: '取り組む' }]], }, ], ], }, ]; } return null; }); const errors: string[] = []; const result = await requestYomitanScanTokens('取り組んで', deps, { error: (message) => errors.push(message), }); assert.deepEqual(result, [ { surface: '取り組んで', reading: 'とりくんで', headword: '取り組む', startPos: 0, endPos: 5, }, ]); assert.equal(errors.length, 1); }); test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => { let scriptValue = ''; const deps = createDeps(async (script) => { scriptValue = script; return [{ title: 'SubMiner Character Dictionary (AniList 130298)', revision: '1' }]; }); const dictionaries = await getYomitanDictionaryInfo(deps, { error: () => undefined }); assert.equal(dictionaries.length, 1); assert.equal(dictionaries[0]?.title, 'SubMiner Character Dictionary (AniList 130298)'); assert.match(scriptValue, /getDictionaryInfo/); }); test('dictionary settings helpers upsert and remove dictionary entries without reordering', async () => { const scripts: string[] = []; const optionsFull = { profileCurrent: 0, profiles: [ { options: { dictionaries: [ { name: 'Jitendex', alias: 'Jitendex', enabled: true, }, { name: 'SubMiner Character Dictionary (AniList 1)', alias: 'SubMiner Character Dictionary (AniList 1)', enabled: false, }, ], }, }, ], }; const deps = createDeps(async (script) => { scripts.push(script); if (script.includes('optionsGetFull')) { return structuredClone(optionsFull); } if (script.includes('setAllSettings')) { return true; } return null; }); const title = 'SubMiner Character Dictionary (AniList 1)'; const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, { error: () => undefined, }); const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, { error: () => undefined, }); assert.equal(upserted, true); assert.equal(removed, true); const setCalls = scripts.filter((script) => script.includes('setAllSettings')).length; assert.equal(setCalls, 2); const upsertScript = scripts.find( (script) => script.includes('setAllSettings') && script.includes('"SubMiner Character Dictionary (AniList 1)"'), ); assert.ok(upsertScript); const jitendexOffset = upsertScript?.indexOf('"Jitendex"') ?? -1; const subMinerOffset = upsertScript?.indexOf('"SubMiner Character Dictionary (AniList 1)"') ?? -1; assert.equal(jitendexOffset >= 0, true); assert.equal(subMinerOffset >= 0, true); assert.equal(jitendexOffset < subMinerOffset, true); assert.match(upsertScript ?? '', /"enabled":true/); }); test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-')); const zipPath = path.join(tempDir, 'dict.zip'); fs.writeFileSync(zipPath, Buffer.from('zip-bytes')); const scripts: string[] = []; const servedArchives: string[] = []; const settingsWindow = { isDestroyed: () => false, destroy: () => undefined, webContents: { executeJavaScript: async (script: string) => { scripts.push(script); const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/); if (urlMatch) { const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string); servedArchives.push(await response.text()); } return true; }, }, }; const deps = createDeps(async () => true, { createYomitanExtensionWindow: async (pageName: string) => { assert.equal(pageName, 'settings.html'); return settingsWindow; }, }); const imported = await importYomitanDictionaryFromZip(zipPath, deps, { error: () => undefined, }); assert.equal(imported, true); assert.equal( scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true, ); assert.equal( scripts.some((script) => script.includes('importDictionaryArchiveUrl')), true, ); assert.deepEqual(servedArchives, ['zip-bytes']); assert.equal( scripts.some((script) => script.includes('emlwLWJ5dGVz')), false, ); assert.equal( scripts.some((script) => script.includes('subminerImportDictionary')), false, ); }); test('importYomitanDictionaryFromZip falls back to base64 import for older Yomitan bridge', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-')); const zipPath = path.join(tempDir, 'dict.zip'); fs.writeFileSync(zipPath, Buffer.from('zip-bytes')); const scripts: string[] = []; const settingsWindow = { isDestroyed: () => false, destroy: () => undefined, webContents: { executeJavaScript: async (script: string) => { scripts.push(script); if ( script.includes( 'typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl', ) ) { return false; } return true; }, }, }; const deps = createDeps(async () => true, { createYomitanExtensionWindow: async (pageName: string) => { assert.equal(pageName, 'settings.html'); return settingsWindow; }, }); const imported = await importYomitanDictionaryFromZip(zipPath, deps, { error: () => undefined, }); assert.equal(imported, true); assert.equal( scripts.some((script) => script.includes('importDictionaryArchiveBase64')), true, ); assert.equal( scripts.some((script) => script.includes('importDictionaryArchiveUrl(')), false, ); assert.equal( scripts.some((script) => script.includes('emlwLWJ5dGVz')), true, ); }); test('importYomitanDictionaryFromZip returns false when served archive cannot be read', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-')); const zipPath = path.join(tempDir, 'dict.zip'); fs.writeFileSync(zipPath, Buffer.from('zip-bytes')); const settingsWindow = { isDestroyed: () => false, destroy: () => undefined, webContents: { executeJavaScript: async (script: string) => { const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/); if (!urlMatch) return true; fs.unlinkSync(zipPath); const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string); return response.ok; }, }, }; const deps = createDeps(async () => true, { createYomitanExtensionWindow: async (pageName: string) => { assert.equal(pageName, 'settings.html'); return settingsWindow; }, }); const imported = await importYomitanDictionaryFromZip(zipPath, deps, { error: () => undefined, }); assert.equal(imported, false); }); test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => { const scripts: string[] = []; const settingsWindow = { isDestroyed: () => false, destroy: () => undefined, webContents: { executeJavaScript: async (script: string) => { scripts.push(script); return true; }, }, }; const deps = createDeps(async () => true, { createYomitanExtensionWindow: async (pageName: string) => { assert.equal(pageName, 'settings.html'); return settingsWindow; }, }); const deleted = await deleteYomitanDictionaryByTitle( 'SubMiner Character Dictionary (AniList 130298)', deps, { error: () => undefined }, ); assert.equal(deleted, true); assert.equal( scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true, ); assert.equal( scripts.some((script) => script.includes('deleteDictionary')), true, ); assert.equal( scripts.some((script) => script.includes('subminerDeleteDictionary')), false, ); }); test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge payload', async () => { const deps = createDeps(async (_script) => ({ noteId: 42, duplicateNoteIds: [18, 7, 18], })); const result = await addYomitanNoteViaSearch('食べる', deps, { error: () => undefined, }); assert.deepEqual(result, { noteId: 42, duplicateNoteIds: [18, 7, 18], }); }); test('addYomitanNoteViaSearch rejects invalid numeric note ids from the bridge shortcut', async () => { const deps = createDeps(async () => NaN); const result = await addYomitanNoteViaSearch('食べる', deps, { error: () => undefined, }); assert.deepEqual(result, { noteId: null, duplicateNoteIds: [], }); }); test('addYomitanNoteViaSearch sanitizes invalid payload note ids while keeping valid duplicate ids', async () => { const deps = createDeps(async (_script) => ({ noteId: -1, duplicateNoteIds: [18, 0, 7.5, 7], })); const result = await addYomitanNoteViaSearch('食べる', deps, { error: () => undefined, }); assert.deepEqual(result, { noteId: null, duplicateNoteIds: [18, 7], }); }); test('requestYomitanScanTokens still finds an emphatically elongated name a longer generic match would swallow', async () => { // Yomitan collapses emphatic sequences, so ミナァァト resolves to the ミナト // entry. The generic word とミナ starts earlier and would swallow the name // unless the pre-pass reserves it, so this only passes when the candidate // prefilter still treats the elongated spelling as a possible name start. const deps = 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 []; } const text = (params as { text?: string } | undefined)?.text ?? ''; if (text.startsWith('とミナ')) { return { originalTextLength: 3, dictionaryEntries: [ { headwords: [ { term: 'トミナ', reading: 'とみな', sources: [{ originalText: 'とミナ', isPrimary: true, matchType: 'exact' }], }, ], definitions: [{ dictionary: 'JMdict' }], }, ], }; } if (text.startsWith('ミナァァト')) { return { originalTextLength: 5, dictionaryEntries: [ { headwords: [ { term: 'ミナト', reading: 'みなと', sources: [{ originalText: 'ミナァァト', isPrimary: true, matchType: 'exact' }], }, ], definitions: [{ dictionary: 'SubMiner Character Dictionary (AniList 1)' }], }, ], }; } if (text.startsWith('と')) { return { originalTextLength: 1, dictionaryEntries: [ { headwords: [ { term: 'と', reading: 'と', sources: [{ originalText: 'と', isPrimary: true, matchType: 'exact' }], }, ], definitions: [{ dictionary: 'JMdict' }], }, ], }; } return { originalTextLength: 0, dictionaryEntries: [] }; }); const result = await requestYomitanScanTokens( 'とミナァァト', deps, { error: () => undefined }, { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 1, nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] }, }, ); const nameToken = result?.find((token) => token.isNameMatch === true); assert.ok(nameToken, 'expected the elongated name to be reserved by the pre-pass'); assert.equal(nameToken?.headword, 'ミナト'); assert.equal(nameToken?.startPos, 1); });