Files
SubMiner/src/core/services/tokenizer/yomitan-parser-runtime.test.ts
T

2903 lines
82 KiB
TypeScript

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);
});