Files
SubMiner/src/main/runtime/stats-cli-command.test.ts
sudacode f2d6c70019 Fix stats command flow and tracking metrics regressions
- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop`
- Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword
- Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling
- Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
2026-03-19 15:46:52 -07:00

472 lines
14 KiB
TypeScript

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 { createRunStatsCliCommandHandler } from './stats-cli-command';
function makeHandler(
overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {},
) {
const calls: string[] = [];
const responses: Array<{
responsePath: string;
payload: { ok: boolean; url?: string; error?: string };
}> = [];
const handler = createRunStatsCliCommandHandler({
getResolvedConfig: () => ({
immersionTracking: { enabled: true },
stats: { serverPort: 6969 },
}),
ensureImmersionTrackerStarted: () => {
calls.push('ensureImmersionTrackerStarted');
},
getImmersionTracker: () => ({ cleanupVocabularyStats: undefined }),
ensureStatsServerStarted: () => {
calls.push('ensureStatsServerStarted');
return 'http://127.0.0.1:6969';
},
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:6969',
runningInCurrentProcess: true,
}),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
openExternal: async (url) => {
calls.push(`openExternal:${url}`);
},
writeResponse: (responsePath, payload) => {
responses.push({ responsePath, payload });
},
exitAppWithCode: (code) => {
calls.push(`exitAppWithCode:${code}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
},
logWarn: (message) => {
calls.push(`warn:${message}`);
},
logError: (message, error) => {
calls.push(`error:${message}:${error instanceof Error ? error.message : String(error)}`);
},
...overrides,
});
return { handler, calls, responses };
}
test('stats cli command starts tracker, server, browser, and writes success response', async () => {
const { handler, calls, responses } = makeHandler();
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'ensureStatsServerStarted',
'openExternal:http://127.0.0.1:6969',
'info:Stats dashboard available at http://127.0.0.1:6969',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command respects stats.autoOpenBrowser=false', async () => {
const { handler, calls, responses } = makeHandler({
getResolvedConfig: () => ({
immersionTracking: { enabled: true },
stats: { serverPort: 6969, autoOpenBrowser: false },
}),
});
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'ensureStatsServerStarted',
'info:Stats dashboard available at http://127.0.0.1:6969',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command starts background daemon without opening browser', async () => {
const { handler, calls, responses } = makeHandler({
ensureBackgroundStatsServerStarted: () => {
calls.push('ensureBackgroundStatsServerStarted');
return { url: 'http://127.0.0.1:6969', runningInCurrentProcess: true };
},
} as never);
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsBackground: true,
} as never,
'initial',
);
assert.deepEqual(calls, [
'ensureBackgroundStatsServerStarted',
'info:Stats dashboard available at http://127.0.0.1:6969',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command exits helper app when background daemon is already running elsewhere', async () => {
const { handler, calls, responses } = makeHandler({
ensureBackgroundStatsServerStarted: () => {
calls.push('ensureBackgroundStatsServerStarted');
return { url: 'http://127.0.0.1:6969', runningInCurrentProcess: false };
},
} as never);
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsBackground: true,
} as never,
'initial',
);
assert.ok(calls.includes('exitAppWithCode:0'));
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:6969' },
},
]);
});
test('stats cli command stops background daemon and treats stale state as success', async () => {
const { handler, calls, responses } = makeHandler({
stopBackgroundStatsServer: async () => {
calls.push('stopBackgroundStatsServer');
return { ok: true, stale: true };
},
} as never);
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsStop: true,
} as never,
'initial',
);
assert.deepEqual(calls, [
'stopBackgroundStatsServer',
'info:Background stats server is not running; cleaned stale state.',
'exitAppWithCode:0',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
test('stats cli command fails when immersion tracking is disabled', async () => {
const { handler, calls, responses } = makeHandler({
getResolvedConfig: () => ({
immersionTracking: { enabled: false },
stats: { serverPort: 6969 },
}),
});
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
assert.equal(calls.includes('ensureImmersionTrackerStarted'), false);
assert.ok(calls.includes('exitAppWithCode:1'));
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: false, error: 'Immersion tracking is disabled in config.' },
},
]);
});
test('stats cli command runs vocab cleanup instead of opening dashboard when cleanup mode is requested', async () => {
const { handler, calls, responses } = makeHandler({
getImmersionTracker: () => ({
cleanupVocabularyStats: async () => ({ scanned: 3, kept: 1, deleted: 2, repaired: 1 }),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupVocab: true,
},
'initial',
);
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats vocabulary cleanup complete: scanned=3 kept=1 deleted=2 repaired=1',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requested', async () => {
const { handler, calls, responses } = makeHandler({
ensureVocabularyCleanupTokenizerReady: async () => {
calls.push('ensureVocabularyCleanupTokenizerReady');
},
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
}),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupLifetime: true,
},
'initial',
);
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
async function waitForPendingAnimeMetadata(
tracker: import('../../core/services/immersion-tracker-service').ImmersionTrackerService,
): Promise<void> {
const privateApi = tracker as unknown as {
sessionState: { videoId: number } | null;
pendingAnimeMetadataUpdates?: Map<number, Promise<void>>;
};
const videoId = privateApi.sessionState?.videoId;
if (!videoId) return;
await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
}
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
const dbPath = makeDbPath();
let tracker:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
let tracker2:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
let tracker3:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
const { ImmersionTrackerService } = await import('../../core/services/immersion-tracker-service');
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
try {
tracker = new ImmersionTrackerService({ dbPath });
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.recordCardsMined(2);
tracker.recordSubtitleLine('first line', 0, 1);
tracker.destroy();
tracker = null;
tracker2 = new ImmersionTrackerService({ dbPath });
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
await waitForPendingAnimeMetadata(tracker2);
tracker2.recordCardsMined(1);
tracker2.recordSubtitleLine('second line', 0, 1);
tracker2.destroy();
tracker2 = null;
const beforeDb = new Database(dbPath);
const expectedGlobal = beforeDb
.prepare(
`
SELECT total_sessions, total_cards, episodes_started, active_days
FROM imm_lifetime_global
`,
)
.get() as {
total_sessions: number;
total_cards: number;
episodes_started: number;
active_days: number;
} | null;
const expectedAnimeRows = (
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime').get() as {
total: number;
}
).total;
const expectedMediaRows = (
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media').get() as {
total: number;
}
).total;
const expectedAppliedSessions = (
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions').get() as {
total: number;
}
).total;
beforeDb.exec(`
DELETE FROM imm_lifetime_anime;
DELETE FROM imm_lifetime_media;
DELETE FROM imm_lifetime_applied_sessions;
UPDATE imm_lifetime_global
SET total_sessions = 999,
total_cards = 999,
episodes_started = 999,
active_days = 999
WHERE global_id = 1;
`);
beforeDb.close();
tracker3 = new ImmersionTrackerService({ dbPath });
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
const rebuiltDb = new Database(dbPath);
const rebuiltGlobal = rebuiltDb
.prepare(
`
SELECT total_sessions, total_cards, episodes_started, active_days
FROM imm_lifetime_global
`,
)
.get() as {
total_sessions: number;
total_cards: number;
episodes_started: number;
active_days: number;
} | null;
const rebuiltAnimeRows = (
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime').get() as {
total: number;
}
).total;
const rebuiltMediaRows = (
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media').get() as {
total: number;
}
).total;
const rebuiltAppliedSessions = (
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions').get() as {
total: number;
}
).total;
rebuiltDb.close();
assert.ok(rebuiltGlobal);
assert.ok(expectedGlobal);
assert.equal(rebuiltGlobal?.total_sessions, expectedGlobal?.total_sessions);
assert.equal(rebuiltGlobal?.total_cards, expectedGlobal?.total_cards);
assert.equal(rebuiltGlobal?.episodes_started, expectedGlobal?.episodes_started);
assert.equal(rebuiltGlobal?.active_days, expectedGlobal?.active_days);
assert.equal(rebuiltAnimeRows, expectedAnimeRows);
assert.equal(rebuiltMediaRows, expectedMediaRows);
assert.equal(rebuiltAppliedSessions, expectedAppliedSessions);
assert.equal(firstRebuild.appliedSessions, expectedAppliedSessions);
assert.equal(secondRebuild.appliedSessions, firstRebuild.appliedSessions);
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
} finally {
tracker?.destroy();
tracker2?.destroy();
tracker3?.destroy();
cleanupDbPath(dbPath);
}
});
test('stats cli command runs lifetime rebuild when requested', async () => {
const { handler, calls, responses } = makeHandler({
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
}),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupLifetime: true,
},
'initial',
);
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
test('stats cli command rejects cleanup calls without exactly one cleanup mode', async () => {
const { handler, calls, responses } = makeHandler({
getImmersionTracker: () => ({
cleanupVocabularyStats: async () => ({ scanned: 1, kept: 1, deleted: 0, repaired: 0 }),
rebuildLifetimeSummaries: async () => ({ appliedSessions: 0, rebuiltAtMs: 0 }),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupVocab: true,
statsCleanupLifetime: true,
},
'initial',
);
assert.ok(calls.includes('error:Stats command failed:Choose exactly one stats cleanup mode.'));
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: false, error: 'Choose exactly one stats cleanup mode.' },
},
]);
});