mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
fix(dictionary): complete Hachidori imports and mining integration
This commit is contained in:
@@ -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'), '{}');
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user