import { describe, it, expect } from "vitest"; import { formatClockTime, calculateTimeAfterPick, getTimerColorClass, parseDraftSpeed, getInitialDraftSpeed, CHESS_CLOCK_PRESETS, } from "../draft-timer"; // ─── parseDraftSpeed ───────────────────────────────────────────────────────── describe("parseDraftSpeed", () => { describe("standard mode", () => { it("returns the raw seconds for both fields", () => { expect(parseDraftSpeed("120", "standard")).toEqual({ draftInitialTime: 120, draftIncrementTime: 120 }); expect(parseDraftSpeed("28800", "standard")).toEqual({ draftInitialTime: 28800, draftIncrementTime: 28800 }); }); it("falls back to 90s for null or unparseable input", () => { expect(parseDraftSpeed(null, "standard")).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 }); expect(parseDraftSpeed("abc", "standard")).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 }); }); }); describe("chess_clock mode — named presets", () => { it.each(CHESS_CLOCK_PRESETS)("maps $value to bankSec=$bankSec / incrSec=$incrSec", ({ value, bankSec, incrSec }) => { expect(parseDraftSpeed(value, "chess_clock")).toEqual({ draftInitialTime: bankSec, draftIncrementTime: incrSec, }); }); }); describe("chess_clock mode — custom string", () => { it("parses custom:{bank}:{incr} correctly", () => { expect(parseDraftSpeed("custom:28800:1800", "chess_clock")).toEqual({ draftInitialTime: 28800, draftIncrementTime: 1800, }); }); }); describe("chess_clock mode — fallback", () => { it("returns standard preset values for null or unknown input", () => { expect(parseDraftSpeed(null, "chess_clock")).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 }); expect(parseDraftSpeed("unknown", "chess_clock")).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 }); }); }); }); // ─── getInitialDraftSpeed ───────────────────────────────────────────────────── describe("getInitialDraftSpeed", () => { it("returns increment seconds as string for standard mode", () => { expect(getInitialDraftSpeed({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 120 })).toBe("120"); expect(getInitialDraftSpeed({ draftTimerMode: "standard", draftInitialTime: 28800, draftIncrementTime: 28800 })).toBe("28800"); }); it("defaults to '90' for standard mode with missing increment", () => { expect(getInitialDraftSpeed({ draftTimerMode: "standard", draftInitialTime: null, draftIncrementTime: null })).toBe("90"); }); it.each(CHESS_CLOCK_PRESETS)("recognises the $value preset from its DB values", ({ value, bankSec, incrSec }) => { expect(getInitialDraftSpeed({ draftTimerMode: "chess_clock", draftInitialTime: bankSec, draftIncrementTime: incrSec })).toBe(value); }); it("returns custom:{bank}:{incr} for non-preset chess clock values", () => { expect(getInitialDraftSpeed({ draftTimerMode: "chess_clock", draftInitialTime: 28800, draftIncrementTime: 1800 })) .toBe("custom:28800:1800"); }); it("defaults to 'standard' preset key when chess clock has no saved values", () => { expect(getInitialDraftSpeed({ draftTimerMode: "chess_clock", draftInitialTime: null, draftIncrementTime: null })).toBe("standard"); expect(getInitialDraftSpeed(null)).toBe("standard"); expect(getInitialDraftSpeed(undefined)).toBe("standard"); }); }); // ─── 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 31–60s", () => { 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 11–30s", () => { 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 }); });