brackt/app/models/__tests__/executeAutoPick.timer.test.ts
Chris Parsons 789408e428
Fix chess clock increment not applied to all pick types (#67 regression) (#220)
Fixes #220

When standard draft mode was added, the chess clock increment was
accidentally restricted to owner-only picks. Commissioner, admin, and
auto-picks stopped earning the increment, causing teams that timed out
to freeze at 0s and instant-autopick every subsequent round.

Fix:
- make-pick: all pick types earn the increment in chess clock mode
- force-manual-pick: same; deduplicate standard/chess-clock branches
  into a single update with mode-selected SQL; remove now-unused
  timerSnapshot query
- draft-utils executeAutoPick: restore increment for chess clock
  auto-picks; add missing seed insert when no timer row exists
- server/timer.ts: fix fallback initialization to use draftIncrementTime
  in standard mode (was always using draftInitialTime)
- leagues/$leagueId.tsx: show Draft Timer Mode in League Info panel

Tests:
- Update draft.make-pick.timer-mode to cover owner/commissioner/admin
  in both modes (commissioner section previously asserted frozen bank)
- Add draft.force-manual-pick.timer-mode for commissioner/admin force
  picks in both modes
- Add executeAutoPick.timer for timer-triggered auto-picks in both modes
- Update draft.force-manual-pick to reflect new chess clock behavior
- Replace fragile toHaveBeenCalledTimes(2) assertions with
  toHaveBeenCalledWith checks on the timer set call

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:00:32 -07:00

352 lines
12 KiB
TypeScript

/**
* Tests for executeAutoPick timer behavior across chess_clock and standard modes.
*
* chess_clock: auto-picks earn the increment (bank += increment) so a team that
* times out doesn't get permanently frozen at 0 on all future turns.
* standard: auto-picks reset the bank to exactly draftIncrementTime.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { executeAutoPick } from "../draft-utils";
// ── Module-level mocks ────────────────────────────────────────────────────────
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
}));
vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(),
getTeamDraftPicksWithSports: vi.fn(),
isParticipantDrafted: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
vi.mock("~/models/season-sport", () => ({
getSeasonSportsSimple: vi.fn(),
}));
vi.mock("~/models/draft-queue", () => ({
getTeamQueue: vi.fn(),
getAllQueuesForSeason: vi.fn(),
}));
vi.mock("~/lib/draft-eligibility", () => ({
calculateDraftEligibility: vi.fn(),
}));
// database context not needed — we pass db directly to executeAutoPick
vi.mock("~/database/context");
import { getDraftPicksWithSports, getTeamDraftPicksWithSports, isParticipantDrafted } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
// ── Fixtures ──────────────────────────────────────────────────────────────────
const SEASON_ID = "season-1";
const TEAM_ID = "team-1";
const NEXT_TEAM_ID = "team-2";
const PARTICIPANT_ID = "p-1";
const SPORT_ID = "sport-1";
const mockParticipantForQueue = {
id: PARTICIPANT_ID,
name: "Test Player",
sportsSeason: { sport: { id: SPORT_ID, name: "NFL" } },
};
const mockParticipantFull = {
id: PARTICIPANT_ID,
name: "Test Player",
sportsSeason: {
id: "ss-1",
sport: { id: SPORT_ID, name: "NFL" },
},
};
const mockDraftPick = {
id: "pick-1",
seasonId: SEASON_ID,
teamId: TEAM_ID,
participantId: PARTICIPANT_ID,
pickNumber: 1,
round: 1,
pickInRound: 1,
pickedByUserId: "",
pickedByType: "auto",
};
const mockDraftSlots = [
{
id: "slot-1",
seasonId: SEASON_ID,
teamId: TEAM_ID,
draftOrder: 1,
team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" },
},
{
id: "slot-2",
seasonId: SEASON_ID,
teamId: NEXT_TEAM_ID,
draftOrder: 2,
team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" },
},
];
function makeSeason(overrides: Record<string, unknown> = {}) {
return {
id: SEASON_ID,
leagueId: "league-1",
status: "draft",
draftRounds: 3,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: "chess_clock",
currentPickNumber: 1,
draftPaused: false,
...overrides,
};
}
/**
* Build a mock DB for executeAutoPick.
*
* The `returning` mock is left unconfigured so individual tests can set up
* the sequence of values they expect (pick insert, then timer update).
*/
function makeMockDb(seasonOverrides: Record<string, unknown> = {}) {
const mockDb: any = {
query: {
// Race-condition guard — no existing pick at this slot
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
seasons: { findFirst: vi.fn().mockResolvedValue(makeSeason(seasonOverrides)) },
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
participants: {
// findMany: used by autoPickForTeam queue path
findMany: vi.fn().mockResolvedValue([mockParticipantForQueue]),
// findFirst: used by executeAutoPick to fetch full participant details
findFirst: vi.fn().mockResolvedValue(mockParticipantFull),
},
draftTimers: {
// Default: timer is at 0 (expired), which is the normal auto-pick trigger state
findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 0 }),
},
// checkAndTriggerNextAutodraft reads this; returning null prevents any recursion
autodraftSettings: { findFirst: vi.fn().mockResolvedValue(null) },
},
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn(), // configured per-test
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
};
return mockDb;
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("executeAutoPick — timer mode behavior", () => {
let mockSocketIO: any;
beforeEach(async () => {
vi.clearAllMocks();
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
const { getSocketIO } = await import("~/server/socket");
vi.mocked(getSocketIO).mockReturnValue(mockSocketIO);
// autoPickForTeam external dependencies — return minimal happy-path values
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
vi.mocked(calculateDraftEligibility).mockReturnValue({
eligibleSportIds: new Set([SPORT_ID]),
ineligibleReasons: {},
} as ReturnType<typeof calculateDraftEligibility>);
// Queue has one item so autoPickForTeam selects it without hitting the EV path
vi.mocked(getTeamQueue).mockResolvedValue([
{ id: "q-1", participantId: PARTICIPANT_ID, queuePosition: 1 } as any,
]);
vi.mocked(isParticipantDrafted).mockResolvedValue(false);
// pruneIneligibleQueueItems calls getAllQueuesForSeason — return empty Map to skip pruning
vi.mocked(getAllQueuesForSeason).mockResolvedValue(new Map() as any);
});
// ── chess_clock mode ────────────────────────────────────────────────────────
describe("chess_clock mode", () => {
it("emits timer-update with bank + increment after a timer-triggered pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
// Timer expired at 0; 0 + 15 = 15 after the increment
mockDb.returning
.mockResolvedValueOnce([mockDraftPick]) // insert draft pick
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 })
);
});
it("writes the timer increment to the DB", async () => {
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
);
});
it("applies the configured increment, not a hard-coded default", async () => {
// Use a non-default increment of 30 to confirm the value is read from the season
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
);
});
it("returns success after the pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
const result = await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(result.success).toBe(true);
});
});
// ── standard mode ───────────────────────────────────────────────────────────
describe("standard mode", () => {
it("emits timer-update with exactly draftIncrementTime after a timer-triggered pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
);
});
it("writes the timer reset to the DB", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockDb.update).toHaveBeenCalledTimes(2);
});
it("uses custom draftIncrementTime when configured", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 90 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
);
});
it("returns success after the pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
const result = await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(result.success).toBe(true);
});
});
});