feat(stats): speed up session maintenance and improve stats UI (#111)

This commit is contained in:
2026-06-08 02:20:52 -07:00
committed by GitHub
parent e6a16a069b
commit 311f1e8ee5
108 changed files with 7441 additions and 729 deletions
+15
View File
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
});
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readMainSource();
const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
const addYomitanNoteBlock = startStatsServerBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { resolveConfig } from '../../config/resolve';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
@@ -10,7 +14,13 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
fields: [],
getConfigPath: () => '/tmp/config.jsonc',
getRawConfig: () => ({}),
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
getConfig: () => ({
...deepCloneConfig(DEFAULT_CONFIG),
ankiConnect: {
...deepCloneConfig(DEFAULT_CONFIG).ankiConnect,
deck: 'Configured',
},
}),
getWarnings: () => [],
reloadConfigStrict: () =>
({
@@ -48,3 +58,62 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
});
test('config settings runtime persists inferred Yomitan Anki deck when config deck is empty', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-'));
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(configPath, '{"ankiConnect":{"deck":""}}\n', 'utf-8');
try {
let rawConfig = { ankiConnect: { deck: '' } };
let resolvedConfig = resolveConfig(rawConfig).resolved;
const runtime = createConfigSettingsRuntime({
fields: [],
getConfigPath: () => configPath,
getRawConfig: () => rawConfig,
getConfig: () => resolvedConfig,
getWarnings: () => [],
reloadConfigStrict: () => {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
resolvedConfig = resolveConfig(rawConfig).resolved;
return {
ok: true,
config: resolvedConfig,
warnings: [],
path: configPath,
};
},
getSettingsWindow: () => null,
setSettingsWindow: () => undefined,
createSettingsWindow: () => ({}) as never,
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () =>
({
deckNames: async () => [],
fieldNamesForDeck: async () => [],
modelNamesForDeck: async () => [],
modelNames: async () => [],
modelFieldNames: async () => [],
}) as never,
getYomitanAnkiDeckName: async () => 'Minecraft',
ipcMain: {
handle: (channel, listener) => {
handlers.set(channel, listener);
},
},
ipcChannels: IPC_CHANNELS.request,
});
runtime.registerHandlers();
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Minecraft' });
assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf-8')).ankiConnect.deck, 'Minecraft');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+26 -1
View File
@@ -193,13 +193,38 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
};
}
function persistInferredYomitanDeckIfEmpty(deckName: string): void {
const normalizedDeckName = deckName.trim();
const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? '';
if (!normalizedDeckName || configuredDeckName) {
return;
}
const result = savePatch({
operations: [
{
op: 'set',
path: 'ankiConnect.deck',
value: normalizedDeckName,
},
],
});
if (!result.ok) {
deps.log?.(
`Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`,
);
}
}
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
if (!deps.getYomitanAnkiDeckName) {
return { ok: true, value: '' };
}
try {
const value = await deps.getYomitanAnkiDeckName();
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
const deckName = typeof value === 'string' ? value.trim() : '';
persistInferredYomitanDeckIfEmpty(deckName);
return { ok: true, value: deckName };
} catch (error) {
return {
ok: false,
@@ -159,6 +159,30 @@ test('mpv subtitle timing handler runs AniList without timing tracker and passes
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
});
test('mpv subtitle timing handler skips invalid cue pairs until timing is complete', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
calls.push(`immersion:${text}:${start}:${end}`),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: (text, start, end) => calls.push(`timing:${text}:${start}:${end}`),
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds}`);
},
logError: () => calls.push('error'),
});
handler({ text: 'line', start: 953.991, end: 953.891 });
handler({ text: 'line', start: 953.991, end: 956.56 });
assert.deepEqual(calls, [
'post-watch:953.991',
'immersion:line:953.991:956.56',
'timing:line:953.991:956.56',
'post-watch:956.56',
]);
});
test('mpv event bindings register all expected events', () => {
const seenEvents: string[] = [];
const bindHandlers = createBindMpvClientEventHandlers({
@@ -72,7 +72,7 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
Number.isFinite(end) ? end : 0,
);
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
if (text.trim()) {
if (text.trim() && Number.isFinite(start) && Number.isFinite(end) && end > start) {
deps.recordImmersionSubtitleLine(text, start, end);
if (deps.hasSubtitleTimingTracker()) {
deps.recordSubtitleTiming(text, start, end);