From 0bba754f628ba95be31b6cb8979f212a5e73e8c9 Mon Sep 17 00:00:00 2001 From: ogunf Date: Thu, 30 Jul 2026 12:46:56 +0000 Subject: [PATCH] test(web): add deterministic Drand countdown boundary tests Extract a pure computeCountdown helper that accepts target round, current time, genesis, and period, making the countdown calculation deterministic and testable. Reuse it in both localCountdown (offline quicknet fallback) and the live chain info path inside useDrandCountdown. Tests cover one second before R, exactly at R, after R, published rounds, future rounds, and formatCountdown boundary cases (seconds/minutes/hours). Closes #174 --- apps/web/package.json | 2 +- apps/web/src/hooks/useDrandCountdown.test.ts | 158 +++++++++++++++++++ apps/web/src/hooks/useDrandCountdown.ts | 36 +++-- 3 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/hooks/useDrandCountdown.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 7e9bf9d..c928e94 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx", + "test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/hooks/useDrandCountdown.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/apps/web/src/hooks/useDrandCountdown.test.ts b/apps/web/src/hooks/useDrandCountdown.test.ts new file mode 100644 index 0000000..a218747 --- /dev/null +++ b/apps/web/src/hooks/useDrandCountdown.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { computeCountdown, formatCountdown } from "./useDrandCountdown"; + +// Fixed Drand network parameters matching the quicknet defaults. +const GENESIS = 1_692_803_367; +const PERIOD = 3; + +// Target round used across boundary tests. +const TARGET_ROUND = 1_000_000; +// targetTime = genesis + period * round +const TARGET_TIME = GENESIS + PERIOD * TARGET_ROUND; // 1695803367 + +// --------------------------------------------------------------------------- +// computeCountdown +// --------------------------------------------------------------------------- + +test("computeCountdown: one second before R reports not published with 1s remaining", () => { + const result = computeCountdown( + TARGET_ROUND, + TARGET_TIME - 1, // nowSecs + GENESIS, + PERIOD, + ); + + assert.equal(result.published, false); + assert.equal(result.targetTime, TARGET_TIME); + assert.equal(result.secondsRemaining, 1); + assert.equal(result.currentRound, TARGET_ROUND - 1); +}); + +test("computeCountdown: exactly at R reports published with 0s remaining", () => { + const result = computeCountdown( + TARGET_ROUND, + TARGET_TIME, // nowSecs + GENESIS, + PERIOD, + ); + + assert.equal(result.published, true); + assert.equal(result.targetTime, TARGET_TIME); + assert.equal(result.secondsRemaining, 0); + assert.equal(result.currentRound, TARGET_ROUND); +}); + +test("computeCountdown: after R reports published with 0s remaining", () => { + const result = computeCountdown( + TARGET_ROUND, + TARGET_TIME + 1, // nowSecs + GENESIS, + PERIOD, + ); + + assert.equal(result.published, true); + assert.equal(result.targetTime, TARGET_TIME); + assert.equal(result.secondsRemaining, 0); + // currentRound is still the target round until the next period boundary + assert.equal(result.currentRound, TARGET_ROUND); +}); + +test("computeCountdown: published round (well in the past) reports secondsRemaining 0", () => { + const result = computeCountdown( + TARGET_ROUND, + TARGET_TIME + 900_000, // far in the future relative to R + GENESIS, + PERIOD, + ); + + assert.equal(result.published, true); + assert.equal(result.secondsRemaining, 0); +}); + +test("computeCountdown: future round reports correct targetTime and remaining seconds", () => { + const result = computeCountdown( + TARGET_ROUND + 5, + TARGET_TIME, // nowSecs pinned at TARGET_ROUND + GENESIS, + PERIOD, + ); + + const expectedTarget = GENESIS + PERIOD * (TARGET_ROUND + 5); + + assert.equal(result.published, false); + assert.equal(result.targetTime, expectedTarget); + assert.equal(result.secondsRemaining, PERIOD * 5); + assert.equal(result.currentRound, TARGET_ROUND); +}); + +test("computeCountdown: round zero with genesis at epoch 0", () => { + const result = computeCountdown(0, 0, 0, 3); + + assert.equal(result.currentRound, 0); + assert.equal(result.targetTime, 0); + assert.equal(result.published, true); + assert.equal(result.secondsRemaining, 0); +}); + +test("computeCountdown: large target round computes correctly", () => { + const round = 100_000_000; + const expectedTarget = GENESIS + PERIOD * round; + + const result = computeCountdown(round, expectedTarget - 1, GENESIS, PERIOD); + + assert.equal(result.published, false); + assert.equal(result.targetTime, expectedTarget); + assert.equal(result.secondsRemaining, 1); + assert.equal(result.currentRound, round - 1); +}); + +// --------------------------------------------------------------------------- +// formatCountdown +// --------------------------------------------------------------------------- + +test("formatCountdown: zero seconds returns 'published'", () => { + assert.equal(formatCountdown(0), "published"); +}); + +test("formatCountdown: negative seconds returns 'published'", () => { + assert.equal(formatCountdown(-1), "published"); + assert.equal(formatCountdown(-100), "published"); +}); + +test("formatCountdown: single digit seconds", () => { + assert.equal(formatCountdown(1), "1s"); + assert.equal(formatCountdown(9), "9s"); +}); + +test("formatCountdown: double digit seconds, no minutes", () => { + assert.equal(formatCountdown(10), "10s"); + assert.equal(formatCountdown(59), "59s"); +}); + +test("formatCountdown: exactly one minute", () => { + assert.equal(formatCountdown(60), "1m 0s"); +}); + +test("formatCountdown: minutes with seconds", () => { + assert.equal(formatCountdown(61), "1m 1s"); + assert.equal(formatCountdown(119), "1m 59s"); + assert.equal(formatCountdown(120), "2m 0s"); + assert.equal(formatCountdown(3599), "59m 59s"); +}); + +test("formatCountdown: exactly one hour", () => { + assert.equal(formatCountdown(3600), "1h 0m 0s"); +}); + +test("formatCountdown: hours with minutes and seconds", () => { + assert.equal(formatCountdown(3661), "1h 1m 1s"); + assert.equal(formatCountdown(7200), "2h 0m 0s"); + assert.equal(formatCountdown(7323), "2h 2m 3s"); +}); + +test("formatCountdown: large hour values", () => { + const seconds = 24 * 3600 + 30 * 60 + 45; // 24h 30m 45s + assert.equal(formatCountdown(seconds), "24h 30m 45s"); +}); diff --git a/apps/web/src/hooks/useDrandCountdown.ts b/apps/web/src/hooks/useDrandCountdown.ts index 4b30730..8b5658b 100644 --- a/apps/web/src/hooks/useDrandCountdown.ts +++ b/apps/web/src/hooks/useDrandCountdown.ts @@ -16,20 +16,38 @@ export interface DrandCountdown { published: boolean; } -function timeOfRound(round: number): number { - return QUICKNET_GENESIS + QUICKNET_PERIOD * round; +/** + * Pure countdown calculation — accepts fixed timestamps, genesis, and period + * so tests can pin the wall clock. Produces deterministic + * { currentRound, targetTime, published, secondsRemaining }. + */ +export function computeCountdown( + targetRound: number, + nowSecs: number, + genesis: number, + period: number, +): { currentRound: number; targetTime: number; published: boolean; secondsRemaining: number } { + const currentRound = Math.floor((nowSecs - genesis) / period); + const targetTime = genesis + period * targetRound; + const published = currentRound >= targetRound; + + return { + currentRound, + targetTime, + published, + secondsRemaining: published ? 0 : Math.max(0, targetTime - nowSecs), + }; } function localCountdown(targetRound: number): Omit { const now = Math.floor(Date.now() / 1000); - const targetTime = timeOfRound(targetRound); - const currentRound = Math.floor((now - QUICKNET_GENESIS) / QUICKNET_PERIOD); - const published = currentRound >= targetRound; + const { currentRound, targetTime, published, secondsRemaining } = + computeCountdown(targetRound, now, QUICKNET_GENESIS, QUICKNET_PERIOD); return { currentRound, targetRound, - secondsRemaining: published ? 0 : Math.max(0, targetTime - now), + secondsRemaining, targetTime, published, }; @@ -54,10 +72,8 @@ export function useDrandCountdown(targetRound: number, pollMs = 1000): DrandCoun const genesis = info.genesis_time; const period = info.period; const now = Math.floor(Date.now() / 1000); - const currentRound = Math.floor((now - genesis) / period); - const targetTime = genesis + period * targetRound; - const published = currentRound >= targetRound; - const secondsRemaining = published ? 0 : Math.max(0, targetTime - now); + const { currentRound, targetTime, published, secondsRemaining } = + computeCountdown(targetRound, now, genesis, period); if (!cancelled) { setState({