|
| 1 | +/** |
| 2 | + * Tests for the CLI transcript day-view window helper `utcDayRange`. |
| 3 | + * |
| 4 | + * The `transcript --date <day>` and default "today" CLI views read a single |
| 5 | + * calendar day via {@link TranscriptManager.readRange}, which uses a half-open |
| 6 | + * `[start, end)` interval (`entryTime < end`, CLAUDE.md rule #35). The day end |
| 7 | + * must therefore be the NEXT day's `00:00:00Z`, not `${date}T23:59:59Z`: |
| 8 | + * a literal `23:59:59Z` end (== `.000Z`) silently drops any entry stamped in |
| 9 | + * the final second of the day (`23:59:59.000Z`–`23:59:59.999Z`). |
| 10 | + * |
| 11 | + * (Pre-existing boundary gap flagged as a P2 on PR #1507, fixed out of band.) |
| 12 | + */ |
| 13 | + |
| 14 | +import assert from "node:assert/strict"; |
| 15 | +import { mkdtemp, realpath, rm } from "node:fs/promises"; |
| 16 | +import os from "node:os"; |
| 17 | +import path from "node:path"; |
| 18 | +import test from "node:test"; |
| 19 | + |
| 20 | +import { TranscriptManager, utcDayRange } from "./transcript.js"; |
| 21 | +import type { PluginConfig, TranscriptEntry } from "./types.js"; |
| 22 | + |
| 23 | +function makeConfig(memoryDir: string): PluginConfig { |
| 24 | + // TranscriptManager only reads memoryDir + transcriptSkipChannelTypes. |
| 25 | + return { |
| 26 | + memoryDir, |
| 27 | + transcriptSkipChannelTypes: [], |
| 28 | + } as unknown as PluginConfig; |
| 29 | +} |
| 30 | + |
| 31 | +// On macOS `os.tmpdir()` is a `/var/folders/...` symlink to `/private/var/...`. |
| 32 | +// Canonicalize the test root upfront (issue #691 symlink convention). |
| 33 | +async function makeMemoryDir(): Promise<string> { |
| 34 | + return realpath(await mkdtemp(path.join(os.tmpdir(), "remnic-tx-dayrange-"))); |
| 35 | +} |
| 36 | + |
| 37 | +function entryAt(timestamp: string, turnId: string): TranscriptEntry { |
| 38 | + return { |
| 39 | + sessionKey: "agent:generalist:main", |
| 40 | + turnId, |
| 41 | + role: "user", |
| 42 | + content: `content-${turnId}`, |
| 43 | + timestamp, |
| 44 | + } as TranscriptEntry; |
| 45 | +} |
| 46 | + |
| 47 | +// ── utcDayRange: half-open [start, next-day-00:00:00Z) ─────────────────────── |
| 48 | + |
| 49 | +test("utcDayRange end is the next day's 00:00:00Z (half-open day window)", () => { |
| 50 | + assert.deepEqual(utcDayRange("2025-03-15"), { |
| 51 | + start: "2025-03-15T00:00:00Z", |
| 52 | + end: "2025-03-16T00:00:00Z", |
| 53 | + }); |
| 54 | +}); |
| 55 | + |
| 56 | +test("utcDayRange rolls over month boundaries", () => { |
| 57 | + assert.deepEqual(utcDayRange("2025-01-31"), { |
| 58 | + start: "2025-01-31T00:00:00Z", |
| 59 | + end: "2025-02-01T00:00:00Z", |
| 60 | + }); |
| 61 | +}); |
| 62 | + |
| 63 | +test("utcDayRange rolls over leap-day and year boundaries", () => { |
| 64 | + assert.deepEqual(utcDayRange("2024-02-28"), { |
| 65 | + start: "2024-02-28T00:00:00Z", |
| 66 | + end: "2024-02-29T00:00:00Z", |
| 67 | + }); |
| 68 | + assert.deepEqual(utcDayRange("2025-12-31"), { |
| 69 | + start: "2025-12-31T00:00:00Z", |
| 70 | + end: "2026-01-01T00:00:00Z", |
| 71 | + }); |
| 72 | +}); |
| 73 | + |
| 74 | +// ── Day-view range includes the final second of the day (the bug fix) ──────── |
| 75 | + |
| 76 | +test("day-view range returns an entry stamped at 23:59:59.500Z of the queried day", async () => { |
| 77 | + const memoryDir = await makeMemoryDir(); |
| 78 | + try { |
| 79 | + const tm = new TranscriptManager(makeConfig(memoryDir)); |
| 80 | + const date = "2025-03-15"; |
| 81 | + const channel = "agent:generalist:main"; |
| 82 | + |
| 83 | + // Entry in the final second of the day — dropped by the old |
| 84 | + // `${date}T23:59:59Z` (== `.000Z`) exclusive upper bound. |
| 85 | + await tm.append(entryAt(`${date}T23:59:59.500Z`, "final-second")); |
| 86 | + // Midday control entry, comfortably inside the window. |
| 87 | + await tm.append(entryAt(`${date}T12:00:00.000Z`, "midday")); |
| 88 | + // Start-of-next-day entry must stay OUT — the upper bound is exclusive |
| 89 | + // (`[start, end)`, rule #35), so `nextDate T00:00:00.000Z` is not the |
| 90 | + // queried day. |
| 91 | + await tm.append(entryAt("2025-03-16T00:00:00.000Z", "next-day")); |
| 92 | + |
| 93 | + const { start, end } = utcDayRange(date); |
| 94 | + const entries = await tm.readRange(start, end, channel); |
| 95 | + const ids = entries.map((e) => e.turnId).sort(); |
| 96 | + |
| 97 | + assert.deepEqual(ids, ["final-second", "midday"]); |
| 98 | + } finally { |
| 99 | + await rm(memoryDir, { recursive: true, force: true }); |
| 100 | + } |
| 101 | +}); |
0 commit comments