test(stats): organize lifetime summary repair tests

- Extract shared lifetime test fixtures
- Move repair scenarios into a dedicated test file
This commit is contained in:
2026-08-14 01:15:17 -07:00
parent 7cc9f72fc9
commit d2f9a91d39
3 changed files with 264 additions and 239 deletions
@@ -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<T>(row: unknown): T {
const { _metadata: _ignored, ...rest } = row as Record<string, unknown>;
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<GlobalSnapshot>(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();
}
});
@@ -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();
}
});
@@ -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<T>(row: unknown): T {
const { _metadata: _ignored, ...rest } = row as Record<string, unknown>;
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<GlobalSnapshot>(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));
}