mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
133 lines
6.1 KiB
TypeScript
133 lines
6.1 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { runInNewContext } from 'node:vm';
|
|
import test from 'node:test';
|
|
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
|
|
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
|
|
|
const bridge = readFileSync(
|
|
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
function run(code: string) {
|
|
runInNewContext(`${bridge}\n${code}`, {
|
|
window: new EventTarget(),
|
|
EventTarget,
|
|
CustomEvent,
|
|
assert,
|
|
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
|
|
KeyboardEvent: class {
|
|
constructor(_type: string, options: KeyboardEventInit = {}) {
|
|
Object.assign(this, options);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
test('Hachidori publishes popup state independently from successful lookups', () => {
|
|
run(`
|
|
const events = [];
|
|
for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) {
|
|
window.addEventListener(name, () => events.push(name));
|
|
}
|
|
const attributes = new Map();
|
|
const host = { setAttribute: (name, value) => attributes.set(name, value) };
|
|
SubMinerHachidori.attention(host, true);
|
|
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
|
|
assert.equal(events.join(','), 'yomitan-popup-shown');
|
|
SubMinerHachidori.lookup();
|
|
SubMinerHachidori.attention(host, false);
|
|
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
|
|
assert.equal(events.join(','), 'yomitan-popup-shown,subminer-yomitan-lookup,yomitan-popup-hidden');
|
|
`);
|
|
});
|
|
|
|
test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => {
|
|
run(`
|
|
const result = (id, matched, dictionary, options = {}) => ({
|
|
matched, deinflected: matched, trace: [], preprocessorSteps: 0,
|
|
term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id,
|
|
});
|
|
const character = characterPrefix + ' - Current show';
|
|
const longer = result('longer', '花子さん', 'General');
|
|
const normal = result('normal', '花子', 'General');
|
|
const person = result('person', '花子', character);
|
|
const shorter = result('shorter', '花', character);
|
|
const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]);
|
|
assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter');
|
|
const merged = result('merged', '花子', 'General');
|
|
merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' });
|
|
const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]);
|
|
assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general');
|
|
assert.equal(merged.term.glossaries[0].dictionary, 'General');
|
|
for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) {
|
|
const transformed = { ...person, ...change };
|
|
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal');
|
|
}
|
|
const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } };
|
|
assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal');
|
|
const aliased = result('aliased', '花子', 'Imported character data');
|
|
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased');
|
|
`);
|
|
});
|
|
|
|
test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => {
|
|
const requests: Array<{ url: string; body: string }> = [];
|
|
const proxy = 'http://127.0.0.1:8766';
|
|
const gateway = createAnkiGateway({
|
|
readSubminerProxyUrl: async () => proxy,
|
|
fetch: async (url: string, options: RequestInit) => {
|
|
assert.equal(typeof options.body, 'string');
|
|
requests.push({ url, body: String(options.body) });
|
|
return Response.json({ result: 123, error: null });
|
|
},
|
|
});
|
|
const params = {
|
|
note: { fields: { Expression: '花子' } },
|
|
subminerDuplicateNoteIds: [456],
|
|
subminerEnrich: true,
|
|
};
|
|
await gateway.invoke('addNote', params, '', 1000, proxy);
|
|
await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765');
|
|
assert.deepEqual(
|
|
requests.map((request) => JSON.parse(request.body).params),
|
|
[params, { note: params.note }],
|
|
);
|
|
assert.equal(params.subminerEnrich, true);
|
|
const direct = createAnkiGateway({
|
|
readSubminerProxyUrl: async () => null,
|
|
fetch: async (_url: string, options: RequestInit) => {
|
|
assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note });
|
|
return Response.json({ result: null, error: null });
|
|
},
|
|
});
|
|
await direct.invoke('updateNoteFields', params, '', 1000, proxy);
|
|
});
|
|
|
|
test('Hachidori routes host commands, validates keyboard input and disconnects', () => {
|
|
run(`
|
|
const calls = [];
|
|
const disconnect = SubMinerHachidori.connect({
|
|
hide: () => calls.push('hide'), clear: () => calls.push('clear'),
|
|
action: name => calls.push(name), cycleAudio: direction => calls.push(direction),
|
|
scroll: (x, y) => calls.push(x + ':' + y),
|
|
keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey),
|
|
});
|
|
const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail}));
|
|
send(null);
|
|
send({ type: 'forwardKeyDown', key: 12, modifiers: [] });
|
|
send({ type: 'mineSelected' });
|
|
send({ type: 'playCurrentAudio' });
|
|
send({ type: 'scanSelectedText' });
|
|
send({ type: 'cycleAudioSource', direction: -1 });
|
|
send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 });
|
|
send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] });
|
|
send({ type: 'setVisible', visible: false });
|
|
send({ type: 'clearActiveTextSource' });
|
|
disconnect();
|
|
send({ type: 'mineSelected' });
|
|
assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear');
|
|
`);
|
|
});
|