From da80ece9f7b386654cf4e4a470fb15311eee86ec Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 10 Aug 2026 23:50:37 -0700 Subject: [PATCH] test(stats): move write-queue drain test to its own file - Extract the mergeAnime/moveVideoToAnime queue-drain test into immersion-tracker-write-queue.test.ts - Factor shared setup (seedTwoEntries, queueSubtitleLines, countLinesForAnime) into helpers and add a moveVideoToAnime coverage case --- .../immersion-tracker-write-queue.test.ts | 126 ++++++++++++++++++ .../immersion-tracker-service.test.ts | 57 -------- 2 files changed, 126 insertions(+), 57 deletions(-) create mode 100644 src/core/services/__tests__/immersion-tracker-write-queue.test.ts diff --git a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts new file mode 100644 index 00000000..46869f99 --- /dev/null +++ b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts @@ -0,0 +1,126 @@ +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 type { DatabaseSync } from '../immersion-tracker/sqlite'; + +type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService; +type ImmersionTrackerServiceCtor = + typeof import('../immersion-tracker-service').ImmersionTrackerService; + +let trackerCtor: ImmersionTrackerServiceCtor | null = null; + +async function loadTrackerCtor(): Promise { + if (trackerCtor) return trackerCtor; + const mod = await import('../immersion-tracker-service'); + trackerCtor = mod.ImmersionTrackerService; + return trackerCtor; +} + +function makeDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-')); + return path.join(dir, 'immersion.sqlite'); +} + +function cleanupDbPath(dbPath: string): void { + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) return; + fs.rmSync(dir, { recursive: true, force: true }); +} + +interface TrackerInternals { + db: DatabaseSync; + queue: unknown[]; + recordWrite: (write: Record) => void; + mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise; + moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise; +} + +function seedTwoEntries(db: DatabaseSync): void { + db.exec(` + INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000); + INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000), + (2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000); + INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000); + `); +} + +function queueSubtitleLines(tracker: TrackerInternals, count: number): void { + for (let index = 0; index < count; index += 1) { + tracker.recordWrite({ + kind: 'subtitleLine', + sessionId: 1, + videoId: 2, + lineIndex: index, + segmentStartMs: index * 1000, + segmentEndMs: index * 1000 + 900, + text: `line ${index}`, + wordOccurrences: [], + kanjiOccurrences: [], + firstSeen: 1000, + lastSeen: 2000, + }); + } +} + +function countLinesForAnime(db: DatabaseSync, animeId: number): number { + const row = db + .prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?') + .get(animeId) as { total: number }; + return Number(row.total); +} + +/** + * Both entry points rebuild the lifetime summaries, which recompute from the + * database. A single flushNow() only writes one batch off the front of the + * queue, so anything past `batchSize` would still be unwritten when the rebuild + * reads. + */ +test('mergeAnime drains a queue larger than one batch before rebuilding summaries', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); + const internals = tracker as unknown as TrackerInternals; + + seedTwoEntries(internals.db); + queueSubtitleLines(internals, 8); + assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch'); + + await internals.mergeAnime(1, [2]); + + assert.equal(internals.queue.length, 0); + assert.equal(countLinesForAnime(internals.db, 1), 8); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('moveVideoToAnime drains a queue larger than one batch before rebuilding summaries', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); + const internals = tracker as unknown as TrackerInternals; + + seedTwoEntries(internals.db); + queueSubtitleLines(internals, 8); + + await internals.moveVideoToAnime(2, 1); + + assert.equal(internals.queue.length, 0); + assert.equal(countLinesForAnime(internals.db, 1), 8); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index a40936e6..f7dbf77d 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -2498,63 +2498,6 @@ test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan } }); -test('mergeAnime drains a queue larger than one batch before rebuilding summaries', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - - try { - const Ctor = await loadTrackerCtor(); - // A batch size well below the queued write count: one flushNow() pass would - // leave the forced telemetry sample, appended last, unwritten. - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const privateApi = tracker as unknown as { - db: DatabaseSync; - queue: unknown[]; - mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise; - }; - - privateApi.db.exec(` - INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000); - INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000), - (2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000); - INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000); - `); - - for (let index = 0; index < 8; index += 1) { - (tracker as unknown as { recordWrite: (write: Record) => void }).recordWrite( - { - kind: 'subtitleLine', - sessionId: 1, - videoId: 2, - lineIndex: index, - segmentStartMs: index * 1000, - segmentEndMs: index * 1000 + 900, - text: `line ${index}`, - wordOccurrences: [], - kanjiOccurrences: [], - firstSeen: 1000, - lastSeen: 2000, - }, - ); - } - assert.ok(privateApi.queue.length > 2, 'expected more queued writes than one batch'); - - await privateApi.mergeAnime(1, [2]); - - assert.equal(privateApi.queue.length, 0); - const lines = privateApi.db - .prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1') - .get() as { total: number }; - assert.equal(Number(lines.total), 8); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - test('applies configurable queue, flush, and retention policy', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null;