mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
* refactor(main): build overlay geometry and pointer interaction in the overlay assembly Inject Electron's screen (and the stats-window promotion) into the overlay geometry and visible-overlay interaction runtimes so they load under bun test, then construct both in createOverlayAssembly together with the overlay visibility composer and the mpv subtitle suppression handlers. Replace the three regex tests that pinned the moved Linux input and shape code with behaviour tests on the runtimes. * refactor(main): move the autoplay-ready gate into the playback assembly The autoplay-ready gate, its post-warm subtitle prime, the current-media tokenization gate and managed local subtitle selection all read playback state, and playback signals through the gate, so createPlaybackAssembly now builds them. The overlay reaches the gate through a getter. Replace the last four regex guards with behaviour tests: the gate holds the release until the visible overlay has measured the line and ignores untokenized lines during warmup (playback), cached autoplay priming never paints raw first and the sidebar media path is tagged only after prefetch loads (priming runtime), and dashboard mining overrides the Hachidori Anki server only when the proxy is on (stats server runtime). main-wiring.test.ts is now empty and removed. * refactor(main): move the plugin host, panel and windows into a plugins assembly * refactor(main): move session action dispatch and renderer mpv commands into an assembly createSessionActionsAssembly owns the session action dispatcher, the secondary-subtitle mode cycle, queueing the clipboard's video, and the mpv commands the overlay renderer sends. The runtime option cycle both paths used is now one function. * refactor(main): move Whisper subtitle generation into an assembly * refactor(main): resolve subtitle annotation options in a runtime module The runtime-option-over-config resolution that the texthooker page and the MeCab startup decision share moves out of main.ts into createAnnotationOptions. * test(main): cover a non-boolean runtime annotation option value * docs: describe the plugins, session actions and subtitle generation assemblies Update ownership for overlay geometry and pointer interaction, the autoplay-ready gate, and annotation options; point the overlay priming entry points at their new modules; the main-wiring regex debt is gone. * build: export MpvClientEvents so declaration emit can name the plugins assembly's return type
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
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';
|
|
import { createStatsServerRuntime, type StatsServerRuntimeDeps } from './stats-server-runtime';
|
|
import type { StatsServer } from '../../core/services/stats-server';
|
|
import type { BackgroundStatsServerState } from './stats-daemon';
|
|
|
|
function createDeferred<T>() {
|
|
let settle: ((value: T) => void) | null = null;
|
|
let fail: ((error: unknown) => void) | null = null;
|
|
const promise = new Promise<T>((resolve, reject) => {
|
|
settle = resolve;
|
|
fail = reject;
|
|
});
|
|
return {
|
|
promise,
|
|
resolve(value: T): void {
|
|
if (!settle) throw new Error('deferred promise is unavailable');
|
|
settle(value);
|
|
},
|
|
reject(error: unknown): void {
|
|
if (!fail) throw new Error('deferred promise is unavailable');
|
|
fail(error);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRuntimeHarness(
|
|
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
|
|
backgroundState: BackgroundStatsServerState | null = null,
|
|
overrides: Partial<StatsServerRuntimeDeps> = {},
|
|
) {
|
|
const appStateValues: Array<StatsServer | null> = [];
|
|
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
|
|
after(() => tracker.destroy());
|
|
const runtime = createStatsServerRuntime({
|
|
userDataPath: '/tmp/subminer-stats-runtime-test',
|
|
statsDistPath: '/tmp/stats-dist',
|
|
getResolvedConfig: () => ({
|
|
...DEFAULT_CONFIG,
|
|
stats: { ...DEFAULT_CONFIG.stats, serverPort: 5175 },
|
|
}),
|
|
getImmersionTracker: () => tracker,
|
|
setAppStateStatsServer: (server) => {
|
|
appStateValues.push(server);
|
|
},
|
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
getYomitanExt: () => null,
|
|
getYomitanSession: () => null,
|
|
getYomitanParserWindow: () => null,
|
|
setYomitanParserWindow: () => {},
|
|
getYomitanParserReadyPromise: () => null,
|
|
setYomitanParserReadyPromise: () => {},
|
|
getYomitanParserInitPromise: () => null,
|
|
setYomitanParserInitPromise: () => {},
|
|
getYomitanAnkiDeckName: async () => 'Mining',
|
|
getAnilistRateLimiter: () => createAnilistRateLimiter(),
|
|
resolveAnkiNoteId: (noteId) => noteId,
|
|
trackDuplicateNoteIdsForNote: () => {},
|
|
resolveSentenceSearchHeadwords: async () => [],
|
|
ensureImmersionTrackerStarted: () => {},
|
|
setStatsStartupInProgress: () => {},
|
|
readBackgroundStatsServerState: () => backgroundState,
|
|
removeBackgroundStatsServerState: () => {},
|
|
isBackgroundStatsServerProcessAlive: () => false,
|
|
startServer,
|
|
...overrides,
|
|
});
|
|
return { runtime, appStateValues };
|
|
}
|
|
|
|
/** Mines a word from the stats dashboard; returns the Anki settings sent to Hachidori. */
|
|
async function mineDashboardWord(
|
|
ankiConnect: Partial<(typeof DEFAULT_CONFIG)['ankiConnect']>,
|
|
): Promise<Record<string, unknown>> {
|
|
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, ...ankiConnect },
|
|
}),
|
|
},
|
|
);
|
|
await runtime.ensureStatsServerStarted();
|
|
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
|
|
await runtime.stopStatsServer();
|
|
const sent = settings[0];
|
|
assert.ok(sent && typeof sent === 'object');
|
|
return sent as Record<string, unknown>;
|
|
}
|
|
|
|
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
|
|
const sent = await mineDashboardWord({
|
|
enabled: true,
|
|
url: 'http://127.0.0.1:8765',
|
|
proxy: { ...DEFAULT_CONFIG.ankiConnect.proxy, enabled: true, host: '127.0.0.1', port: 8766 },
|
|
});
|
|
|
|
assert.equal(sent.server, 'http://127.0.0.1:8766');
|
|
assert.equal(sent.forceOverride, true);
|
|
});
|
|
|
|
// Without the proxy SubMiner does not own the endpoint, so a server the user set in
|
|
// Yomitan/Hachidori must survive dashboard mining.
|
|
test('dashboard word mining without the proxy does not override the Hachidori Anki server', async () => {
|
|
const sent = await mineDashboardWord({
|
|
enabled: true,
|
|
url: 'http://127.0.0.1:8765',
|
|
proxy: { ...DEFAULT_CONFIG.ankiConnect.proxy, enabled: false },
|
|
});
|
|
|
|
assert.equal(sent.server, 'http://127.0.0.1:8765');
|
|
assert.equal(sent.forceOverride, false);
|
|
});
|
|
|
|
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
|
const calls: string[] = [];
|
|
const runtime = createStatsServerRuntime({
|
|
userDataPath: '/tmp/subminer-stats-runtime-test',
|
|
statsDistPath: '/tmp/stats-dist',
|
|
getResolvedConfig: () => ({ stats: { serverPort: 5175 } }) as never,
|
|
getImmersionTracker: () => null,
|
|
setAppStateStatsServer: () => {},
|
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
getYomitanExt: () => null,
|
|
getYomitanSession: () => null,
|
|
getYomitanParserWindow: () => null,
|
|
setYomitanParserWindow: () => {},
|
|
getYomitanParserReadyPromise: () => null,
|
|
setYomitanParserReadyPromise: () => {},
|
|
getYomitanParserInitPromise: () => null,
|
|
setYomitanParserInitPromise: () => {},
|
|
getYomitanAnkiDeckName: async () => 'Mining',
|
|
getAnilistRateLimiter: () => ({}) as never,
|
|
resolveAnkiNoteId: (noteId) => noteId,
|
|
trackDuplicateNoteIdsForNote: () => {},
|
|
resolveSentenceSearchHeadwords: async () => [],
|
|
ensureImmersionTrackerStarted: () => {},
|
|
setStatsStartupInProgress: () => {},
|
|
readBackgroundStatsServerState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
|
|
removeBackgroundStatsServerState: () => {
|
|
calls.push('removeBackgroundStatsServerState');
|
|
},
|
|
isBackgroundStatsServerProcessAlive: () => true,
|
|
verifyBackgroundStatsServerIdentity: () => false,
|
|
killProcess: () => {
|
|
calls.push('killProcess');
|
|
},
|
|
});
|
|
|
|
const result = await runtime.stopBackgroundStatsServer();
|
|
|
|
assert.deepEqual(result, { ok: true, stale: true });
|
|
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
|
|
});
|
|
|
|
test('concurrent stats startup requests share one pending server', async () => {
|
|
const deferred = createDeferred<StatsServer>();
|
|
let startCalls = 0;
|
|
const server: StatsServer = { close: async () => {} };
|
|
const { runtime, appStateValues } = createRuntimeHarness(() => {
|
|
startCalls += 1;
|
|
return deferred.promise;
|
|
});
|
|
|
|
const first = runtime.ensureStatsServerStarted();
|
|
const second = runtime.ensureStatsServerStarted();
|
|
assert.equal(startCalls, 1);
|
|
assert.deepEqual(appStateValues, []);
|
|
|
|
deferred.resolve(server);
|
|
assert.deepEqual(await Promise.all([first, second]), [
|
|
{ url: 'http://127.0.0.1:5175', source: 'local' },
|
|
{ url: 'http://127.0.0.1:5175', source: 'local' },
|
|
]);
|
|
assert.deepEqual(appStateValues, [server]);
|
|
});
|
|
|
|
test('failed stats startup remains recoverable on the next request', async () => {
|
|
const first = createDeferred<StatsServer>();
|
|
const second = createDeferred<StatsServer>();
|
|
const attempts = [first, second];
|
|
let startCalls = 0;
|
|
const server: StatsServer = { close: async () => {} };
|
|
const { runtime, appStateValues } = createRuntimeHarness(() => {
|
|
const attempt = attempts[startCalls];
|
|
startCalls += 1;
|
|
if (!attempt) throw new Error('unexpected startup attempt');
|
|
return attempt.promise;
|
|
});
|
|
|
|
const failedStartup = runtime.ensureStatsServerStarted();
|
|
first.reject(Object.assign(new Error('address in use'), { code: 'EADDRINUSE' }));
|
|
await assert.rejects(failedStartup, /address in use/);
|
|
|
|
const retry = runtime.ensureStatsServerStarted();
|
|
second.resolve(server);
|
|
assert.deepEqual(await retry, { url: 'http://127.0.0.1:5175', source: 'local' });
|
|
assert.equal(startCalls, 2);
|
|
assert.deepEqual(appStateValues, [null, server]);
|
|
});
|
|
|
|
test('shutdown cancels pending startup and closes the late server', async () => {
|
|
const deferred = createDeferred<StatsServer>();
|
|
let closeCalls = 0;
|
|
const server: StatsServer = {
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
};
|
|
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
|
|
|
|
const startup = runtime.ensureStatsServerStarted();
|
|
const shutdown = runtime.stopStatsServer();
|
|
deferred.resolve(server);
|
|
|
|
await assert.rejects(startup, /startup was cancelled/);
|
|
await shutdown;
|
|
assert.equal(closeCalls, 1);
|
|
assert.deepEqual(appStateValues, [null, null]);
|
|
});
|
|
|
|
test('stopping a self-owned background server closes its local handle', async () => {
|
|
let closeCalls = 0;
|
|
const server: StatsServer = {
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
};
|
|
const { runtime } = createRuntimeHarness(async () => server, {
|
|
pid: process.pid,
|
|
port: 5175,
|
|
startedAtMs: 1,
|
|
});
|
|
await runtime.ensureStatsServerStarted();
|
|
|
|
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: false });
|
|
assert.equal(closeCalls, 1);
|
|
});
|
|
|
|
test('background stop leaves a foreground-only server available', async () => {
|
|
let closeCalls = 0;
|
|
let startCalls = 0;
|
|
const { runtime } = createRuntimeHarness(async () => {
|
|
startCalls += 1;
|
|
return {
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
};
|
|
});
|
|
const foreground = await runtime.ensureStatsServerStarted();
|
|
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
|
|
assert.equal(closeCalls, 0);
|
|
assert.deepEqual(await runtime.ensureStatsServerStarted(), foreground);
|
|
assert.equal(startCalls, 1);
|
|
await runtime.stopStatsServer();
|
|
});
|
|
|
|
test('background stop leaves a pending foreground-only startup alone', async () => {
|
|
const deferred = createDeferred<StatsServer>();
|
|
const { runtime } = createRuntimeHarness(() => deferred.promise);
|
|
const startup = runtime.ensureStatsServerStarted();
|
|
assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true });
|
|
deferred.resolve({ close: async () => {} });
|
|
assert.deepEqual(await startup, { url: 'http://127.0.0.1:5175', source: 'local' });
|
|
await runtime.stopStatsServer();
|
|
});
|
|
|
|
test('a startup requested during shutdown waits and then restarts', async () => {
|
|
const closeDeferred = createDeferred<void>();
|
|
const firstServer: StatsServer = { close: () => closeDeferred.promise };
|
|
const secondServer: StatsServer = { close: async () => {} };
|
|
const servers = [firstServer, secondServer];
|
|
let startCalls = 0;
|
|
const { runtime } = createRuntimeHarness(async () => {
|
|
const server = servers[startCalls];
|
|
startCalls += 1;
|
|
if (!server) throw new Error('unexpected startup attempt');
|
|
return server;
|
|
});
|
|
await runtime.ensureStatsServerStarted();
|
|
|
|
const shutdown = runtime.stopStatsServer();
|
|
const restart = runtime.ensureStatsServerStarted();
|
|
assert.equal(startCalls, 1);
|
|
|
|
closeDeferred.resolve();
|
|
await shutdown;
|
|
assert.deepEqual(await restart, { url: 'http://127.0.0.1:5175', source: 'local' });
|
|
assert.equal(startCalls, 2);
|
|
});
|
|
|
|
test('background stop cancels startup before daemon ownership is published', async () => {
|
|
const deferred = createDeferred<StatsServer>();
|
|
let closeCalls = 0;
|
|
const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise);
|
|
const startup = runtime.ensureBackgroundStatsServerStarted();
|
|
const shutdown = runtime.stopBackgroundStatsServer();
|
|
deferred.resolve({
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
});
|
|
await assert.rejects(startup, /startup was cancelled/);
|
|
assert.deepEqual(await shutdown, { ok: true, stale: false });
|
|
assert.equal(closeCalls, 1);
|
|
assert.equal(appStateValues.at(-1), null);
|
|
});
|