* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
762 lines
25 KiB
TypeScript
762 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);
|
||
if (!teamIdx) throw new Error(`No team for pick ${pickNum}`);
|
||
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", () => {
|
||
type AutodraftStatusEntry = { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean };
|
||
let autodraftStatus: Record<string, AutodraftStatusEntry> = {
|
||
"team-1": { isEnabled: false, mode: "next_pick", queueOnly: false },
|
||
"team-2": { isEnabled: false, mode: "next_pick", queueOnly: false },
|
||
};
|
||
|
||
// Simulate what happens on revalidation completion
|
||
const autodraftSettings = [
|
||
{ teamId: "team-1", isEnabled: true, mode: "while_on" as const, queueOnly: false },
|
||
{ teamId: "team-2", isEnabled: false, mode: "next_pick" as const, queueOnly: false },
|
||
{ teamId: "team-3", isEnabled: true, mode: "next_pick" as const, queueOnly: true },
|
||
];
|
||
|
||
autodraftStatus = {};
|
||
autodraftSettings.forEach((setting) => {
|
||
autodraftStatus[setting.teamId] = {
|
||
isEnabled: setting.isEnabled,
|
||
mode: setting.mode,
|
||
queueOnly: setting.queueOnly,
|
||
};
|
||
});
|
||
|
||
expect(autodraftStatus["team-1"].isEnabled).toBe(true);
|
||
expect(autodraftStatus["team-2"].isEnabled).toBe(false);
|
||
expect(autodraftStatus["team-3"].isEnabled).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);
|
||
});
|
||
});
|