brackt/app/lib/__tests__/draft-timer.test.ts
Chris Parsons d92f73e449
Extract chess clock presets to shared constant (#432)
* Fix custom chess-clock timer detection and save settings blocker

- Extract getInitialDraftSpeed() helper so both useState init and
  resetSettingsFormState use the same logic; unrecognized presets
  now produce a "custom:{bank}:{incr}" string instead of falling
  back to "standard", so the custom section auto-expands correctly
  (e.g. 8 hr bank + 30 min increment is no longer shown as Standard)
- resetSettingsFormState was not resetting draftSpeed at all; fixed
- Clear hasUnsavedSettingsChanges in the Form's onSubmit so the
  useBlocker check is false at the moment the save navigation starts,
  preventing the "Leave without saving?" dialog from firing on save
- Replace the cleared-on-success useEffect with one that re-marks
  dirty when the action returns an error, so the warning reappears
  after a failed save

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1

* Address code review: single source of truth for presets, useEffect guard, tests

- Extract CHESS_CLOCK_PRESETS (pure data) into draft-timer.ts as the
  canonical source of truth; DraftSpeedPicker.tsx now spreads those
  values into CHESS_PRESETS rather than duplicating the numbers
- getInitialDraftSpeed moved to draft-timer.ts and rewritten to use
  CHESS_CLOCK_PRESETS.find() — no more hardcoded bankSec/incrSec values
  in three separate places
- parseDraftSpeed switch replaced with CHESS_CLOCK_PRESETS.find() and
  an explicit fallback comment; eliminates the silent default-handles-
  "standard" fragility called out in review
- useEffect that re-marks settings dirty now guards against non-settings
  errors (draft-order, commissioner actions) which carry a `section`
  field — previously any error actionData would spuriously set
  hasUnsavedSettingsChanges=true
- Add parseDraftSpeed and getInitialDraftSpeed test suites to
  draft-timer.test.ts, including parametrised preset coverage via
  it.each(CHESS_CLOCK_PRESETS)

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1

* Fix lint: use !== null/undefined instead of != null

oxlint enforces eqeqeq; the null check in getInitialDraftSpeed used
!= which triggered two errors.

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 14:02:49 -07:00

336 lines
13 KiB
TypeScript
Raw Permalink 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,
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 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
});
});