* Redesign autodraft queue system with three-state control and queue-only constraint Core Logic & Database: - Add `queue_only` boolean column to `autodraft_settings` (migration 0031) - Rename autodraft UI states: Off / Next Pick / All Picks (while_on mode maps to All Picks) - `autoPickForTeam`: respects new `queueOnly` param — skips EV fallback when enabled - `executeAutoPick`: auto-disables autodraft + emits socket event when queue empties with queueOnly ON (AC3) - `autodraft-updated` socket event now includes `queueOnly` field Mobile UI Overhaul: - Rename "Lobby" tab → "Available" (AC6) - Add new "Queue" tab to mobile bottom nav with drag-reorder, per-item Draft buttons, and autodraft controls (AC5) - Controls tab retains commissioner tools, notifications, exit; queue controls moved to Queue tab - Turn indicator appears on both Available and Queue tabs Components: - `AutodraftSettings`: replaces toggle+radio with three-state button group (Off | Next Pick | All Picks) + "Only autodraft from queue" switch (AC1, AC2) - `QueueSection`: adds `canPick` prop + per-item Draft buttons for instant drafting when on the clock Desktop (AC4): - Sidebar QueueSection unchanged in position; gains same three-state controls and Draft buttons Tests (AC7): - `autodraft.test.ts`: updated for queueOnly field and socket event shape - `timer-autodraft.test.ts`: new tests for queue-only constraint, auto-shutoff transitions, and all three autodraft states https://claude.ai/code/session_01PYhJicAStoJ2u6q6dV1naB * fix: remove erroneous ?? fallbacks in autodraft socket emissions and add autoPickForTeam tests - Remove `?? true` default on queueOnly in queue-empty auto-disable socket emit (line 488) — was dead code since the column is NOT NULL, but semantically wrong and would have caused client-side UI desync if the type ever relaxed - Remove `?? false` default on the next_pick auto-disable path for consistency - Add app/models/__tests__/auto-pick.test.ts with 6 tests covering the queueOnly constraint: empty queue, all items drafted, partial queue skip, and EV fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add missing queueOnly prop to AutodraftSettings test fixtures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: rewrite AutodraftSettings tests for three-state button group UI The component was redesigned from a switch + radio buttons to Off/Next Pick/All Picks buttons with a separate queue-only Switch toggle. Updated 17 stale tests and added 5 new tests covering the queue-only toggle and the All Picks/Off button interactions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
304 lines
9.2 KiB
TypeScript
304 lines
9.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { autoPickForTeam } from "../draft-utils";
|
||
|
||
// Mock all external model dependencies so we can control their return values
|
||
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(),
|
||
}));
|
||
|
||
vi.mock("~/lib/draft-eligibility", () => ({
|
||
calculateDraftEligibility: vi.fn(),
|
||
}));
|
||
|
||
// Prevent actual socket/db usage
|
||
vi.mock("~/server/socket");
|
||
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 } from "~/models/draft-queue";
|
||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||
|
||
const SEASON_ID = "season-1";
|
||
const TEAM_ID = "team-1";
|
||
const DRAFT_ROUNDS = 10;
|
||
const ALL_TEAM_IDS = [TEAM_ID, "team-2"];
|
||
|
||
// A minimal mock db for cases that do NOT reach getTopAvailableParticipant.
|
||
// `where` resolves to [] so `await db.select()...where()` yields an array.
|
||
function makeMockDb(overrides: Record<string, unknown> = {}) {
|
||
const mockDb: Record<string, unknown> = {
|
||
query: {
|
||
participants: {
|
||
findMany: vi.fn().mockResolvedValue([]),
|
||
},
|
||
},
|
||
select: vi.fn().mockReturnThis(),
|
||
from: vi.fn().mockReturnThis(),
|
||
innerJoin: vi.fn().mockReturnThis(),
|
||
where: vi.fn().mockResolvedValue([]),
|
||
orderBy: vi.fn().mockResolvedValue([]),
|
||
delete: vi.fn().mockReturnThis(),
|
||
...overrides,
|
||
};
|
||
return mockDb;
|
||
}
|
||
|
||
// Build a mock db whose select chain returns a real EV participant.
|
||
// getTopAvailableParticipant makes three sequential awaitable calls:
|
||
// 1. select().from().where() → drafted picks → []
|
||
// 2. select().from().innerJoin()×2.where() → season sports → [{sportsSeasonId, sportId}]
|
||
// 3. select().from().where().orderBy() → top participant → [{id, ...}]
|
||
function makeMockDbWithEvParticipant(participantId: string) {
|
||
return {
|
||
query: { participants: { findMany: vi.fn().mockResolvedValue([]) } },
|
||
select: vi.fn().mockReturnThis(),
|
||
from: vi.fn().mockReturnThis(),
|
||
innerJoin: vi.fn().mockReturnThis(),
|
||
where: vi.fn()
|
||
.mockResolvedValueOnce([]) // call 1: drafted picks
|
||
.mockResolvedValueOnce([{ sportsSeasonId: "ss-1", sportId: "sport-1" }]) // call 2: season sports
|
||
.mockReturnThis(), // call 3: chain to orderBy
|
||
orderBy: vi.fn().mockResolvedValue([
|
||
{ id: participantId, name: "EV Player", expectedValue: "100.00" },
|
||
]),
|
||
delete: vi.fn().mockReturnThis(),
|
||
};
|
||
}
|
||
|
||
// Minimal eligibility stub — makes every sport eligible.
|
||
function stubEligibility() {
|
||
vi.mocked(calculateDraftEligibility).mockReturnValue({
|
||
eligibleSportIds: new Set(["sport-1"]),
|
||
} as ReturnType<typeof calculateDraftEligibility>);
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
|
||
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
|
||
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
|
||
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
|
||
stubEligibility();
|
||
});
|
||
|
||
describe("autoPickForTeam – queueOnly constraint (AC2 & AC3)", () => {
|
||
describe("empty queue", () => {
|
||
it("returns null when queueOnly=true (no EV fallback)", async () => {
|
||
vi.mocked(getTeamQueue).mockResolvedValue([]);
|
||
|
||
const result = await autoPickForTeam(
|
||
SEASON_ID,
|
||
TEAM_ID,
|
||
DRAFT_ROUNDS,
|
||
ALL_TEAM_IDS,
|
||
makeMockDb() as any,
|
||
true // queueOnly
|
||
);
|
||
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it("returns the top EV participant when queueOnly=false (falls back to EV)", async () => {
|
||
vi.mocked(getTeamQueue).mockResolvedValue([]);
|
||
|
||
const mockDb = makeMockDbWithEvParticipant("p-ev");
|
||
|
||
const result = await autoPickForTeam(
|
||
SEASON_ID,
|
||
TEAM_ID,
|
||
DRAFT_ROUNDS,
|
||
ALL_TEAM_IDS,
|
||
mockDb as any,
|
||
false // queueOnly off
|
||
);
|
||
|
||
// Must return the EV participant — proves the EV path ran, not the queueOnly guard.
|
||
expect(result).toBe("p-ev");
|
||
});
|
||
});
|
||
|
||
describe("all queue items already drafted", () => {
|
||
it("returns null when queueOnly=true and every queued participant is taken", async () => {
|
||
const queue = [
|
||
{ id: "q-1", participantId: "p-1", queuePosition: 1 },
|
||
{ id: "q-2", participantId: "p-2", queuePosition: 2 },
|
||
];
|
||
vi.mocked(getTeamQueue).mockResolvedValue(queue as any);
|
||
|
||
const mockDb = makeMockDb({
|
||
query: {
|
||
participants: {
|
||
findMany: vi.fn().mockResolvedValue([
|
||
{
|
||
id: "p-1",
|
||
name: "Player One",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
{
|
||
id: "p-2",
|
||
name: "Player Two",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
]),
|
||
},
|
||
},
|
||
});
|
||
|
||
// Both participants are already drafted
|
||
vi.mocked(isParticipantDrafted).mockResolvedValue(true);
|
||
|
||
const result = await autoPickForTeam(
|
||
SEASON_ID,
|
||
TEAM_ID,
|
||
DRAFT_ROUNDS,
|
||
ALL_TEAM_IDS,
|
||
mockDb as any,
|
||
true // queueOnly
|
||
);
|
||
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it("removes stale queue entries when all items are drafted", async () => {
|
||
const queue = [{ id: "q-1", participantId: "p-1", queuePosition: 1 }];
|
||
vi.mocked(getTeamQueue).mockResolvedValue(queue as any);
|
||
|
||
const mockDelete = vi.fn().mockReturnThis();
|
||
const mockWhere = vi.fn().mockResolvedValue(undefined);
|
||
const mockDb = makeMockDb({
|
||
query: {
|
||
participants: {
|
||
findMany: vi.fn().mockResolvedValue([
|
||
{
|
||
id: "p-1",
|
||
name: "Player One",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
]),
|
||
},
|
||
},
|
||
delete: mockDelete,
|
||
where: mockWhere,
|
||
});
|
||
|
||
vi.mocked(isParticipantDrafted).mockResolvedValue(true);
|
||
|
||
await autoPickForTeam(
|
||
SEASON_ID,
|
||
TEAM_ID,
|
||
DRAFT_ROUNDS,
|
||
ALL_TEAM_IDS,
|
||
mockDb as any,
|
||
true
|
||
);
|
||
|
||
// Stale items should be cleaned from the queue
|
||
expect(mockDelete).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("partial queue — first item drafted, second available", () => {
|
||
it("skips drafted item and returns the next valid participant", async () => {
|
||
const queue = [
|
||
{ id: "q-1", participantId: "p-1", queuePosition: 1 },
|
||
{ id: "q-2", participantId: "p-2", queuePosition: 2 },
|
||
];
|
||
vi.mocked(getTeamQueue).mockResolvedValue(queue as any);
|
||
|
||
const mockDb = makeMockDb({
|
||
query: {
|
||
participants: {
|
||
findMany: vi.fn().mockResolvedValue([
|
||
{
|
||
id: "p-1",
|
||
name: "Player One",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
{
|
||
id: "p-2",
|
||
name: "Player Two",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
]),
|
||
},
|
||
},
|
||
});
|
||
|
||
// p-1 is already drafted, p-2 is available
|
||
vi.mocked(isParticipantDrafted).mockImplementation(
|
||
async (_seasonId, participantId) => participantId === "p-1"
|
||
);
|
||
|
||
const result = await autoPickForTeam(
|
||
SEASON_ID,
|
||
TEAM_ID,
|
||
DRAFT_ROUNDS,
|
||
ALL_TEAM_IDS,
|
||
mockDb as any,
|
||
true // queueOnly — still uses queue, just no EV fallback
|
||
);
|
||
|
||
expect(result).toBe("p-2");
|
||
});
|
||
|
||
it("returns the first available participant when none are drafted (happy path)", async () => {
|
||
const queue = [
|
||
{ id: "q-1", participantId: "p-1", queuePosition: 1 },
|
||
{ id: "q-2", participantId: "p-2", queuePosition: 2 },
|
||
];
|
||
vi.mocked(getTeamQueue).mockResolvedValue(queue as any);
|
||
|
||
const mockDb = makeMockDb({
|
||
query: {
|
||
participants: {
|
||
findMany: vi.fn().mockResolvedValue([
|
||
{
|
||
id: "p-1",
|
||
name: "Player One",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
{
|
||
id: "p-2",
|
||
name: "Player Two",
|
||
sportsSeason: { sport: { id: "sport-1", name: "NFL" } },
|
||
},
|
||
]),
|
||
},
|
||
},
|
||
});
|
||
|
||
vi.mocked(isParticipantDrafted).mockResolvedValue(false);
|
||
|
||
const result = await autoPickForTeam(
|
||
SEASON_ID,
|
||
TEAM_ID,
|
||
DRAFT_ROUNDS,
|
||
ALL_TEAM_IDS,
|
||
mockDb as any,
|
||
true
|
||
);
|
||
|
||
expect(result).toBe("p-1");
|
||
});
|
||
});
|
||
});
|