mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
- 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
472 lines
14 KiB
TypeScript
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.' },
|
|
},
|
|
]);
|
|
});
|