mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat: stabilize startup sync and overlay/runtime paths
This commit is contained in:
@@ -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.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user