- SessionExpiryBanner: live countdown, warn 15min early, one-shot toast, Extend button - countdown.ts: pure countdown helpers (timeUntilExpiry, sessionExpiryPhase, formatCountdown) - useSessionExpired: added unlock handler for SESSION_EXTENDED_EVENT - useGameTick: exported SESSION_EXTENDED_EVENT for session extension - unit tests: 14/14 pass for countdown helpers - e2e test: banner renders with countdown + extend button
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
SESSION_AUTO_CLOSE_MS,
|
|
SESSION_WARN_THRESHOLD_MS,
|
|
formatCountdown,
|
|
sessionExpiryPhase,
|
|
timeUntilExpiry,
|
|
} from "@/lib/session/countdown";
|
|
|
|
describe("timeUntilExpiry", () => {
|
|
it("returns sessionExp - now", () => {
|
|
expect(timeUntilExpiry(1_800_000_000, 1_700_000_000)).toBe(100_000_000);
|
|
});
|
|
|
|
it("defaults now to Date.now()", () => {
|
|
const now = Date.now();
|
|
const result = timeUntilExpiry(now + 5_000);
|
|
expect(result).toBeGreaterThanOrEqual(4_900);
|
|
expect(result).toBeLessThanOrEqual(5_000);
|
|
});
|
|
|
|
it("returns null when there is no known expiry", () => {
|
|
expect(timeUntilExpiry(null)).toBeNull();
|
|
expect(timeUntilExpiry(undefined)).toBeNull();
|
|
});
|
|
|
|
it("returns null for a non-finite expiry", () => {
|
|
expect(timeUntilExpiry(Number.NaN)).toBeNull();
|
|
expect(timeUntilExpiry(Number.POSITIVE_INFINITY)).toBeNull();
|
|
});
|
|
|
|
it("allows a negative remaining time (already expired)", () => {
|
|
expect(timeUntilExpiry(100, 200)).toBe(-100);
|
|
});
|
|
});
|
|
|
|
describe("sessionExpiryPhase", () => {
|
|
const warn = SESSION_WARN_THRESHOLD_MS; // 15 min
|
|
const close = SESSION_AUTO_CLOSE_MS; // 30s
|
|
|
|
it("returns idle when remaining exceeds the warn window", () => {
|
|
expect(sessionExpiryPhase(warn + 1)).toBe("idle");
|
|
expect(sessionExpiryPhase(null)).toBe("idle");
|
|
});
|
|
|
|
it("returns warn inside the window (close < remaining <= warn)", () => {
|
|
expect(sessionExpiryPhase(warn)).toBe("warn");
|
|
expect(sessionExpiryPhase(close + 1)).toBe("warn");
|
|
expect(sessionExpiryPhase(warn / 2)).toBe("warn");
|
|
});
|
|
|
|
it("returns close at or below the close threshold", () => {
|
|
expect(sessionExpiryPhase(close)).toBe("close");
|
|
expect(sessionExpiryPhase(0)).toBe("close");
|
|
expect(sessionExpiryPhase(-1)).toBe("close");
|
|
});
|
|
|
|
it("honours custom thresholds", () => {
|
|
expect(sessionExpiryPhase(10 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("warn");
|
|
expect(sessionExpiryPhase(3 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("close");
|
|
});
|
|
});
|
|
|
|
describe("formatCountdown", () => {
|
|
it("formats under an hour as MM:SS", () => {
|
|
expect(formatCountdown(0)).toBe("00:00");
|
|
expect(formatCountdown(59)).toBe("00:59");
|
|
expect(formatCountdown(60)).toBe("01:00");
|
|
expect(formatCountdown(90)).toBe("01:30");
|
|
expect(formatCountdown(599)).toBe("09:59");
|
|
});
|
|
|
|
it("formats hours as H:MM:SS", () => {
|
|
expect(formatCountdown(3600)).toBe("1:00:00");
|
|
expect(formatCountdown(3725)).toBe("1:02:05");
|
|
});
|
|
|
|
it("rounds up to the next whole second", () => {
|
|
expect(formatCountdown(0.1)).toBe("00:01");
|
|
expect(formatCountdown(59.4)).toBe("01:00");
|
|
});
|
|
|
|
it("never goes negative", () => {
|
|
expect(formatCountdown(-5)).toBe("00:00");
|
|
});
|
|
|
|
it("handles non-finite input", () => {
|
|
expect(formatCountdown(Number.NaN)).toBe("00:00");
|
|
expect(formatCountdown(Number.POSITIVE_INFINITY)).toBe("00:00");
|
|
});
|
|
});
|