Factor out mock date helper in tracker tests

- reuse a shared `withMockDate` helper for date-sensitive query tests
- make monthly rollup assertions key off `videoId` instead of row order
This commit is contained in:
2026-03-28 00:26:19 -07:00
parent 615625d215
commit efcacded66

View File

@@ -81,6 +81,34 @@ function cleanupDbPath(dbPath: string): void {
} }
} }
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
const realDate = Date;
const fixedDateMs = fixedDate.getTime();
type MockDateArgs = [any, any, any, any, any, any, any];
class MockDate extends Date {
constructor(...args: MockDateArgs) {
if (args.length === 0) {
super(fixedDateMs);
} else {
super(...args);
}
}
static override now(): number {
return fixedDateMs;
}
}
globalThis.Date = MockDate as DateConstructor;
try {
return run(realDate);
} finally {
globalThis.Date = realDate;
}
}
test('getSessionSummaries returns sessionId and canonicalTitle', () => { test('getSessionSummaries returns sessionId and canonicalTitle', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -790,127 +818,110 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
const RealDate = Date; withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => {
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
canonicalTitle: 'Monthly Trends',
sourcePath: '/tmp/feb-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const marVideoId = getOrCreateVideoRecord(db, 'local:/tmp/mar-trends.mkv', {
canonicalTitle: 'Monthly Trends',
sourcePath: '/tmp/mar-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Monthly Trends',
canonicalTitle: 'Monthly Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, febVideoId, {
animeId,
parsedBasename: 'feb-trends.mkv',
parsedTitle: 'Monthly Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, marVideoId, {
animeId,
parsedBasename: 'mar-trends.mkv',
parsedTitle: 'Monthly Trends',
parsedSeason: 1,
parsedEpisode: 2,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
class MockDate extends Date { const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
constructor(...args: any[]) { const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
type MockDateArgs = [any, any, any, any, any, any, any]; const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
if (args.length === 0) { const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
super(new RealDate(2026, 2, 1, 12, 0, 0).getTime());
} else { for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
super(...(args as MockDateArgs)); [febSessionId, febStartedAtMs, 100, 2, 3],
[marSessionId, marStartedAtMs, 120, 4, 5],
] as const) {
stmts.telemetryInsertStmt.run(
sessionId,
startedAtMs + 60_000,
30 * 60_000,
30 * 60_000,
4,
tokensSeen,
cardsMined,
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
0,
0,
0,
0,
startedAtMs + 60_000,
startedAtMs + 60_000,
);
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
status = 2,
total_watched_ms = ?,
active_watched_ms = ?,
lines_seen = ?,
tokens_seen = ?,
cards_mined = ?,
lookup_count = ?,
lookup_hits = ?,
yomitan_lookup_count = ?,
LAST_UPDATE_DATE = ?
WHERE session_id = ?
`,
).run(
startedAtMs + 60_000,
30 * 60_000,
30 * 60_000,
4,
tokensSeen,
cardsMined,
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
startedAtMs + 60_000,
sessionId,
);
} }
}
static override now(): number {
return new RealDate(2026, 2, 1, 12, 0, 0).getTime();
}
}
try {
globalThis.Date = MockDate as DateConstructor;
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
canonicalTitle: 'Monthly Trends',
sourcePath: '/tmp/feb-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const marVideoId = getOrCreateVideoRecord(db, 'local:/tmp/mar-trends.mkv', {
canonicalTitle: 'Monthly Trends',
sourcePath: '/tmp/mar-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Monthly Trends',
canonicalTitle: 'Monthly Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, febVideoId, {
animeId,
parsedBasename: 'feb-trends.mkv',
parsedTitle: 'Monthly Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, marVideoId, {
animeId,
parsedBasename: 'mar-trends.mkv',
parsedTitle: 'Monthly Trends',
parsedSeason: 1,
parsedEpisode: 2,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
[febSessionId, febStartedAtMs, 100, 2, 3],
[marSessionId, marStartedAtMs, 120, 4, 5],
] as const) {
stmts.telemetryInsertStmt.run(
sessionId,
startedAtMs + 60_000,
30 * 60_000,
30 * 60_000,
4,
tokensSeen,
cardsMined,
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
0,
0,
0,
0,
startedAtMs + 60_000,
startedAtMs + 60_000,
);
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
status = 2,
total_watched_ms = ?,
active_watched_ms = ?,
lines_seen = ?,
tokens_seen = ?,
cards_mined = ?,
lookup_count = ?,
lookup_hits = ?,
yomitan_lookup_count = ?,
LAST_UPDATE_DATE = ?
WHERE session_id = ?
`,
).run(
startedAtMs + 60_000,
30 * 60_000,
30 * 60_000,
4,
tokensSeen,
cardsMined,
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
startedAtMs + 60_000,
sessionId,
);
}
const insertDailyRollup = db.prepare( const insertDailyRollup = db.prepare(
` `
@@ -987,11 +998,11 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
dashboard.progress.lookups.map((point) => point.label), dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label), dashboard.activity.watchTime.map((point) => point.label),
); );
} finally { } finally {
globalThis.Date = RealDate; db.close();
db.close(); cleanupDbPath(dbPath);
cleanupDbPath(dbPath); }
} });
}); });
test('getQueryHints reads all-time totals from lifetime summary', () => { test('getQueryHints reads all-time totals from lifetime summary', () => {
@@ -1067,72 +1078,56 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => { test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
const RealDate = Date;
class MockDate extends Date { withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => {
constructor(...args: any[]) { try {
type MockDateArgs = [any, any, any, any, any, any, any]; ensureSchema(db);
if (args.length === 0) {
super(new RealDate(2026, 2, 15, 12, 0, 0).getTime()); const insertWord = db.prepare(
} else { `
super(...(args as MockDateArgs)); INSERT INTO imm_words (
} headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const justBeforeWeekBoundary = Math.floor(
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
);
const justAfterWeekBoundary = Math.floor(
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
);
insertWord.run(
'境界前',
'境界前',
'きょうかいまえ',
'noun',
'名詞',
'',
'',
justBeforeWeekBoundary,
justBeforeWeekBoundary,
1,
);
insertWord.run(
'境界後',
'境界後',
'きょうかいご',
'noun',
'名詞',
'',
'',
justAfterWeekBoundary,
justAfterWeekBoundary,
1,
);
const hints = getQueryHints(db);
assert.equal(hints.newWordsThisWeek, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
} }
});
static override now(): number {
return new RealDate(2026, 2, 15, 12, 0, 0).getTime();
}
}
try {
globalThis.Date = MockDate as DateConstructor;
ensureSchema(db);
const insertWord = db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const justBeforeWeekBoundary = Math.floor(
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
);
const justAfterWeekBoundary = Math.floor(
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
);
insertWord.run(
'境界前',
'境界前',
'きょうかいまえ',
'noun',
'名詞',
'',
'',
justBeforeWeekBoundary,
justBeforeWeekBoundary,
1,
);
insertWord.run(
'境界後',
'境界後',
'きょうかいご',
'noun',
'名詞',
'',
'',
justAfterWeekBoundary,
justAfterWeekBoundary,
1,
);
const hints = getQueryHints(db);
assert.equal(hints.newWordsThisWeek, 1);
} finally {
globalThis.Date = RealDate;
db.close();
cleanupDbPath(dbPath);
}
}); });
test('getQueryHints counts new words by distinct headword first-seen time', () => { test('getQueryHints counts new words by distinct headword first-seen time', () => {
@@ -1522,11 +1517,12 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
const rows = getMonthlyRollups(db, 1); const rows = getMonthlyRollups(db, 1);
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
assert.equal(rows[1]?.cardsPerHour, 30); const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row]));
assert.equal(rows[1]?.tokensPerMin, 3); assert.equal(rowsByVideoId.get(2)?.cardsPerHour, 30);
assert.equal(rows[1]?.lookupHitRate ?? null, null); assert.equal(rowsByVideoId.get(2)?.tokensPerMin, 3);
assert.equal(rows[0]?.cardsPerHour ?? null, null); assert.equal(rowsByVideoId.get(2)?.lookupHitRate ?? null, null);
assert.equal(rows[0]?.tokensPerMin ?? null, null); assert.equal(rowsByVideoId.get(1)?.cardsPerHour ?? null, null);
assert.equal(rowsByVideoId.get(1)?.tokensPerMin ?? null, null);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);