feat: stabilize startup sync and overlay/runtime paths

This commit is contained in:
2026-03-17 00:48:55 -07:00
parent de574c04bd
commit 11710f20db
69 changed files with 5323 additions and 495 deletions

View File

@@ -1,5 +1,8 @@
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(
@@ -114,3 +117,245 @@ test('stats cli command runs vocab cleanup instead of opening dashboard when cle
},
]);
});
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.' },
},
]);
});