From d2f9a91d395c9fc395119d35c25547c69a95142a Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 14 Aug 2026 01:15:17 -0700 Subject: [PATCH] test(stats): organize lifetime summary repair tests - Extract shared lifetime test fixtures - Move repair scenarios into a dedicated test file --- .../__tests__/lifetime-delete.test.ts | 252 +----------------- .../__tests__/lifetime-repair.test.ts | 94 +++++++ .../__tests__/lifetime-test-fixtures.ts | 157 +++++++++++ 3 files changed, 264 insertions(+), 239 deletions(-) create mode 100644 src/core/services/immersion-tracker/__tests__/lifetime-repair.test.ts create mode 100644 src/core/services/immersion-tracker/__tests__/lifetime-test-fixtures.ts diff --git a/src/core/services/immersion-tracker/__tests__/lifetime-delete.test.ts b/src/core/services/immersion-tracker/__tests__/lifetime-delete.test.ts index 158681ee..456e9de8 100644 --- a/src/core/services/immersion-tracker/__tests__/lifetime-delete.test.ts +++ b/src/core/services/immersion-tracker/__tests__/lifetime-delete.test.ts @@ -1,168 +1,21 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { Database } from '../sqlite.js'; -import type { DatabaseSync } from '../sqlite.js'; -import { - applyPragmas, - ensureSchema, - getOrCreateAnimeRecord, - getOrCreateVideoRecord, - linkVideoToAnimeRecord, -} from '../storage.js'; import { startSessionRecord } from '../session.js'; -import { - applySessionLifetimeSummary, - rebuildLifetimeSummaries, - repairLifetimeSummariesFromMedia, -} from '../lifetime.js'; +import { applySessionLifetimeSummary, rebuildLifetimeSummaries } from '../lifetime.js'; import { deleteMaintenanceBatch } from '../query-delete-maintenance.js'; import { toDbTimestamp } from '../query-shared.js'; - -const SOURCE_TYPE_LOCAL = 1; -const DAY_MS = 86_400_000; -// Noon UTC keeps every seeded timestamp on the same local day regardless of -// the timezone the test host runs in. -const BASE_MS = Date.UTC(2026, 0, 5, 12, 0, 0); - -function createDb(): DatabaseSync { - const db = new Database(':memory:'); - applyPragmas(db); - ensureSchema(db); - return db; -} - -function seedAnime(db: DatabaseSync, title: string, episodesTotal: number | null): number { - const animeId = getOrCreateAnimeRecord(db, { - parsedTitle: title, - canonicalTitle: title, - anilistId: null, - titleRomaji: null, - titleEnglish: null, - titleNative: null, - metadataJson: null, - }); - if (episodesTotal !== null) { - db.prepare('UPDATE imm_anime SET episodes_total = ? WHERE anime_id = ?').run( - episodesTotal, - animeId, - ); - } - return animeId; -} - -function seedVideo( - db: DatabaseSync, - animeId: number | null, - name: string, - options: { watched?: boolean } = {}, -): number { - const videoId = getOrCreateVideoRecord(db, `local:/tmp/${name}.mkv`, { - canonicalTitle: name, - sourcePath: `/tmp/${name}.mkv`, - sourceUrl: null, - sourceType: SOURCE_TYPE_LOCAL, - }); - if (animeId !== null) { - linkVideoToAnimeRecord(db, videoId, { - animeId, - parsedBasename: `${name}.mkv`, - parsedTitle: name, - parsedSeason: 1, - parsedEpisode: 1, - parserSource: 'test', - parserConfidence: 1, - parseMetadataJson: null, - }); - } - if (options.watched) { - db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId); - } - return videoId; -} - -function seedEndedSession( - db: DatabaseSync, - videoId: number, - startedAtMs: number, - metrics: { activeMs: number; cards?: number; lines?: number; tokens?: number }, -): number { - const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; - db.prepare( - ` - UPDATE imm_sessions SET - ended_at_ms = ?, - active_watched_ms = ?, - total_watched_ms = ?, - cards_mined = ?, - lines_seen = ?, - tokens_seen = ? - WHERE session_id = ? - `, - ).run( - toDbTimestamp(startedAtMs + metrics.activeMs), - metrics.activeMs, - metrics.activeMs, - metrics.cards ?? 0, - metrics.lines ?? 0, - metrics.tokens ?? 0, - sessionId, - ); - return sessionId; -} - -// libsql attaches a per-query `_metadata` property to result rows; strip it so -// row snapshots can be compared with deepEqual. -function cleanRow(row: unknown): T { - const { _metadata: _ignored, ...rest } = row as Record; - return rest as T; -} - -interface GlobalSnapshot { - total_sessions: number; - total_active_ms: number; - total_cards: number; - active_days: number; - episodes_started: number; - episodes_completed: number; - anime_completed: number; -} - -function snapshotGlobal(db: DatabaseSync): GlobalSnapshot { - const row = db - .prepare( - `SELECT total_sessions, total_active_ms, total_cards, active_days, - episodes_started, episodes_completed, anime_completed - FROM imm_lifetime_global WHERE global_id = 1`, - ) - .get(); - return cleanRow(row); -} - -function snapshotMedia(db: DatabaseSync): unknown[] { - return db - .prepare( - `SELECT video_id, total_sessions, total_active_ms, total_cards, - total_lines_seen, total_tokens_seen, completed, - CAST(first_watched_ms AS REAL) AS first_watched, - CAST(last_watched_ms AS REAL) AS last_watched - FROM imm_lifetime_media ORDER BY video_id`, - ) - .all() - .map((row) => cleanRow(row)); -} - -function snapshotAnime(db: DatabaseSync): unknown[] { - return db - .prepare( - `SELECT anime_id, total_sessions, total_active_ms, total_cards, - total_lines_seen, total_tokens_seen, episodes_started, episodes_completed, - CAST(first_watched_ms AS REAL) AS first_watched, - CAST(last_watched_ms AS REAL) AS last_watched - FROM imm_lifetime_anime ORDER BY anime_id`, - ) - .all() - .map((row) => cleanRow(row)); -} +import { + BASE_MS, + DAY_MS, + cleanRow, + createDb, + seedAnime, + seedEndedSession, + seedVideo, + snapshotAnime, + snapshotGlobal, + snapshotMedia, +} from './lifetime-test-fixtures.js'; test('fractional lifetime metrics stay normalized across apply, rebuild, and delete', () => { const db = createDb(); @@ -448,82 +301,3 @@ test('deleting a video updates anime and global rollups without a rebuild', () = db.close(); } }); - -test('repair after a video moves between anime matches a full rebuild', () => { - const db = createDb(); - try { - const animeA = seedAnime(db, 'Move Source', 2); - const animeB = seedAnime(db, 'Move Target', 2); - const movedVideo = seedVideo(db, animeA, 'moved-ep', { watched: true }); - const stayingVideo = seedVideo(db, animeA, 'staying-ep'); - const targetVideo = seedVideo(db, animeB, 'target-ep', { watched: true }); - seedEndedSession(db, movedVideo, BASE_MS, { activeMs: 60_000, cards: 2 }); - seedEndedSession(db, stayingVideo, BASE_MS + DAY_MS, { activeMs: 30_000 }); - seedEndedSession(db, targetVideo, BASE_MS + 2 * DAY_MS, { activeMs: 45_000, cards: 1 }); - rebuildLifetimeSummaries(db); - - // Simulate a library merge reassigning the episode to the other anime. - db.prepare('UPDATE imm_videos SET anime_id = ? WHERE video_id = ?').run(animeB, movedVideo); - repairLifetimeSummariesFromMedia(db); - - const repairedGlobal = snapshotGlobal(db); - const repairedMedia = snapshotMedia(db); - const repairedAnime = snapshotAnime(db); - - rebuildLifetimeSummaries(db); - assert.deepEqual(repairedGlobal, snapshotGlobal(db)); - assert.deepEqual(repairedMedia, snapshotMedia(db)); - assert.deepEqual(repairedAnime, snapshotAnime(db)); - } finally { - db.close(); - } -}); - -test('repair preserves lifetime history from pruned sessions where a rebuild would not', () => { - const db = createDb(); - try { - const animeId = seedAnime(db, 'Repair Anime', null); - const videoId = seedVideo(db, animeId, 'repair-ep'); - const prunedSessionId = seedEndedSession(db, videoId, BASE_MS, { - activeMs: 90_000, - cards: 3, - }); - seedEndedSession(db, videoId, BASE_MS + DAY_MS, { activeMs: 30_000, cards: 1 }); - rebuildLifetimeSummaries(db); - - db.prepare('DELETE FROM imm_sessions WHERE session_id = ?').run(prunedSessionId); - repairLifetimeSummariesFromMedia(db); - - const globalRow = snapshotGlobal(db); - assert.equal(globalRow.total_sessions, 2, 'repair keeps the pruned session contribution'); - assert.equal(globalRow.total_active_ms, 120_000); - assert.equal(globalRow.total_cards, 4); - assert.equal(globalRow.active_days, 2, 'repair never subtracts active days'); - - const animeRow = db - .prepare('SELECT total_sessions FROM imm_lifetime_anime WHERE anime_id = ?') - .get(animeId); - assert.equal(cleanRow<{ total_sessions: number }>(animeRow).total_sessions, 2); - } finally { - db.close(); - } -}); - -test('repair leaves a caller-owned transaction intact when its begin fails', () => { - const db = createDb(); - try { - db.exec('BEGIN'); - const animeId = seedAnime(db, 'Caller Transaction', null); - - assert.throws(() => repairLifetimeSummariesFromMedia(db), /transaction/i); - assert.ok( - db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId), - 'the repair did not roll back the caller transaction', - ); - - db.exec('ROLLBACK'); - assert.equal(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId), undefined); - } finally { - db.close(); - } -}); diff --git a/src/core/services/immersion-tracker/__tests__/lifetime-repair.test.ts b/src/core/services/immersion-tracker/__tests__/lifetime-repair.test.ts new file mode 100644 index 00000000..4dd17f66 --- /dev/null +++ b/src/core/services/immersion-tracker/__tests__/lifetime-repair.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { rebuildLifetimeSummaries, repairLifetimeSummariesFromMedia } from '../lifetime.js'; +import { + BASE_MS, + DAY_MS, + cleanRow, + createDb, + seedAnime, + seedEndedSession, + seedVideo, + snapshotAnime, + snapshotGlobal, + snapshotMedia, +} from './lifetime-test-fixtures.js'; + +test('repair after a video moves between anime matches a full rebuild', () => { + const db = createDb(); + try { + const animeA = seedAnime(db, 'Move Source', 2); + const animeB = seedAnime(db, 'Move Target', 2); + const movedVideo = seedVideo(db, animeA, 'moved-ep', { watched: true }); + const stayingVideo = seedVideo(db, animeA, 'staying-ep'); + const targetVideo = seedVideo(db, animeB, 'target-ep', { watched: true }); + seedEndedSession(db, movedVideo, BASE_MS, { activeMs: 60_000, cards: 2 }); + seedEndedSession(db, stayingVideo, BASE_MS + DAY_MS, { activeMs: 30_000 }); + seedEndedSession(db, targetVideo, BASE_MS + 2 * DAY_MS, { activeMs: 45_000, cards: 1 }); + rebuildLifetimeSummaries(db); + + // Simulate a library merge reassigning the episode to the other anime. + db.prepare('UPDATE imm_videos SET anime_id = ? WHERE video_id = ?').run(animeB, movedVideo); + repairLifetimeSummariesFromMedia(db); + + const repairedGlobal = snapshotGlobal(db); + const repairedMedia = snapshotMedia(db); + const repairedAnime = snapshotAnime(db); + + rebuildLifetimeSummaries(db); + assert.deepEqual(repairedGlobal, snapshotGlobal(db)); + assert.deepEqual(repairedMedia, snapshotMedia(db)); + assert.deepEqual(repairedAnime, snapshotAnime(db)); + } finally { + db.close(); + } +}); + +test('repair preserves lifetime history from pruned sessions where a rebuild would not', () => { + const db = createDb(); + try { + const animeId = seedAnime(db, 'Repair Anime', null); + const videoId = seedVideo(db, animeId, 'repair-ep'); + const prunedSessionId = seedEndedSession(db, videoId, BASE_MS, { + activeMs: 90_000, + cards: 3, + }); + seedEndedSession(db, videoId, BASE_MS + DAY_MS, { activeMs: 30_000, cards: 1 }); + rebuildLifetimeSummaries(db); + + db.prepare('DELETE FROM imm_sessions WHERE session_id = ?').run(prunedSessionId); + repairLifetimeSummariesFromMedia(db); + + const globalRow = snapshotGlobal(db); + assert.equal(globalRow.total_sessions, 2, 'repair keeps the pruned session contribution'); + assert.equal(globalRow.total_active_ms, 120_000); + assert.equal(globalRow.total_cards, 4); + assert.equal(globalRow.active_days, 2, 'repair never subtracts active days'); + + const animeRow = db + .prepare('SELECT total_sessions FROM imm_lifetime_anime WHERE anime_id = ?') + .get(animeId); + assert.equal(cleanRow<{ total_sessions: number }>(animeRow).total_sessions, 2); + } finally { + db.close(); + } +}); + +test('repair leaves a caller-owned transaction intact when its begin fails', () => { + const db = createDb(); + try { + db.exec('BEGIN'); + const animeId = seedAnime(db, 'Caller Transaction', null); + + assert.throws(() => repairLifetimeSummariesFromMedia(db), /transaction/i); + assert.ok( + db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId), + 'the repair did not roll back the caller transaction', + ); + + db.exec('ROLLBACK'); + assert.equal(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId), undefined); + } finally { + db.close(); + } +}); diff --git a/src/core/services/immersion-tracker/__tests__/lifetime-test-fixtures.ts b/src/core/services/immersion-tracker/__tests__/lifetime-test-fixtures.ts new file mode 100644 index 00000000..067f6e9d --- /dev/null +++ b/src/core/services/immersion-tracker/__tests__/lifetime-test-fixtures.ts @@ -0,0 +1,157 @@ +import { Database } from '../sqlite.js'; +import type { DatabaseSync } from '../sqlite.js'; +import { + applyPragmas, + ensureSchema, + getOrCreateAnimeRecord, + getOrCreateVideoRecord, + linkVideoToAnimeRecord, +} from '../storage.js'; +import { startSessionRecord } from '../session.js'; +import { toDbTimestamp } from '../query-shared.js'; + +const SOURCE_TYPE_LOCAL = 1; +export const DAY_MS = 86_400_000; +// Noon UTC keeps every seeded timestamp on the same local day regardless of +// the timezone the test host runs in. +export const BASE_MS = Date.UTC(2026, 0, 5, 12, 0, 0); + +export function createDb(): DatabaseSync { + const db = new Database(':memory:'); + applyPragmas(db); + ensureSchema(db); + return db; +} + +export function seedAnime(db: DatabaseSync, title: string, episodesTotal: number | null): number { + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: title, + canonicalTitle: title, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + if (episodesTotal !== null) { + db.prepare('UPDATE imm_anime SET episodes_total = ? WHERE anime_id = ?').run( + episodesTotal, + animeId, + ); + } + return animeId; +} + +export function seedVideo( + db: DatabaseSync, + animeId: number | null, + name: string, + options: { watched?: boolean } = {}, +): number { + const videoId = getOrCreateVideoRecord(db, `local:/tmp/${name}.mkv`, { + canonicalTitle: name, + sourcePath: `/tmp/${name}.mkv`, + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + if (animeId !== null) { + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: `${name}.mkv`, + parsedTitle: name, + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + } + if (options.watched) { + db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId); + } + return videoId; +} + +export function seedEndedSession( + db: DatabaseSync, + videoId: number, + startedAtMs: number, + metrics: { activeMs: number; cards?: number; lines?: number; tokens?: number }, +): number { + const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; + db.prepare( + ` + UPDATE imm_sessions SET + ended_at_ms = ?, + active_watched_ms = ?, + total_watched_ms = ?, + cards_mined = ?, + lines_seen = ?, + tokens_seen = ? + WHERE session_id = ? + `, + ).run( + toDbTimestamp(startedAtMs + metrics.activeMs), + metrics.activeMs, + metrics.activeMs, + metrics.cards ?? 0, + metrics.lines ?? 0, + metrics.tokens ?? 0, + sessionId, + ); + return sessionId; +} + +// libsql attaches a per-query `_metadata` property to result rows; strip it so +// row snapshots can be compared with deepEqual. +export function cleanRow(row: unknown): T { + const { _metadata: _ignored, ...rest } = row as Record; + return rest as T; +} + +export interface GlobalSnapshot { + total_sessions: number; + total_active_ms: number; + total_cards: number; + active_days: number; + episodes_started: number; + episodes_completed: number; + anime_completed: number; +} + +export function snapshotGlobal(db: DatabaseSync): GlobalSnapshot { + const row = db + .prepare( + `SELECT total_sessions, total_active_ms, total_cards, active_days, + episodes_started, episodes_completed, anime_completed + FROM imm_lifetime_global WHERE global_id = 1`, + ) + .get(); + return cleanRow(row); +} + +export function snapshotMedia(db: DatabaseSync): unknown[] { + return db + .prepare( + `SELECT video_id, total_sessions, total_active_ms, total_cards, + total_lines_seen, total_tokens_seen, completed, + CAST(first_watched_ms AS REAL) AS first_watched, + CAST(last_watched_ms AS REAL) AS last_watched + FROM imm_lifetime_media ORDER BY video_id`, + ) + .all() + .map((row) => cleanRow(row)); +} + +export function snapshotAnime(db: DatabaseSync): unknown[] { + return db + .prepare( + `SELECT anime_id, total_sessions, total_active_ms, total_cards, + total_lines_seen, total_tokens_seen, episodes_started, episodes_completed, + CAST(first_watched_ms AS REAL) AS first_watched, + CAST(last_watched_ms AS REAL) AS last_watched + FROM imm_lifetime_anime ORDER BY anime_id`, + ) + .all() + .map((row) => cleanRow(row)); +}