mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Several suites asserted local-time renders from UTC instants, or built date fixtures from the real clock, so they only held where local time happened to match. CI runs in UTC and never reported it; a contributor anywhere else saw failures on a clean checkout. Seven files, anchored to the clock under test rather than to the machine's. The set grew twice while being fixed: the two tests the issue named surfaced three more at UTC+9, and those surfaced two more at UTC+14. `StatusCards/format` is the interesting one. `rollupUpdatesToday` filters on the UTC calendar day to match the server token cap, while the fixtures were built on the local day. West of UTC that lands `iso(0)` in the previous UTC day for the stretch between UTC midnight and local midnight - about seven hours a day at UTC-7 - and east of UTC+12 "today at local noon" is already yesterday in UTC outright. Either way the rows it means to count drop out. A run crossing midnight UTC splits the same way. Fixes #11476. Deliberately left: IssueProperties.test.tsx:1515-1517 still pin the minute of three timestamps against a UTC fixture. They pass at every offset tried, including UTC+5:45, and the minute there is load-bearing - it distinguishes Created from Started from Completed - so it wants more care than mechanical anchoring. Full ui suite 4113 pass. The TZ pin added by #11480 is still in place here and is now redundant; #11508 removes it, stacked on this branch so it cannot land without the anchoring it depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.8 KiB
TypeScript
142 lines
5.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { StatusCardUpdate } from "@paperclipai/shared";
|
|
|
|
import {
|
|
estimateStatusCardCost,
|
|
rollupUpdates,
|
|
rollupUpdatesToday,
|
|
} from "./format";
|
|
|
|
function update(overrides: Partial<StatusCardUpdate>): StatusCardUpdate {
|
|
return {
|
|
id: "00000000-0000-0000-0000-000000000000",
|
|
cardId: "00000000-0000-0000-0000-000000000001",
|
|
kind: "full",
|
|
trigger: "manual",
|
|
generationIssueId: null,
|
|
runId: null,
|
|
changes: [],
|
|
inputTokens: 1000,
|
|
outputTokens: 500,
|
|
costCents: 2,
|
|
model: null,
|
|
queryVersion: 1,
|
|
changeSummary: null,
|
|
startedAt: NOW.toISOString(),
|
|
finishedAt: null,
|
|
status: "ok",
|
|
error: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// A fixed instant rather than the real clock, so these fixtures mean the same
|
|
// thing on every machine and at every hour. `rollupUpdatesToday` filters on the
|
|
// *UTC* day boundary, and building from `new Date()` fails against that in two
|
|
// separate ways — see `iso` below. The function takes `now` for exactly this
|
|
// reason, and the sibling test at the bottom of this file already passes one.
|
|
const NOW = new Date("2026-07-23T12:00:00.000Z");
|
|
|
|
function iso(daysAgo: number): string {
|
|
const d = new Date(NOW);
|
|
d.setUTCDate(d.getUTCDate() - daysAgo);
|
|
// Noon UTC, so a row is unambiguously inside the UTC day it belongs to.
|
|
//
|
|
// Built on the *local* day instead, `iso(0)` misses in both directions. West
|
|
// of UTC it lands in the previous UTC day for the stretch between UTC
|
|
// midnight and local midnight — about seven hours a day at UTC-7 — and east
|
|
// of UTC+12 "today at local noon" is already yesterday in UTC outright.
|
|
// Either way today's rows drop out of a filter that means to count them. A
|
|
// run crossing midnight UTC splits the same way, with `iso(0)` and the
|
|
// function's default `now` landing on different days.
|
|
d.setUTCHours(12, 0, 0, 0);
|
|
return d.toISOString();
|
|
}
|
|
|
|
describe("rollupUpdates (lifetime)", () => {
|
|
it("sums the whole ledger and excludes compile rows from the update count", () => {
|
|
const rollup = rollupUpdates([
|
|
update({ kind: "full", inputTokens: 3000, outputTokens: 1000, costCents: 3, startedAt: iso(0) }),
|
|
update({ kind: "compile", inputTokens: 500, outputTokens: 100, costCents: 1, startedAt: iso(0) }),
|
|
update({ kind: "incremental", inputTokens: 1000, outputTokens: 200, costCents: 2, startedAt: iso(5) }),
|
|
]);
|
|
// full + incremental = 2 updates (compile excluded from count)
|
|
expect(rollup.updateCount).toBe(2);
|
|
// tokens/cost still include the compile spend
|
|
expect(rollup.totalTokens).toBe(3000 + 1000 + 500 + 100 + 1000 + 200);
|
|
expect(rollup.totalCostCents).toBe(3 + 1 + 2);
|
|
});
|
|
});
|
|
|
|
describe("rollupUpdatesToday", () => {
|
|
it("only counts updates started today and drops older ledger rows", () => {
|
|
const rollup = rollupUpdatesToday([
|
|
update({ kind: "full", inputTokens: 2000, outputTokens: 600, costCents: 3, startedAt: iso(0) }),
|
|
update({ kind: "compile", inputTokens: 400, outputTokens: 100, costCents: 1, startedAt: iso(0) }),
|
|
// yesterday + last week — must not be counted as "today"
|
|
update({ kind: "full", inputTokens: 9999, outputTokens: 9999, costCents: 99, startedAt: iso(1) }),
|
|
update({ kind: "incremental", inputTokens: 9999, outputTokens: 9999, costCents: 99, startedAt: iso(7) }),
|
|
], NOW);
|
|
// Only today's full rebuild counts as an update (compile excluded).
|
|
expect(rollup.updateCount).toBe(1);
|
|
// Today's tokens/cost include today's compile but not older days.
|
|
expect(rollup.totalTokens).toBe(2000 + 600 + 400 + 100);
|
|
expect(rollup.totalCostCents).toBe(3 + 1);
|
|
});
|
|
|
|
it("uses the UTC day boundary used by the server token cap", () => {
|
|
const now = new Date("2026-07-23T01:00:00.000Z");
|
|
const rollup = rollupUpdatesToday([
|
|
update({ startedAt: "2026-07-23T00:30:00.000Z", inputTokens: 200, outputTokens: 50 }),
|
|
update({ startedAt: "2026-07-22T23:30:00.000Z", inputTokens: 900, outputTokens: 100 }),
|
|
], now);
|
|
|
|
expect(rollup.updateCount).toBe(1);
|
|
expect(rollup.totalTokens).toBe(250);
|
|
});
|
|
});
|
|
|
|
describe("estimateStatusCardCost", () => {
|
|
it("manual mode = a single per-refresh rebuild", () => {
|
|
const est = estimateStatusCardCost({ mode: "manual", triggers: {} as never });
|
|
expect(est.primary).toContain("per refresh");
|
|
expect(est.note).toMatch(/only cost/i);
|
|
});
|
|
|
|
it("interval mode scales with the interval and reacts to the daily token cap", () => {
|
|
const uncapped = estimateStatusCardCost({ mode: "interval", intervalMinutes: 60, triggers: {} as never });
|
|
// 24h / 60min = 24 updates/day upper bound
|
|
expect(uncapped.primary).toContain("Up to ~24 updates/day");
|
|
expect(uncapped.primary).toContain("every 60 min");
|
|
|
|
const capped = estimateStatusCardCost({
|
|
mode: "interval",
|
|
intervalMinutes: 60,
|
|
dailyTokenCap: 10_000,
|
|
triggers: {} as never,
|
|
});
|
|
// 10_000 / 2_000 est tokens = 5 updates before the cap bites
|
|
expect(capped.primary).toContain("Up to ~5 updates/day");
|
|
expect(capped.note).toMatch(/daily token cap/i);
|
|
});
|
|
|
|
it("reactive mode reports the per-hour ceiling", () => {
|
|
const est = estimateStatusCardCost({ mode: "reactive", maxUpdatesPerHour: 4, debounceSeconds: 60, triggers: {} as never });
|
|
expect(est.primary).toContain("up to 4/hour");
|
|
// 24h * 4/hr = 96 updates/day upper bound
|
|
expect(est.primary).toContain("Up to ~96 updates/day");
|
|
});
|
|
|
|
it("interval mode honours the active-hours window", () => {
|
|
const est = estimateStatusCardCost({
|
|
mode: "interval",
|
|
intervalMinutes: 60,
|
|
activeHours: { start: "08:00", end: "20:00", timezone: "UTC" },
|
|
triggers: {} as never,
|
|
});
|
|
// 12h window / 60min = 12 updates/day
|
|
expect(est.primary).toContain("Up to ~12 updates/day");
|
|
expect(est.primary).toContain("during active hours");
|
|
});
|
|
});
|