mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user