fix(dictionary): complete Hachidori imports and mining integration

This commit is contained in:
2026-09-22 15:01:23 -07:00
parent 15c74ef502
commit ab5e50adac
44 changed files with 756 additions and 40 deletions
@@ -31,6 +31,45 @@ function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void
return { promise, resolve };
}
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
for (const succeeds of [true, false]) {
let imported = false;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: makeTempDir(),
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async () => ({
zipPath: '/tmp/replacement.zip',
revision: 'new',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
}),
getYomitanDictionaryInfo: async () => [
{ title: 'SubMiner Character Dictionary', revision: 'old' },
],
dictionaryImportReplacesExisting: () => true,
importYomitanDictionary: async () => {
imported = true;
return succeeds;
},
deleteYomitanDictionary: async () => {
assert.fail('must keep the old dictionary until replacement succeeds');
},
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
if (succeeds) await runtime.runSyncNow();
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
assert.equal(imported, true);
}
});
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
const userDataPath = makeTempDir();
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
@@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
waitForYomitanMutationReady?: () => Promise<void>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
dictionaryImportReplacesExisting?: () => boolean;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
@@ -669,7 +670,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
const importTimeoutMs = resolveImportTimeoutMs(
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
);
if (existing !== null) {
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
await withTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
@@ -815,6 +815,36 @@ test('switching to Hachidori requires its own dictionaries and persists backend
});
});
test('reopening setup for legacy plugin cleanup preserves both backend completions', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
});
await yomitan.ensureSetupStateInitialized();
const reopened = await yomitan.markSetupInProgress();
assert.equal(reopened.state.status, 'in_progress');
assert.deepEqual(reopened.state.completedDictionaryBackends, ['yomitan']);
assert.equal(yomitan.isSetupCompleted(), false);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const switched = await hachidori.ensureSetupStateInitialized();
assert.deepEqual(switched.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
const restored = await yomitan.getSetupStatus();
assert.equal(restored.state.status, 'completed');
});
});
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
+5 -2
View File
@@ -336,7 +336,6 @@ export function createFirstRunSetupService(deps: {
// incomplete until its own dictionaries are ready.
const projectState = (stored: SetupState): SetupState => {
const backend = getDictionaryBackend();
if (getSetupStateDictionaryBackend(stored) === backend) return stored;
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
// Legacy files carry their completion only as the recorded status; keep it.
const storedBackend = getSetupStateDictionaryBackend(stored);
@@ -346,6 +345,7 @@ export function createFirstRunSetupService(deps: {
...(stored.status === 'completed' ? [storedBackend] : []),
]),
];
if (storedBackend === backend) return { ...stored, completedDictionaryBackends };
return {
...stored,
dictionaryBackend: backend,
@@ -364,7 +364,10 @@ export function createFirstRunSetupService(deps: {
state = {
...state,
dictionaryBackend: backend,
completedDictionaryBackends: state.status === 'completed' ? [...others, backend] : others,
completedDictionaryBackends:
state.status === 'completed'
? [...others, backend]
: (state.completedDictionaryBackends ?? []),
};
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import * as vm from 'node:vm';
import { createDeps } from '../../core/services/tokenizer/yomitan-scan-test-harness';
import { DEFAULT_CONFIG } from '../../config';
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
@@ -34,6 +36,7 @@ function createDeferred<T>() {
function createRuntimeHarness(
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
backgroundState: BackgroundStatsServerState | null = null,
overrides: Partial<StatsServerRuntimeDeps> = {},
) {
const appStateValues: Array<StatsServer | null> = [];
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
@@ -69,10 +72,54 @@ function createRuntimeHarness(
removeBackgroundStatsServerState: () => {},
isBackgroundStatsServerProcessAlive: () => false,
startServer,
...overrides,
});
return { runtime, appStateValues };
}
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
const settings: unknown[] = [];
const context = vm.createContext({
chrome: {},
__subminerSyncAnkiSettings: async (value: unknown) => {
settings.push(value);
return { updated: true, matched: true };
},
__subminerAddNote: async () => ({ noteId: 123, duplicateNoteIds: [] }),
});
vm.runInContext('window = globalThis', context);
const configs: Parameters<NonNullable<StatsServerRuntimeDeps['startServer']>>[0][] = [];
const { runtime } = createRuntimeHarness(
async (config) => {
configs.push(config);
return { close: async () => {} };
},
null,
{
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
getResolvedConfig: () => ({
...DEFAULT_CONFIG,
ankiConnect: {
...DEFAULT_CONFIG.ankiConnect,
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: {
...DEFAULT_CONFIG.ankiConnect.proxy,
enabled: true,
host: '127.0.0.1',
port: 8766,
},
},
}),
},
);
await runtime.ensureStatsServerStarted();
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
await runtime.stopStatsServer();
});
test('detects self-owned background stats daemon state', () => {
assert.equal(
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
+5 -2
View File
@@ -18,7 +18,10 @@ import {
writeBackgroundStatsServerState,
} from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
export function isSelfOwnedBackgroundStatsDaemonState(state: {
pid: number;
@@ -168,7 +171,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
const ankiUrl = getPreferredYomitanAnkiServerUrl(ankiConnectConfig);
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,