Add a reference-equality check so that if HTTP revalidation fails (network error, expired token), the sync effect does not overwrite fresh data that draft-state-sync already applied. Also adds missing dependency array entries (userQueue, timers, autodraftSettings) to the revalidation sync effect. https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms (#48) * fix: sync all draft state on mobile reconnection After a mobile browser returns from a long background period, the draft room had stale picks, wrong "on the clock" display, and inaccurate available player lists. The root cause was that reconnection relied solely on an HTTP revalidation that could fail (expired JWT, flaky network), and the timer-update handler ignored currentPickNumber. Changes: - Server emits draft-state-sync on join-draft with full picks, timers, and season state, giving the client an immediate socket-based sync path that doesn't depend on HTTP revalidation - timer-update handler now syncs currentPickNumber, fixing the "on the clock" display within 1 second of reconnection - Revalidation retry with 3s delay ensures the HTTP path succeeds even when the network is slow to stabilize on mobile return - Revalidation completion now also syncs teamTimers and autodraftStatus - Added draft-state-sync client handler that applies the server snapshot immediately (skipped when revalidation is in-flight to avoid conflicts) Tests: 32 new tests covering reconnection sync, pick buffering/merge, timer-update currentPickNumber sync, draft-state-sync handling, available player filtering, on-the-clock correctness, and revalidation retry logic. https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms * fix: guard against stale revalidation overwriting fresh socket data Add a reference-equality check so that if HTTP revalidation fails (network error, expired token), the sync effect does not overwrite fresh data that draft-state-sync already applied. Also adds missing dependency array entries (userQueue, timers, autodraftSettings) to the revalidation sync effect. https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms --------- Co-authored-by: Claude <noreply@anthropic.com>
756 lines
25 KiB
TypeScript
756 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);
|
||
});
|
||
});
|