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>
This commit is contained in:
parent
d7ba20d9ea
commit
789408e428
9 changed files with 949 additions and 143 deletions
352
app/models/__tests__/executeAutoPick.timer.test.ts
Normal file
352
app/models/__tests__/executeAutoPick.timer.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -645,9 +645,10 @@ export async function executeAutoPick(params: {
|
||||||
const totalPicks = totalTeams * season.draftRounds;
|
const totalPicks = totalTeams * season.draftRounds;
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Auto picks do not earn bank time.
|
// Update the team's timer after the auto-pick.
|
||||||
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
|
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
|
||||||
// Chess clock mode: timer already reached 0; leave the bank frozen at 0.
|
// Chess clock mode: add the increment so the team starts their next turn with some time
|
||||||
|
// (without this, a single timeout would permanently freeze their bank at 0).
|
||||||
let emitTimeRemaining: number;
|
let emitTimeRemaining: number;
|
||||||
|
|
||||||
if (season.draftTimerMode === "standard") {
|
if (season.draftTimerMode === "standard") {
|
||||||
|
|
@ -666,10 +667,26 @@ export async function executeAutoPick(params: {
|
||||||
`[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)`
|
`[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Chess clock: no DB update — auto picks don't earn increment.
|
// Chess clock: add the increment (atomic add, same as a manual pick).
|
||||||
emitTimeRemaining = currentTimer?.timeRemaining ?? 0;
|
const [updatedTimer] = await db
|
||||||
|
.update(schema.draftTimers)
|
||||||
|
.set({
|
||||||
|
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.draftTimers.seasonId, seasonId),
|
||||||
|
eq(schema.draftTimers.teamId, teamId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||||
|
if (!updatedTimer) {
|
||||||
|
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining });
|
||||||
|
}
|
||||||
logger.log(
|
logger.log(
|
||||||
`[AutoPick] Chess clock pick for team ${teamId}, bank frozen at ${emitTimeRemaining}s (no increment)`
|
`[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -348,43 +348,38 @@ describe("draft.force-manual-pick action", () => {
|
||||||
|
|
||||||
// ── Timer behavior ─────────────────────────────────────────────────────────
|
// ── Timer behavior ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Force manual picks are commissioner actions — they do NOT earn the team
|
// Force manual picks earn the increment the same as any other pick.
|
||||||
// any extra time bank, regardless of timer mode.
|
|
||||||
//
|
//
|
||||||
// chess_clock: bank is frozen (no DB update); current value read for socket emit.
|
// chess_clock: bank += draftIncrementTime (atomic add).
|
||||||
// standard: bank resets to draftIncrementTime (the per-pick time) for the next turn.
|
// standard: bank resets to draftIncrementTime for the next turn.
|
||||||
// Next team's bank is always left completely untouched.
|
// Next team's bank is always left completely untouched.
|
||||||
|
|
||||||
describe("timer behavior", () => {
|
describe("timer behavior", () => {
|
||||||
describe("chess_clock mode", () => {
|
describe("chess_clock mode", () => {
|
||||||
it("does NOT add increment to the picking team's bank", async () => {
|
it("adds the increment to the picking team's bank", async () => {
|
||||||
// Timer has 75s (from beforeEach mock); forced pick → bank stays at 75s
|
// Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
"timer-update",
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits timer-update with the frozen (unchanged) bank value", async () => {
|
it("writes the incremented timer to the DB", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
seasonId: SEASON_ID,
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
teamId: TEAM_ID,
|
);
|
||||||
timeRemaining: 75,
|
|
||||||
currentPickNumber: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads the picking team's timer but does not write it", async () => {
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
|
||||||
|
|
||||||
// findFirst called once (picking team's current value for the emit)
|
|
||||||
expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1);
|
|
||||||
// update called once only for season.currentPickNumber — NOT for the timer
|
|
||||||
expect(mockDb.update).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -409,15 +404,16 @@ describe("draft.force-manual-pick action", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes the DB timer (reset) and updates the season pick number", async () => {
|
it("writes the timer reset to the DB", async () => {
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
// Two updates: timer reset + season.currentPickNumber
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
/**
|
||||||
|
* Tests for draft.force-manual-pick timer behavior across chess_clock and standard modes.
|
||||||
|
*
|
||||||
|
* chess_clock: force picks earn the increment just like any other pick (bank += increment)
|
||||||
|
* standard: force picks always reset the bank to exactly draftIncrementTime
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import type { RouterContextProvider } from "react-router";
|
||||||
|
import { action } from "~/routes/api/draft.force-manual-pick";
|
||||||
|
|
||||||
|
const ctx = {} as unknown as RouterContextProvider;
|
||||||
|
|
||||||
|
vi.mock("~/database/context");
|
||||||
|
vi.mock("~/server/socket", () => ({
|
||||||
|
getSocketIO: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@clerk/react-router/server", () => ({
|
||||||
|
getAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/draft-pick", () => ({
|
||||||
|
getDraftPicksWithSports: vi.fn(),
|
||||||
|
getTeamDraftPicksWithSports: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/participant", () => ({
|
||||||
|
getParticipantsForSeasonWithSports: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/season-sport", () => ({
|
||||||
|
getSeasonSportsSimple: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/lib/draft-eligibility", () => ({
|
||||||
|
calculateDraftEligibility: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/draft-utils", () => ({
|
||||||
|
checkAndTriggerNextAutodraft: vi.fn(),
|
||||||
|
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||||||
|
}));
|
||||||
|
vi.mock("~/models/user", () => ({
|
||||||
|
isUserAdminByClerkId: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEASON_ID = "season-1";
|
||||||
|
const TEAM_ID = "team-1";
|
||||||
|
const NEXT_TEAM_ID = "team-2";
|
||||||
|
const PARTICIPANT_ID = "participant-1";
|
||||||
|
const COMMISSIONER_ID = "commissioner-user-1";
|
||||||
|
const ADMIN_ID = "admin-user-1";
|
||||||
|
const SPORT_ID = "sport-nfl";
|
||||||
|
|
||||||
|
const mockParticipant = {
|
||||||
|
id: PARTICIPANT_ID,
|
||||||
|
name: "Patrick Mahomes",
|
||||||
|
sportsSeason: {
|
||||||
|
id: "sports-season-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: COMMISSIONER_ID,
|
||||||
|
pickedByType: "commissioner",
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 30,
|
||||||
|
draftTimerMode: "chess_clock",
|
||||||
|
currentPickNumber: 1,
|
||||||
|
draftPaused: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest() {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("seasonId", SEASON_ID);
|
||||||
|
formData.append("teamId", TEAM_ID);
|
||||||
|
formData.append("participantId", PARTICIPANT_ID);
|
||||||
|
formData.append("pickNumber", "1");
|
||||||
|
return new Request("http://localhost/api/draft/force-manual-pick", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
|
let mockDb: any;
|
||||||
|
let mockSocketIO: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default: authenticated as commissioner
|
||||||
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
|
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||||
|
|
||||||
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||||
|
|
||||||
|
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||||
|
const socketModule = await import("~/server/socket");
|
||||||
|
vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO);
|
||||||
|
|
||||||
|
const { getDraftPicksWithSports, getTeamDraftPicksWithSports } = await import("~/models/draft-pick");
|
||||||
|
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
|
||||||
|
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { getParticipantsForSeasonWithSports } = await import("~/models/participant");
|
||||||
|
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { getSeasonSportsSimple } = await import("~/models/season-sport");
|
||||||
|
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { calculateDraftEligibility } = await import("~/lib/draft-eligibility");
|
||||||
|
vi.mocked(calculateDraftEligibility).mockReturnValue({
|
||||||
|
eligibleSportIds: new Set([SPORT_ID]),
|
||||||
|
ineligibleReasons: {},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils");
|
||||||
|
vi.mocked(checkAndTriggerNextAutodraft).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
mockDb = {
|
||||||
|
query: {
|
||||||
|
seasons: { findFirst: vi.fn() },
|
||||||
|
commissioners: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID }),
|
||||||
|
},
|
||||||
|
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
|
||||||
|
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
|
||||||
|
draftTimers: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 75 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insert: vi.fn().mockReturnThis(),
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockResolvedValue([mockDraftPick]),
|
||||||
|
update: vi.fn().mockReturnThis(),
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
delete: vi.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { database } = await import("~/database/context");
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── chess_clock mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("chess_clock mode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
|
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("commissioner force pick", () => {
|
||||||
|
it("emits timer-update with bank + increment", async () => {
|
||||||
|
// Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s.
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
// Two DB updates: timer row + season.currentPickNumber
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates a larger bank when more time was remaining", async () => {
|
||||||
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||||
|
// 100 + 30 = 130
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin force pick", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
|
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||||
|
|
||||||
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
|
||||||
|
// Admin is not a commissioner
|
||||||
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits timer-update with bank + increment", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── standard mode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("standard mode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 30, draftIncrementTime: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("commissioner force pick", () => {
|
||||||
|
it("resets bank to exactly draftIncrementTime", async () => {
|
||||||
|
// Pre-pick bank doesn't matter — standard always resets
|
||||||
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 });
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accumulate time even when bank was large", async () => {
|
||||||
|
// Team had 25s left; standard mode resets to increment, never adds to prior balance
|
||||||
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin force pick", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
|
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||||
|
|
||||||
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
|
||||||
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets bank to exactly draftIncrementTime", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom draftIncrementTime when configured", async () => {
|
||||||
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 })
|
||||||
|
);
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 60 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Tests for draft.make-pick timer behavior across chess_clock and standard modes.
|
* Tests for draft.make-pick timer behavior across chess_clock and standard modes.
|
||||||
*
|
*
|
||||||
* chess_clock: after a pick, the team's remaining bank += increment (carry-over)
|
* chess_clock: after ANY pick (owner, commissioner, admin), bank += increment
|
||||||
* standard: after a pick, the team's bank resets to exactly the increment
|
* standard: after ANY pick, bank resets to exactly draftIncrementTime
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import type { RouterContextProvider } from "react-router";
|
import type { RouterContextProvider } from "react-router";
|
||||||
|
|
@ -39,13 +39,15 @@ vi.mock("~/models/user", () => ({
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdminByClerkId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SEASON_ID = "season-1";
|
const SEASON_ID = "season-1";
|
||||||
const TEAM_ID = "team-1";
|
const TEAM_ID = "team-1";
|
||||||
const NEXT_TEAM_ID = "team-2";
|
const NEXT_TEAM_ID = "team-2";
|
||||||
const PARTICIPANT_ID = "participant-1";
|
const PARTICIPANT_ID = "participant-1";
|
||||||
const OWNER_ID = "owner-user-1";
|
const OWNER_ID = "owner-user-1";
|
||||||
|
const COMMISSIONER_ID = "commissioner-user-1";
|
||||||
|
const ADMIN_ID = "admin-user-1";
|
||||||
const SPORT_ID = "sport-nfl";
|
const SPORT_ID = "sport-nfl";
|
||||||
|
|
||||||
const mockParticipant = {
|
const mockParticipant = {
|
||||||
|
|
@ -69,6 +71,7 @@ const mockDraftPick = {
|
||||||
pickedByType: "owner",
|
pickedByType: "owner",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Two-team draft; team-1 picks first.
|
||||||
const mockDraftSlots = [
|
const mockDraftSlots = [
|
||||||
{
|
{
|
||||||
id: "slot-1",
|
id: "slot-1",
|
||||||
|
|
@ -111,7 +114,7 @@ function makeRequest() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("draft.make-pick action — timer mode behavior", () => {
|
describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
let mockDb: any;
|
let mockDb: any;
|
||||||
|
|
@ -156,7 +159,7 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
|
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
|
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
|
||||||
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
|
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
|
||||||
draftTimers: { findFirst: vi.fn().mockResolvedValue({ timeRemaining: 75 }) },
|
draftTimers: { findFirst: vi.fn().mockResolvedValue({ id: "timer-1", timeRemaining: 75 }) },
|
||||||
draftQueue: { findMany: vi.fn().mockResolvedValue([]) },
|
draftQueue: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
},
|
},
|
||||||
insert: vi.fn().mockReturnThis(),
|
insert: vi.fn().mockReturnThis(),
|
||||||
|
|
@ -172,16 +175,23 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
vi.mocked(database).mockReturnValue(mockDb);
|
vi.mocked(database).mockReturnValue(mockDb);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── chess_clock mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("chess_clock mode", () => {
|
describe("chess_clock mode", () => {
|
||||||
describe("owner pick — earns the increment", () => {
|
beforeEach(() => {
|
||||||
it("emits timer-update with the incremented balance (bank + increment)", async () => {
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
||||||
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
);
|
||||||
);
|
});
|
||||||
// Simulate DB returning previous 75s + 30s increment = 105s
|
|
||||||
|
describe("owner pick", () => {
|
||||||
|
// Auth default is OWNER_ID (team owner), no extra setup needed.
|
||||||
|
|
||||||
|
it("emits timer-update with bank + increment", async () => {
|
||||||
|
// DB returns 75 + 30 = 105 after the atomic add
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick]) // insert draft pick
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]); // update timer
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
|
@ -191,14 +201,12 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picks with more time remaining accumulate a larger bank", async () => {
|
it("accumulates a larger bank when more time was remaining", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||||
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
// 100 + 30 = 130
|
||||||
);
|
|
||||||
// Team had 100s; 100 + 30 = 130s
|
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]);
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
|
@ -207,78 +215,120 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("commissioner pick — no increment earned", () => {
|
describe("commissioner pick", () => {
|
||||||
const COMMISSIONER_ID = "commissioner-user-1";
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Auth as commissioner (not the team owner)
|
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||||
// Commissioner record exists
|
|
||||||
mockDb.query.commissioners.findFirst.mockResolvedValue({
|
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
||||||
id: "c-1",
|
// Commissioner does not own the team on the clock
|
||||||
userId: COMMISSIONER_ID,
|
|
||||||
});
|
|
||||||
// Team is owned by someone else
|
|
||||||
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: "slot-1",
|
...mockDraftSlots[0],
|
||||||
seasonId: SEASON_ID,
|
team: { ...mockDraftSlots[0].team, ownerId: "someone-else" },
|
||||||
teamId: TEAM_ID,
|
|
||||||
draftOrder: 1,
|
|
||||||
team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "different-owner" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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" },
|
|
||||||
},
|
},
|
||||||
|
mockDraftSlots[1],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits timer-update with the frozen (unchanged) bank — no increment added", async () => {
|
it("emits timer-update with bank + increment", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.returning
|
||||||
makeSeason({ draftTimerMode: "chess_clock", draftIncrementTime: 30 })
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
);
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
// timerSnapshot has 75s remaining; commissioner picks → should still be 75s
|
|
||||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ timeRemaining: 75 });
|
|
||||||
mockDb.returning.mockResolvedValueOnce([mockDraftPick]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
"timer-update",
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not write the timer to the DB (no increment update)", async () => {
|
it("writes the timer update to the DB", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.returning
|
||||||
makeSeason({ draftTimerMode: "chess_clock", draftIncrementTime: 30 })
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
);
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
mockDb.returning.mockResolvedValueOnce([mockDraftPick]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
// Only one update: season.currentPickNumber — the timer row is NOT written
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect(mockDb.update).toHaveBeenCalledTimes(1);
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin pick", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
|
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||||
|
|
||||||
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
|
||||||
|
// Admin does not own the team on the clock
|
||||||
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
...mockDraftSlots[0],
|
||||||
|
team: { ...mockDraftSlots[0].team, ownerId: "someone-else" },
|
||||||
|
},
|
||||||
|
mockDraftSlots[1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits timer-update with bank + increment", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── standard mode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("standard mode", () => {
|
describe("standard mode", () => {
|
||||||
it("emits timer-update with exactly draftIncrementTime regardless of remaining bank", async () => {
|
beforeEach(() => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 30, draftIncrementTime: 30 })
|
||||||
);
|
);
|
||||||
// Simulate DB resetting to exactly 30s (the increment), ignoring prior bank
|
});
|
||||||
|
|
||||||
|
it("owner pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
|
@ -288,37 +338,78 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fast pickers do NOT accumulate time — always resets to increment", async () => {
|
it("owner pick — fast picker does NOT accumulate time (bank never exceeds increment)", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
// Team had 25s left (picked quickly); standard mode ignores prior balance
|
||||||
makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
||||||
);
|
|
||||||
// Team picked quickly with 90s remaining — in standard mode they still reset to 30s
|
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
// Must be 30 (increment reset), not 120 (initialTime) or 120 (90+30)
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
"timer-update",
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still returns 200 on a successful pick", async () => {
|
it("commissioner pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
|
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||||
|
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
||||||
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
|
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
||||||
|
mockDraftSlots[1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
|
const { getAuth } = await import("@clerk/react-router/server");
|
||||||
|
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||||
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
|
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
||||||
|
mockDraftSlots[1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockDb.returning
|
||||||
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||||
|
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom draftIncrementTime when configured", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
makeSeason({ draftTimerMode: "standard", draftIncrementTime: 45 })
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 90, draftIncrementTime: 90 })
|
||||||
);
|
);
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 45 }]);
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
|
||||||
|
|
||||||
const response = await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
const data = await response.json();
|
"timer-update",
|
||||||
expect(data.success).toBe(true);
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -146,14 +146,6 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
return Response.json({ error: reason }, { status: 400 });
|
return Response.json({ error: reason }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the team's time bank before any writes (used by chess clock branch below).
|
|
||||||
const timerSnapshot = await db.query.draftTimers.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the draft pick
|
// Create the draft pick
|
||||||
const [draftPick] = await db
|
const [draftPick] = await db
|
||||||
.insert(schema.draftPicks)
|
.insert(schema.draftPicks)
|
||||||
|
|
@ -174,30 +166,26 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
const totalPicks = totalTeams * season.draftRounds;
|
const totalPicks = totalTeams * season.draftRounds;
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Commissioner force picks do not earn bank time.
|
|
||||||
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
|
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
|
||||||
// Chess clock mode: leave the bank frozen — no DB update, use the pre-pick snapshot.
|
// Chess clock: add the increment to the bank (same as any other pick type).
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
const incrementTime = season.draftIncrementTime || 30;
|
||||||
let newTimeRemaining: number;
|
const timerSql = season.draftTimerMode === "standard"
|
||||||
|
? sql`${incrementTime}`
|
||||||
|
: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`;
|
||||||
|
|
||||||
if (season.draftTimerMode === "standard") {
|
const [updatedTimer] = await db
|
||||||
const [updatedTimer] = await db
|
.update(schema.draftTimers)
|
||||||
.update(schema.draftTimers)
|
.set({ timeRemaining: timerSql, updatedAt: new Date() })
|
||||||
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
.where(
|
||||||
.where(
|
and(
|
||||||
and(
|
eq(schema.draftTimers.seasonId, seasonId),
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
eq(schema.draftTimers.teamId, teamId)
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.returning();
|
)
|
||||||
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
.returning();
|
||||||
if (!updatedTimer) {
|
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
if (!updatedTimer) {
|
||||||
}
|
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
||||||
} else {
|
|
||||||
// Chess clock: bank is frozen — reuse the pre-pick snapshot, no DB write needed.
|
|
||||||
newTimeRemaining = timerSnapshot?.timeRemaining ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit timer update to all clients
|
// Emit timer update to all clients
|
||||||
|
|
|
||||||
|
|
@ -205,8 +205,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
// Update the picking team's timer after their pick.
|
// Update the picking team's timer after their pick.
|
||||||
// Standard mode: always reset to the per-pick time, regardless of who picked.
|
// Standard mode: always reset to the per-pick time, regardless of who picked.
|
||||||
// Chess clock, owner pick: add the increment (reward for picking promptly).
|
// Chess clock: always add the increment (any pick type — owner, commissioner, or admin).
|
||||||
// Chess clock, commissioner/admin pick: no change — forced picks don't earn bank time.
|
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
const incrementTime = season.draftIncrementTime || 30;
|
||||||
let newTimeRemaining: number;
|
let newTimeRemaining: number;
|
||||||
|
|
||||||
|
|
@ -225,8 +224,8 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||||
} else if (isTeamOwner) {
|
} else {
|
||||||
// Chess clock, owner pick: earn the increment (atomic add).
|
// Chess clock: earn the increment regardless of who made the pick (atomic add).
|
||||||
[updatedTimer] = await db
|
[updatedTimer] = await db
|
||||||
.update(schema.draftTimers)
|
.update(schema.draftTimers)
|
||||||
.set({
|
.set({
|
||||||
|
|
@ -241,13 +240,10 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||||
} else {
|
|
||||||
// Chess clock, commissioner/admin pick: bank is frozen — no DB update needed.
|
|
||||||
newTimeRemaining = timerSnapshot?.timeRemaining ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the timer row didn't exist yet, seed it (standard and chess clock owner branches only).
|
// If the timer row didn't exist yet, seed it.
|
||||||
if (!updatedTimer && (season.draftTimerMode === "standard" || isTeamOwner)) {
|
if (!updatedTimer) {
|
||||||
await db.insert(schema.draftTimers).values({
|
await db.insert(schema.draftTimers).values({
|
||||||
seasonId,
|
seasonId,
|
||||||
teamId: currentDraftSlot.teamId,
|
teamId: currentDraftSlot.teamId,
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,14 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium">{season.draftRounds}</p>
|
<p className="font-medium">{season.draftRounds}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Draft Timer Mode
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Sports Selected
|
Sports Selected
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,10 @@ async function updateDraftTimers(): Promise<void> {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
|
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
|
||||||
);
|
);
|
||||||
const initialTime = season.draftInitialTime || 120;
|
// Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
|
||||||
|
const initialTime = season.draftTimerMode === "standard"
|
||||||
|
? (season.draftIncrementTime || 30)
|
||||||
|
: (season.draftInitialTime || 120);
|
||||||
await db
|
await db
|
||||||
.insert(schema.draftTimers)
|
.insert(schema.draftTimers)
|
||||||
.values({
|
.values({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue