757 lines
25 KiB
TypeScript
757 lines
25 KiB
TypeScript
|
|
/**
|
|||
|
|
* Tests for draft room reconnection state synchronization.
|
|||
|
|
*
|
|||
|
|
* These tests validate the core sync logic that runs when a mobile user
|
|||
|
|
* returns from a long background period. They exercise the handler functions
|
|||
|
|
* and state sync patterns in isolation, verifying:
|
|||
|
|
*
|
|||
|
|
* 1. All picks made during the offline window are visible after reconnection
|
|||
|
|
* 2. The correct person is shown as "on the clock"
|
|||
|
|
* 3. Available players list is accurate (drafted players excluded)
|
|||
|
|
* 4. Timer-update events immediately sync currentPickNumber
|
|||
|
|
* 5. draft-state-sync event provides a full snapshot for instant sync
|
|||
|
|
* 6. Revalidation retry ensures the HTTP path also succeeds
|
|||
|
|
* 7. Buffered picks during revalidation are properly merged
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
|
|
import { getTeamForPick } from "~/lib/draft-order";
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Fixtures
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function makeDraftSlots(count: number) {
|
|||
|
|
return Array.from({ length: count }, (_, i) => ({
|
|||
|
|
id: `slot-${i + 1}`,
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
teamId: `team-${i + 1}`,
|
|||
|
|
draftOrder: i + 1,
|
|||
|
|
team: {
|
|||
|
|
id: `team-${i + 1}`,
|
|||
|
|
name: `Team ${i + 1}`,
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
ownerId: `user-${i + 1}`,
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makePick(pickNumber: number, teamId: string, participantId: string) {
|
|||
|
|
const teamCount = 4; // default for tests
|
|||
|
|
const round = Math.ceil(pickNumber / teamCount);
|
|||
|
|
const pickInRound = ((pickNumber - 1) % teamCount) + 1;
|
|||
|
|
return {
|
|||
|
|
id: `pick-${pickNumber}`,
|
|||
|
|
pickNumber,
|
|||
|
|
round,
|
|||
|
|
pickInRound,
|
|||
|
|
timeUsed: 100,
|
|||
|
|
team: {
|
|||
|
|
id: teamId,
|
|||
|
|
name: `Team ${teamId.split("-")[1]}`,
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
ownerId: `user-${teamId.split("-")[1]}`,
|
|||
|
|
},
|
|||
|
|
participant: {
|
|||
|
|
id: participantId,
|
|||
|
|
name: `Player ${participantId.split("-")[1]}`,
|
|||
|
|
},
|
|||
|
|
sport: { id: "sport-1", name: "NFL" },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeParticipant(id: string, name: string, sportId = "sport-1", sportName = "NFL") {
|
|||
|
|
return { id, name, sport: { id: sportId, name: sportName } };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 1. draft-order helper validates correctly for snake draft
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("getTeamForPick – snake draft order", () => {
|
|||
|
|
const draftSlots = makeDraftSlots(4);
|
|||
|
|
|
|||
|
|
it("round 1 (odd): picks go 1→4", () => {
|
|||
|
|
expect(getTeamForPick(1, draftSlots)?.team.id).toBe("team-1");
|
|||
|
|
expect(getTeamForPick(2, draftSlots)?.team.id).toBe("team-2");
|
|||
|
|
expect(getTeamForPick(3, draftSlots)?.team.id).toBe("team-3");
|
|||
|
|
expect(getTeamForPick(4, draftSlots)?.team.id).toBe("team-4");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("round 2 (even): picks go 4→1 (snake)", () => {
|
|||
|
|
expect(getTeamForPick(5, draftSlots)?.team.id).toBe("team-4");
|
|||
|
|
expect(getTeamForPick(6, draftSlots)?.team.id).toBe("team-3");
|
|||
|
|
expect(getTeamForPick(7, draftSlots)?.team.id).toBe("team-2");
|
|||
|
|
expect(getTeamForPick(8, draftSlots)?.team.id).toBe("team-1");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("round 3 (odd): picks go 1→4 again", () => {
|
|||
|
|
expect(getTeamForPick(9, draftSlots)?.team.id).toBe("team-1");
|
|||
|
|
expect(getTeamForPick(12, draftSlots)?.team.id).toBe("team-4");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 2. timer-update handler syncs currentPickNumber
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("timer-update handler – currentPickNumber sync", () => {
|
|||
|
|
it("updates currentPick from timer-update data", () => {
|
|||
|
|
let currentPick = 5;
|
|||
|
|
const setCurrentPick = (value: number) => { currentPick = value; };
|
|||
|
|
let teamTimers: Record<string, number> = {};
|
|||
|
|
const setTeamTimers = (fn: (prev: Record<string, number>) => Record<string, number>) => {
|
|||
|
|
teamTimers = fn(teamTimers);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Simulate the timer-update handler from the draft room component
|
|||
|
|
const handleTimerUpdate = (data: any) => {
|
|||
|
|
setTeamTimers((prev) => ({
|
|||
|
|
...prev,
|
|||
|
|
[data.teamId]: data.timeRemaining,
|
|||
|
|
}));
|
|||
|
|
if (data.currentPickNumber != null) {
|
|||
|
|
setCurrentPick(data.currentPickNumber);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Timer update for pick 25 (user was away, picks advanced from 5 to 25)
|
|||
|
|
handleTimerUpdate({
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
teamId: "team-2",
|
|||
|
|
timeRemaining: 85,
|
|||
|
|
currentPickNumber: 25,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(currentPick).toBe(25);
|
|||
|
|
expect(teamTimers["team-2"]).toBe(85);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("does not update currentPick when currentPickNumber is null", () => {
|
|||
|
|
let currentPick = 5;
|
|||
|
|
const setCurrentPick = (value: number) => { currentPick = value; };
|
|||
|
|
|
|||
|
|
const handleTimerUpdate = (data: any) => {
|
|||
|
|
if (data.currentPickNumber != null) {
|
|||
|
|
setCurrentPick(data.currentPickNumber);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
handleTimerUpdate({
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
teamId: "team-1",
|
|||
|
|
timeRemaining: 100,
|
|||
|
|
currentPickNumber: null,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(currentPick).toBe(5);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 3. draft-state-sync handler – full state snapshot
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("draft-state-sync handler – full state sync on reconnection", () => {
|
|||
|
|
it("syncs picks, currentPick, isPaused, and isDraftComplete from server snapshot", () => {
|
|||
|
|
// Initial state (stale – from before mobile background)
|
|||
|
|
let picks = [makePick(1, "team-1", "player-1")];
|
|||
|
|
let currentPick = 2;
|
|||
|
|
let isPaused = false;
|
|||
|
|
let isDraftComplete = false;
|
|||
|
|
let teamTimers: Record<string, number> = { "team-1": 100, "team-2": 120 };
|
|||
|
|
|
|||
|
|
const setPicks = (value: any) => { picks = value; };
|
|||
|
|
const setCurrentPick = (value: number) => { currentPick = value; };
|
|||
|
|
const setIsPaused = (value: boolean) => { isPaused = value; };
|
|||
|
|
const setIsDraftComplete = (value: boolean) => { isDraftComplete = value; };
|
|||
|
|
const setTeamTimers = (fn: (prev: Record<string, number>) => Record<string, number>) => {
|
|||
|
|
teamTimers = fn(teamTimers);
|
|||
|
|
};
|
|||
|
|
const isRevalidatingRef = { current: false };
|
|||
|
|
|
|||
|
|
// Simulate the draft-state-sync handler
|
|||
|
|
const handleDraftStateSync = (data: any) => {
|
|||
|
|
if (!isRevalidatingRef.current) {
|
|||
|
|
setPicks(data.picks);
|
|||
|
|
setCurrentPick(data.currentPickNumber);
|
|||
|
|
setIsPaused(data.isPaused);
|
|||
|
|
setIsDraftComplete(
|
|||
|
|
data.status === "active" || data.status === "completed"
|
|||
|
|
);
|
|||
|
|
if (data.timers) {
|
|||
|
|
setTeamTimers((prev) => {
|
|||
|
|
const updated = { ...prev };
|
|||
|
|
data.timers.forEach((t: any) => {
|
|||
|
|
updated[t.teamId] = t.timeRemaining;
|
|||
|
|
});
|
|||
|
|
return updated;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Server sends full state after join-draft on reconnect
|
|||
|
|
const serverPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
makePick(4, "team-4", "player-4"),
|
|||
|
|
makePick(5, "team-4", "player-5"),
|
|||
|
|
makePick(6, "team-3", "player-6"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
handleDraftStateSync({
|
|||
|
|
currentPickNumber: 7,
|
|||
|
|
isPaused: false,
|
|||
|
|
status: "draft",
|
|||
|
|
picks: serverPicks,
|
|||
|
|
timers: [
|
|||
|
|
{ teamId: "team-1", timeRemaining: 45 },
|
|||
|
|
{ teamId: "team-2", timeRemaining: 80 },
|
|||
|
|
{ teamId: "team-3", timeRemaining: 60 },
|
|||
|
|
{ teamId: "team-4", timeRemaining: 55 },
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// All 6 picks should be synced
|
|||
|
|
expect(picks).toHaveLength(6);
|
|||
|
|
expect(picks[5].pickNumber).toBe(6);
|
|||
|
|
|
|||
|
|
// Current pick should be 7 (next pick)
|
|||
|
|
expect(currentPick).toBe(7);
|
|||
|
|
|
|||
|
|
// Paused and complete status synced
|
|||
|
|
expect(isPaused).toBe(false);
|
|||
|
|
expect(isDraftComplete).toBe(false);
|
|||
|
|
|
|||
|
|
// Timers synced
|
|||
|
|
expect(teamTimers["team-1"]).toBe(45);
|
|||
|
|
expect(teamTimers["team-2"]).toBe(80);
|
|||
|
|
expect(teamTimers["team-3"]).toBe(60);
|
|||
|
|
expect(teamTimers["team-4"]).toBe(55);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("sets isDraftComplete when status is 'active'", () => {
|
|||
|
|
let isDraftComplete = false;
|
|||
|
|
const setIsDraftComplete = (value: boolean) => { isDraftComplete = value; };
|
|||
|
|
const isRevalidatingRef = { current: false };
|
|||
|
|
|
|||
|
|
const handleDraftStateSync = (data: any) => {
|
|||
|
|
if (!isRevalidatingRef.current) {
|
|||
|
|
setIsDraftComplete(
|
|||
|
|
data.status === "active" || data.status === "completed"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
handleDraftStateSync({
|
|||
|
|
currentPickNumber: 81,
|
|||
|
|
isPaused: false,
|
|||
|
|
status: "active",
|
|||
|
|
picks: [],
|
|||
|
|
timers: [],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(isDraftComplete).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("skips sync when revalidation is in-flight", () => {
|
|||
|
|
let picks = [makePick(1, "team-1", "player-1")];
|
|||
|
|
const setPicks = (value: any) => { picks = value; };
|
|||
|
|
const isRevalidatingRef = { current: true }; // revalidation in progress
|
|||
|
|
|
|||
|
|
const handleDraftStateSync = (data: any) => {
|
|||
|
|
if (!isRevalidatingRef.current) {
|
|||
|
|
setPicks(data.picks);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
handleDraftStateSync({
|
|||
|
|
currentPickNumber: 7,
|
|||
|
|
isPaused: false,
|
|||
|
|
status: "draft",
|
|||
|
|
picks: [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
],
|
|||
|
|
timers: [],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Picks should NOT be updated because revalidation is in-flight
|
|||
|
|
expect(picks).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 4. Revalidation sync merges picks correctly
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("revalidation sync – pick merge with buffering", () => {
|
|||
|
|
it("merges DB snapshot with buffered picks from socket events during revalidation", () => {
|
|||
|
|
const dbPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// A pick that arrived via socket during the revalidation window
|
|||
|
|
const bufferedPick = makePick(4, "team-4", "player-4");
|
|||
|
|
|
|||
|
|
const dbPickIds = new Set(dbPicks.map((p) => p.id));
|
|||
|
|
const pendingPicks = [bufferedPick];
|
|||
|
|
|
|||
|
|
// Merge logic from the sync effect
|
|||
|
|
const missedPicks = pendingPicks.filter((p) => !dbPickIds.has(p.id));
|
|||
|
|
const mergedPicks = [...dbPicks, ...missedPicks];
|
|||
|
|
|
|||
|
|
expect(mergedPicks).toHaveLength(4);
|
|||
|
|
expect(mergedPicks[3].pickNumber).toBe(4);
|
|||
|
|
expect(mergedPicks[3].participant.id).toBe("player-4");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("deduplicates picks that are in both DB snapshot and buffer", () => {
|
|||
|
|
const dbPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// The same pick arrived via socket AND is in the DB snapshot
|
|||
|
|
const bufferedPick = makePick(3, "team-3", "player-3");
|
|||
|
|
|
|||
|
|
const dbPickIds = new Set(dbPicks.map((p) => p.id));
|
|||
|
|
const pendingPicks = [bufferedPick];
|
|||
|
|
|
|||
|
|
const missedPicks = pendingPicks.filter((p) => !dbPickIds.has(p.id));
|
|||
|
|
const mergedPicks = [...dbPicks, ...missedPicks];
|
|||
|
|
|
|||
|
|
// No duplicates – pick 3 appears only once
|
|||
|
|
expect(mergedPicks).toHaveLength(3);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles empty buffer (no socket events during revalidation)", () => {
|
|||
|
|
const dbPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const pendingPicks: any[] = [];
|
|||
|
|
const dbPickIds = new Set(dbPicks.map((p) => p.id));
|
|||
|
|
const missedPicks = pendingPicks.filter((p) => !dbPickIds.has(p.id));
|
|||
|
|
const mergedPicks = [...dbPicks, ...missedPicks];
|
|||
|
|
|
|||
|
|
expect(mergedPicks).toHaveLength(2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles multiple buffered picks during slow revalidation", () => {
|
|||
|
|
const dbPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Three picks arrived while revalidation was slow
|
|||
|
|
const bufferedPicks = [
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
makePick(4, "team-4", "player-4"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const dbPickIds = new Set(dbPicks.map((p) => p.id));
|
|||
|
|
const missedPicks = bufferedPicks.filter((p) => !dbPickIds.has(p.id));
|
|||
|
|
const mergedPicks = [...dbPicks, ...missedPicks];
|
|||
|
|
|
|||
|
|
expect(mergedPicks).toHaveLength(4);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 4b. Failed revalidation staleness guard
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("revalidation sync – staleness guard on failed fetch", () => {
|
|||
|
|
it("skips state overwrite when draftPicks reference is unchanged (fetch failed)", () => {
|
|||
|
|
// Initial loader data (stale — from before mobile background)
|
|||
|
|
const staleDraftPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Simulated state that draft-state-sync already updated to fresh data
|
|||
|
|
let picks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
];
|
|||
|
|
let currentPick = 4;
|
|||
|
|
const setPicks = (value: any) => { picks = value; };
|
|||
|
|
const setCurrentPick = (value: number) => { currentPick = value; };
|
|||
|
|
|
|||
|
|
// Track the draftPicks reference at revalidation start
|
|||
|
|
const draftPicksAtRevalidationStart = staleDraftPicks;
|
|||
|
|
|
|||
|
|
// Simulate: revalidation completes but draftPicks is the SAME reference
|
|||
|
|
// (loader fetch failed, React Router didn't update the data)
|
|||
|
|
const draftPicksAfterRevalidation = staleDraftPicks; // same reference
|
|||
|
|
|
|||
|
|
if (draftPicksAfterRevalidation === draftPicksAtRevalidationStart) {
|
|||
|
|
// Guard fires — skip the overwrite
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// This code should NOT run
|
|||
|
|
setPicks(draftPicksAfterRevalidation);
|
|||
|
|
setCurrentPick(1);
|
|||
|
|
|
|||
|
|
// If guard worked, these should still be the fresh values
|
|||
|
|
expect(picks).toHaveLength(3);
|
|||
|
|
expect(currentPick).toBe(4);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("applies state overwrite when draftPicks reference changes (fetch succeeded)", () => {
|
|||
|
|
const staleDraftPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
let picks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
];
|
|||
|
|
let currentPick = 4;
|
|||
|
|
const setPicks = (value: any) => { picks = value; };
|
|||
|
|
const setCurrentPick = (value: number) => { currentPick = value; };
|
|||
|
|
|
|||
|
|
const draftPicksAtRevalidationStart = staleDraftPicks;
|
|||
|
|
|
|||
|
|
// Simulate: revalidation completes with NEW data (different reference)
|
|||
|
|
const freshDraftPicks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
makePick(4, "team-4", "player-4"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
if (freshDraftPicks === draftPicksAtRevalidationStart) {
|
|||
|
|
return; // Guard fires — but won't in this case
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Merge logic runs — apply fresh data
|
|||
|
|
const dbPickIds = new Set(freshDraftPicks.map((p) => p.id));
|
|||
|
|
const bufferedPicks: any[] = [];
|
|||
|
|
const missedPicks = bufferedPicks.filter((p: any) => !dbPickIds.has(p.id));
|
|||
|
|
setPicks([...freshDraftPicks, ...missedPicks]);
|
|||
|
|
setCurrentPick(5);
|
|||
|
|
|
|||
|
|
expect(picks).toHaveLength(4);
|
|||
|
|
expect(currentPick).toBe(5);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 5. Available players accuracy after reconnection
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("available players filtering after reconnection", () => {
|
|||
|
|
const allParticipants = [
|
|||
|
|
makeParticipant("player-1", "Patrick Mahomes"),
|
|||
|
|
makeParticipant("player-2", "Josh Allen"),
|
|||
|
|
makeParticipant("player-3", "Lamar Jackson"),
|
|||
|
|
makeParticipant("player-4", "Jalen Hurts"),
|
|||
|
|
makeParticipant("player-5", "Joe Burrow"),
|
|||
|
|
makeParticipant("player-6", "Dak Prescott"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
it("correctly filters drafted players from available list after sync", () => {
|
|||
|
|
// After reconnection, picks include players 1-3
|
|||
|
|
const picks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const draftedIds = new Set(picks.map((p) => p.participant.id));
|
|||
|
|
const available = allParticipants.filter((p) => !draftedIds.has(p.id));
|
|||
|
|
|
|||
|
|
expect(available).toHaveLength(3);
|
|||
|
|
expect(available.map((p) => p.name)).toEqual([
|
|||
|
|
"Jalen Hurts",
|
|||
|
|
"Joe Burrow",
|
|||
|
|
"Dak Prescott",
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("shows all players as available when picks are empty (draft just started)", () => {
|
|||
|
|
const picks: any[] = [];
|
|||
|
|
const draftedIds = new Set(picks.map((p: any) => p.participant.id));
|
|||
|
|
const available = allParticipants.filter((p) => !draftedIds.has(p.id));
|
|||
|
|
|
|||
|
|
expect(available).toHaveLength(6);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("shows no players as available when all are drafted", () => {
|
|||
|
|
const picks = allParticipants.map((p, i) =>
|
|||
|
|
makePick(i + 1, `team-${(i % 4) + 1}`, p.id)
|
|||
|
|
);
|
|||
|
|
const draftedIds = new Set(picks.map((p) => p.participant.id));
|
|||
|
|
const available = allParticipants.filter((p) => !draftedIds.has(p.id));
|
|||
|
|
|
|||
|
|
expect(available).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 6. "On the clock" correctness after reconnection
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("on-the-clock display after reconnection", () => {
|
|||
|
|
const draftSlots = makeDraftSlots(4);
|
|||
|
|
|
|||
|
|
it("shows correct team on clock after timer-update syncs currentPickNumber", () => {
|
|||
|
|
// User was disconnected at pick 5 (Team 4's turn in round 2)
|
|||
|
|
// 20 picks happened, now at pick 25 (which is round 7, pick 1 → Team 1)
|
|||
|
|
const currentPickNumber = 25;
|
|||
|
|
const currentSlot = getTeamForPick(currentPickNumber, draftSlots);
|
|||
|
|
|
|||
|
|
expect(currentSlot?.team.id).toBe("team-1");
|
|||
|
|
expect(currentSlot?.team.name).toBe("Team 1");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("shows correct team after draft-state-sync provides currentPickNumber", () => {
|
|||
|
|
// Server says currentPickNumber is 13 (round 4, even, pick 1 → team 4)
|
|||
|
|
const currentPickNumber = 13;
|
|||
|
|
const currentSlot = getTeamForPick(currentPickNumber, draftSlots);
|
|||
|
|
|
|||
|
|
expect(currentSlot?.team.id).toBe("team-4");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles edge case: currentPickNumber = 1 (draft just started)", () => {
|
|||
|
|
const currentSlot = getTeamForPick(1, draftSlots);
|
|||
|
|
expect(currentSlot?.team.id).toBe("team-1");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles last pick of a round correctly", () => {
|
|||
|
|
// Pick 4 = last pick of round 1 (team 4)
|
|||
|
|
expect(getTeamForPick(4, draftSlots)?.team.id).toBe("team-4");
|
|||
|
|
// Pick 8 = last pick of round 2 (team 1, snake)
|
|||
|
|
expect(getTeamForPick(8, draftSlots)?.team.id).toBe("team-1");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 7. Revalidation retry mechanism
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("revalidation retry on reconnection", () => {
|
|||
|
|
it("calls revalidate immediately and schedules a retry", () => {
|
|||
|
|
const revalidate = vi.fn();
|
|||
|
|
let reconnectCount = 0;
|
|||
|
|
|
|||
|
|
// Simulate the effect logic
|
|||
|
|
const runEffect = () => {
|
|||
|
|
if (reconnectCount > 0) {
|
|||
|
|
revalidate();
|
|||
|
|
// Schedule retry
|
|||
|
|
setTimeout(() => revalidate(), 3000);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// First reconnect
|
|||
|
|
reconnectCount = 1;
|
|||
|
|
runEffect();
|
|||
|
|
|
|||
|
|
expect(revalidate).toHaveBeenCalledTimes(1);
|
|||
|
|
|
|||
|
|
// After 3 seconds, retry fires
|
|||
|
|
vi.advanceTimersByTime(3000);
|
|||
|
|
expect(revalidate).toHaveBeenCalledTimes(2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("clears pending retry when a new reconnect happens", () => {
|
|||
|
|
const revalidate = vi.fn();
|
|||
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|||
|
|
|
|||
|
|
const runEffect = (reconnectCount: number) => {
|
|||
|
|
if (reconnectCount > 0) {
|
|||
|
|
revalidate();
|
|||
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|||
|
|
timeoutId = setTimeout(() => revalidate(), 3000);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// First reconnect
|
|||
|
|
runEffect(1);
|
|||
|
|
expect(revalidate).toHaveBeenCalledTimes(1);
|
|||
|
|
|
|||
|
|
// Second reconnect before retry fires
|
|||
|
|
vi.advanceTimersByTime(1000);
|
|||
|
|
runEffect(2);
|
|||
|
|
expect(revalidate).toHaveBeenCalledTimes(2);
|
|||
|
|
|
|||
|
|
// After 3s from second reconnect, only one retry fires (not two)
|
|||
|
|
vi.advanceTimersByTime(3000);
|
|||
|
|
expect(revalidate).toHaveBeenCalledTimes(3);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
vi.useFakeTimers();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 8. Full reconnection scenario end-to-end (state simulation)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("full mobile reconnection scenario", () => {
|
|||
|
|
it("correctly syncs all state after 20 picks made during mobile background", () => {
|
|||
|
|
const draftSlots = makeDraftSlots(4);
|
|||
|
|
|
|||
|
|
// STATE BEFORE MOBILE BACKGROUND (picks 1-4 made)
|
|||
|
|
let picks = [
|
|||
|
|
makePick(1, "team-1", "player-1"),
|
|||
|
|
makePick(2, "team-2", "player-2"),
|
|||
|
|
makePick(3, "team-3", "player-3"),
|
|||
|
|
makePick(4, "team-4", "player-4"),
|
|||
|
|
];
|
|||
|
|
let currentPick = 5;
|
|||
|
|
let teamTimers: Record<string, number> = {
|
|||
|
|
"team-1": 100,
|
|||
|
|
"team-2": 100,
|
|||
|
|
"team-3": 100,
|
|||
|
|
"team-4": 100,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// --- USER GOES TO MOBILE BACKGROUND ---
|
|||
|
|
// 20 more picks happen (picks 5-24), now on pick 25
|
|||
|
|
|
|||
|
|
// --- USER RETURNS FROM BACKGROUND ---
|
|||
|
|
|
|||
|
|
// Step 1: Socket reconnects, server sends draft-state-sync
|
|||
|
|
const serverPicks = Array.from({ length: 24 }, (_, i) => {
|
|||
|
|
const pickNum = i + 1;
|
|||
|
|
const teamIdx = getTeamForPick(pickNum, draftSlots);
|
|||
|
|
return makePick(pickNum, teamIdx!.team.id, `player-${pickNum}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Simulate draft-state-sync handler
|
|||
|
|
picks = serverPicks;
|
|||
|
|
currentPick = 25;
|
|||
|
|
teamTimers = {
|
|||
|
|
"team-1": 45,
|
|||
|
|
"team-2": 80,
|
|||
|
|
"team-3": 60,
|
|||
|
|
"team-4": 55,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// VERIFY: All 24 picks are now visible
|
|||
|
|
expect(picks).toHaveLength(24);
|
|||
|
|
|
|||
|
|
// VERIFY: Correct person on the clock (pick 25 = round 7, odd, pick 1 → team 1)
|
|||
|
|
const onClock = getTeamForPick(currentPick, draftSlots);
|
|||
|
|
expect(onClock?.team.id).toBe("team-1");
|
|||
|
|
|
|||
|
|
// VERIFY: Available players excludes drafted ones
|
|||
|
|
const allParticipants = Array.from({ length: 40 }, (_, i) =>
|
|||
|
|
makeParticipant(`player-${i + 1}`, `Player ${i + 1}`)
|
|||
|
|
);
|
|||
|
|
const draftedIds = new Set(picks.map((p) => p.participant.id));
|
|||
|
|
const available = allParticipants.filter((p) => !draftedIds.has(p.id));
|
|||
|
|
expect(available).toHaveLength(16); // 40 - 24 = 16
|
|||
|
|
|
|||
|
|
// VERIFY: Timer state is fresh
|
|||
|
|
expect(teamTimers["team-1"]).toBe(45);
|
|||
|
|
|
|||
|
|
// Step 2: Timer-update arrives 1 second later
|
|||
|
|
// This would also sync currentPickNumber (belt-and-suspenders)
|
|||
|
|
const timerUpdate = {
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
teamId: "team-1",
|
|||
|
|
timeRemaining: 44,
|
|||
|
|
currentPickNumber: 25,
|
|||
|
|
};
|
|||
|
|
teamTimers = { ...teamTimers, [timerUpdate.teamId]: timerUpdate.timeRemaining };
|
|||
|
|
currentPick = timerUpdate.currentPickNumber;
|
|||
|
|
|
|||
|
|
expect(currentPick).toBe(25);
|
|||
|
|
expect(teamTimers["team-1"]).toBe(44);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles reconnection during a paused draft", () => {
|
|||
|
|
let isPaused = false;
|
|||
|
|
let currentPick = 5;
|
|||
|
|
|
|||
|
|
// Server sends sync with paused state
|
|||
|
|
isPaused = true;
|
|||
|
|
currentPick = 10;
|
|||
|
|
|
|||
|
|
expect(isPaused).toBe(true);
|
|||
|
|
expect(currentPick).toBe(10);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles reconnection after draft completed", () => {
|
|||
|
|
let isDraftComplete = false;
|
|||
|
|
let picks: any[] = [];
|
|||
|
|
|
|||
|
|
// Server sends sync with completed status
|
|||
|
|
const totalPicks = 80; // 4 teams × 20 rounds
|
|||
|
|
picks = Array.from({ length: totalPicks }, (_, i) =>
|
|||
|
|
makePick(i + 1, `team-${(i % 4) + 1}`, `player-${i + 1}`)
|
|||
|
|
);
|
|||
|
|
isDraftComplete = true;
|
|||
|
|
|
|||
|
|
expect(isDraftComplete).toBe(true);
|
|||
|
|
expect(picks).toHaveLength(80);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 9. Autodraft and timer sync on revalidation
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("autodraft and timer sync on revalidation completion", () => {
|
|||
|
|
it("syncs autodraftStatus from loader data on revalidation", () => {
|
|||
|
|
let autodraftStatus: Record<string, boolean> = {
|
|||
|
|
"team-1": false,
|
|||
|
|
"team-2": false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Simulate what happens on revalidation completion
|
|||
|
|
const autodraftSettings = [
|
|||
|
|
{ teamId: "team-1", isEnabled: true },
|
|||
|
|
{ teamId: "team-2", isEnabled: false },
|
|||
|
|
{ teamId: "team-3", isEnabled: true },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
autodraftStatus = {};
|
|||
|
|
autodraftSettings.forEach((setting) => {
|
|||
|
|
autodraftStatus[setting.teamId] = setting.isEnabled;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(autodraftStatus["team-1"]).toBe(true);
|
|||
|
|
expect(autodraftStatus["team-2"]).toBe(false);
|
|||
|
|
expect(autodraftStatus["team-3"]).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("syncs teamTimers from loader data on revalidation", () => {
|
|||
|
|
let teamTimers: Record<string, number> = {
|
|||
|
|
"team-1": 100,
|
|||
|
|
"team-2": 100,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const timers = [
|
|||
|
|
{ teamId: "team-1", timeRemaining: 45 },
|
|||
|
|
{ teamId: "team-2", timeRemaining: 80 },
|
|||
|
|
{ teamId: "team-3", timeRemaining: 120 },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Simulate the sync logic
|
|||
|
|
const updated = { ...teamTimers };
|
|||
|
|
timers.forEach((timer) => {
|
|||
|
|
updated[timer.teamId] = timer.timeRemaining;
|
|||
|
|
});
|
|||
|
|
teamTimers = updated;
|
|||
|
|
|
|||
|
|
expect(teamTimers["team-1"]).toBe(45);
|
|||
|
|
expect(teamTimers["team-2"]).toBe(80);
|
|||
|
|
expect(teamTimers["team-3"]).toBe(120);
|
|||
|
|
});
|
|||
|
|
});
|