brackt/app/lib/__tests__/draft-timer.test.ts
Chris Parsons 7a8ea60e77
Add draft clock UI, Fischer increment timer logic, and security fixes (#19)
- Add prominent clock badge to tab bar (lights up on your turn) with
  correct Fischer increment chess-clock model: bank starts at initialTime,
  += incrementTime after each pick, other teams' banks untouched
- Extract pure timer helpers to app/lib/draft-timer.ts and add 29 unit
  tests covering formatClockTime, calculateTimeAfterPick, getTimerColorClass,
  and full snake-draft lifecycle regression scenarios
- Fix make-pick.ts: add status !== 'draft' and draftPaused server guards
- Fix draft-utils.ts: replace (global as any).__socketIO with getSocketIO(),
  fix timeUsed to store actual timeRemaining at pick moment (not always 120),
  add null timer warning, move timer fetch before pick insert
- Fix draft.start.ts: batch timer inserts, guard against empty draftSlots
- Fix DraftGridSection: memoize currentTeamId, widen teamTimers type to
  Record<string, number | undefined>
- Fix duplicate animate-pulse (getTimerColorClass already includes it)
- Clamp negative seconds in formatClockTime to guard against timer drift

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:51:12 -08:00

264 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import {
formatClockTime,
calculateTimeAfterPick,
getTimerColorClass,
} from "../draft-timer";
// ─── formatClockTime ─────────────────────────────────────────────────────────
describe("formatClockTime", () => {
it("returns --:-- for undefined", () => {
expect(formatClockTime(undefined)).toBe("--:--");
});
it("formats 0 seconds", () => {
expect(formatClockTime(0)).toBe("0:00");
});
it("pads single-digit seconds", () => {
expect(formatClockTime(5)).toBe("0:05");
expect(formatClockTime(9)).toBe("0:09");
});
it("formats seconds under a minute", () => {
expect(formatClockTime(30)).toBe("0:30");
expect(formatClockTime(59)).toBe("0:59");
});
it("formats exactly one minute", () => {
expect(formatClockTime(60)).toBe("1:00");
});
it("formats mixed minutes and seconds", () => {
expect(formatClockTime(90)).toBe("1:30");
expect(formatClockTime(125)).toBe("2:05");
expect(formatClockTime(150)).toBe("2:30");
});
it("formats the default initial bank time", () => {
expect(formatClockTime(120)).toBe("2:00");
});
it("formats hours when >= 3600 seconds", () => {
expect(formatClockTime(3600)).toBe("1:00:00");
expect(formatClockTime(3661)).toBe("1:01:01");
expect(formatClockTime(7322)).toBe("2:02:02");
});
it("pads minutes when hours are present", () => {
expect(formatClockTime(3605)).toBe("1:00:05");
expect(formatClockTime(3660)).toBe("1:01:00");
});
});
// ─── calculateTimeAfterPick ───────────────────────────────────────────────────
describe("calculateTimeAfterPick", () => {
it("adds increment to a healthy bank", () => {
expect(calculateTimeAfterPick(90, 30)).toBe(120);
});
it("adds increment when bank is exactly zero (timer expired before pick)", () => {
// Timer ran out but autopick fired — team still earns the increment
expect(calculateTimeAfterPick(0, 30)).toBe(30);
});
it("adds increment to a depleted bank (barely positive)", () => {
expect(calculateTimeAfterPick(3, 30)).toBe(33);
});
it("accumulates correctly across multiple quick picks", () => {
const increment = 30;
let bank = 120; // initialTime
// Round 1 — uses 10s, picks fast
bank -= 10;
bank = calculateTimeAfterPick(bank, increment); // 140
// Round 2 — uses 20s
bank -= 20;
bank = calculateTimeAfterPick(bank, increment); // 150
// Round 3 — uses 5s
bank -= 5;
bank = calculateTimeAfterPick(bank, increment); // 175
expect(bank).toBe(175);
});
it("depletes over many slow picks", () => {
const increment = 30;
let bank = 120;
// Each round the team uses more time than the increment replenishes
for (let round = 0; round < 5; round++) {
bank -= 50; // uses 50s each turn (net loss of 20s per pick)
bank = calculateTimeAfterPick(bank, increment);
}
// Started at 120, lost 20s net × 5 rounds = 20s remaining
expect(bank).toBe(20);
});
it("works with a non-default increment value", () => {
expect(calculateTimeAfterPick(60, 15)).toBe(75);
expect(calculateTimeAfterPick(60, 60)).toBe(120);
});
});
// ─── getTimerColorClass ───────────────────────────────────────────────────────
describe("getTimerColorClass", () => {
it("returns muted for undefined (clock not running)", () => {
expect(getTimerColorClass(undefined)).toBe("text-muted-foreground");
});
it("returns green for time well above the warning threshold (> 60s)", () => {
expect(getTimerColorClass(120)).toBe("text-emerald-400");
expect(getTimerColorClass(61)).toBe("text-emerald-400");
});
it("returns amber at the 60-second boundary", () => {
// 60 is NOT > 60, so it falls to amber
expect(getTimerColorClass(60)).toBe("text-amber-accent");
});
it("returns amber for time between 3160s", () => {
expect(getTimerColorClass(45)).toBe("text-amber-accent");
expect(getTimerColorClass(31)).toBe("text-amber-accent");
});
it("returns coral at the 30-second boundary", () => {
expect(getTimerColorClass(30)).toBe("text-coral-accent");
});
it("returns coral for time between 1130s", () => {
expect(getTimerColorClass(20)).toBe("text-coral-accent");
expect(getTimerColorClass(11)).toBe("text-coral-accent");
});
it("returns coral+pulse at the 10-second boundary", () => {
expect(getTimerColorClass(10)).toContain("text-coral-accent");
expect(getTimerColorClass(10)).toContain("animate-pulse");
});
it("returns coral+pulse for critical time (≤ 10s)", () => {
expect(getTimerColorClass(5)).toContain("animate-pulse");
expect(getTimerColorClass(1)).toContain("animate-pulse");
expect(getTimerColorClass(0)).toContain("animate-pulse");
});
it("never returns animate-pulse above the critical threshold", () => {
expect(getTimerColorClass(11)).not.toContain("animate-pulse");
expect(getTimerColorClass(30)).not.toContain("animate-pulse");
expect(getTimerColorClass(120)).not.toContain("animate-pulse");
});
});
// ─── Draft clock lifecycle scenarios ─────────────────────────────────────────
//
// These tests document the intended chess-clock behaviour end-to-end and guard
// against the specific regressions we've hit:
//
// REGRESSION 1 — Timer reset: next team's bank was being reset to initialTime
// instead of left untouched.
// REGRESSION 2 — Double increment: increment was added both at pick-start AND
// after the pick, giving teams 2× the intended bonus.
describe("Draft clock lifecycle", () => {
const INITIAL_TIME = 120;
const INCREMENT = 30;
it("every team starts with exactly initialTime — no increment added upfront", () => {
// draft.start.ts sets timeRemaining: initialTime (not initialTime + increment)
const startingBank = INITIAL_TIME;
expect(startingBank).toBe(120);
expect(startingBank).not.toBe(INITIAL_TIME + INCREMENT);
});
it("REGRESSION 1 — next team's bank is preserved when another team picks", () => {
// Team B has been on the clock and has 75s remaining.
// Team A (not team B) makes a pick.
// Team B's bank must NOT be touched at all.
const teamBBank = 75;
// Verify that calling calculateTimeAfterPick on team B WOULD change the value —
// this proves that a mutation is detectable and the correct behaviour is to skip it.
const incorrectlyUpdated = calculateTimeAfterPick(teamBBank, INCREMENT);
expect(incorrectlyUpdated).not.toBe(teamBBank); // 105 ≠ 75: mutation would be visible
// Correct behaviour: team B's bank is left unchanged (no calculateTimeAfterPick called)
expect(teamBBank).toBe(75);
expect(teamBBank).not.toBe(INITIAL_TIME); // 75 ≠ 120: not reset to initialTime (old bug)
});
it("REGRESSION 2 — increment is added exactly once per pick (post-pick only)", () => {
// Team A is on the clock. They have 90s and use 30s before picking.
let teamABank = 90;
teamABank -= 30; // 60s left at pick time
// Post-pick reward: +increment once
teamABank = calculateTimeAfterPick(teamABank, INCREMENT);
expect(teamABank).toBe(90); // 60 + 30 = 90 (not 120, which would be double-increment)
});
it("full two-team, three-round snake draft simulation", () => {
// Settings
const init = INITIAL_TIME; // 120s
const inc = INCREMENT; // 30s
let teamA = init; // 120
let teamB = init; // 120
// ── Round 1 ──────────────────────────
// Pick 1: Team A on clock, uses 20s
teamA -= 20; // 100
teamA = calculateTimeAfterPick(teamA, inc); // 130
// teamB untouched: 120
// Pick 2: Team B on clock, uses 60s
teamB -= 60; // 60
teamB = calculateTimeAfterPick(teamB, inc); // 90
// teamA untouched: 130
// ── Round 2 (reversed) ───────────────
// Pick 3: Team B on clock, uses 40s
teamB -= 40; // 50
teamB = calculateTimeAfterPick(teamB, inc); // 80
// teamA untouched: 130
// Pick 4: Team A on clock, uses 10s
teamA -= 10; // 120
teamA = calculateTimeAfterPick(teamA, inc); // 150
// teamB untouched: 80
// ── Round 3 ──────────────────────────
// Pick 5: Team A on clock, uses 80s
teamA -= 80; // 70
teamA = calculateTimeAfterPick(teamA, inc); // 100
// teamB untouched: 80
// Pick 6: Team B on clock, uses 75s
teamB -= 75; // 5
teamB = calculateTimeAfterPick(teamB, inc); // 35
// teamA untouched: 100
expect(teamA).toBe(100); // quick picker — healthy bank
expect(teamB).toBe(35); // slow picker — barely surviving
});
it("team that consistently runs out of time survives on increments alone", () => {
let bank = INITIAL_TIME; // 120
for (let round = 0; round < 4; round++) {
// Use the entire bank each round (timer hits 0 before pick fires)
bank = 0;
bank = calculateTimeAfterPick(bank, INCREMENT);
}
// After 4 rounds of running the clock dry, bank = just the increment
expect(bank).toBe(INCREMENT); // 30s — still alive, just barely
});
});