285 lines
8.2 KiB
TypeScript
285 lines
8.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* Tests for draft-state-sync event handling on reconnection.
|
|||
|
|
*
|
|||
|
|
* Validates that after a mobile browser goes to background and returns,
|
|||
|
|
* the draft room properly syncs all state: picks, current pick number,
|
|||
|
|
* pause status, timers, and available participants.
|
|||
|
|
*
|
|||
|
|
* These tests verify the contract between useDraftSocket and the draft
|
|||
|
|
* room component's event handlers.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||
|
|
import { renderHook, act } from "@testing-library/react";
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// socket.io-client mock
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
type EventHandler = (...args: any[]) => void;
|
|||
|
|
|
|||
|
|
function makeSocketMock() {
|
|||
|
|
const socketHandlers: Record<string, EventHandler[]> = {};
|
|||
|
|
const managerHandlers: Record<string, EventHandler[]> = {};
|
|||
|
|
|
|||
|
|
const socketMock = {
|
|||
|
|
connected: false,
|
|||
|
|
id: "socket-test-id",
|
|||
|
|
on: vi.fn((event: string, handler: EventHandler) => {
|
|||
|
|
socketHandlers[event] ??= [];
|
|||
|
|
socketHandlers[event].push(handler);
|
|||
|
|
}),
|
|||
|
|
off: vi.fn((event: string, handler?: EventHandler) => {
|
|||
|
|
if (!handler) {
|
|||
|
|
socketHandlers[event] = [];
|
|||
|
|
} else {
|
|||
|
|
socketHandlers[event] = (socketHandlers[event] ?? []).filter(
|
|||
|
|
(h) => h !== handler
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}),
|
|||
|
|
emit: vi.fn(),
|
|||
|
|
connect: vi.fn(() => {
|
|||
|
|
socketMock.connected = true;
|
|||
|
|
setTimeout(() => {
|
|||
|
|
socketMock._trigger("connect");
|
|||
|
|
}, 0);
|
|||
|
|
}),
|
|||
|
|
disconnect: vi.fn(() => {
|
|||
|
|
socketMock.connected = false;
|
|||
|
|
}),
|
|||
|
|
io: {
|
|||
|
|
on: vi.fn((event: string, handler: EventHandler) => {
|
|||
|
|
managerHandlers[event] ??= [];
|
|||
|
|
managerHandlers[event].push(handler);
|
|||
|
|
}),
|
|||
|
|
off: vi.fn((event: string, handler?: EventHandler) => {
|
|||
|
|
if (!handler) {
|
|||
|
|
managerHandlers[event] = [];
|
|||
|
|
} else {
|
|||
|
|
managerHandlers[event] = (managerHandlers[event] ?? []).filter(
|
|||
|
|
(h) => h !== handler
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}),
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
_trigger(event: string, ...args: any[]) {
|
|||
|
|
(socketHandlers[event] ?? []).forEach((h) => h(...args));
|
|||
|
|
},
|
|||
|
|
_triggerManager(event: string, ...args: any[]) {
|
|||
|
|
(managerHandlers[event] ?? []).forEach((h) => h(...args));
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return socketMock;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type SocketMock = ReturnType<typeof makeSocketMock>;
|
|||
|
|
let socketMock: SocketMock;
|
|||
|
|
|
|||
|
|
vi.mock("socket.io-client", () => ({
|
|||
|
|
io: vi.fn(() => socketMock),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
import { useDraftSocket } from "../useDraftSocket";
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Helpers
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function renderDraftSocket(seasonId = "season-1", teamId?: string) {
|
|||
|
|
return renderHook(() => useDraftSocket(seasonId, teamId));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Simulate the full connect → disconnect → reconnect cycle */
|
|||
|
|
function doReconnectCycle(mock: SocketMock) {
|
|||
|
|
// Initial connect
|
|||
|
|
act(() => {
|
|||
|
|
mock.connected = true;
|
|||
|
|
mock._trigger("connect");
|
|||
|
|
});
|
|||
|
|
// Disconnect (mobile background / network loss)
|
|||
|
|
act(() => {
|
|||
|
|
mock.connected = false;
|
|||
|
|
mock._trigger("disconnect", "transport close");
|
|||
|
|
});
|
|||
|
|
// Reconnect
|
|||
|
|
act(() => {
|
|||
|
|
mock.connected = true;
|
|||
|
|
mock._trigger("connect");
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Tests
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
describe("useDraftSocket – draft-state-sync integration", () => {
|
|||
|
|
beforeEach(() => {
|
|||
|
|
socketMock = makeSocketMock();
|
|||
|
|
vi.useFakeTimers();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterEach(() => {
|
|||
|
|
vi.useRealTimers();
|
|||
|
|
vi.clearAllMocks();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("re-emits join-draft on reconnect, which triggers server to send draft-state-sync", () => {
|
|||
|
|
renderDraftSocket("season-42", "team-7");
|
|||
|
|
|
|||
|
|
doReconnectCycle(socketMock);
|
|||
|
|
|
|||
|
|
// join-draft should have been emitted on both initial connect and reconnect
|
|||
|
|
const joinCalls = socketMock.emit.mock.calls.filter(
|
|||
|
|
([ev]) => ev === "join-draft"
|
|||
|
|
);
|
|||
|
|
expect(joinCalls.length).toBe(2);
|
|||
|
|
expect(joinCalls[1]).toEqual(["join-draft", "season-42", "team-7"]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("increments reconnectCount on reconnect so draft room can trigger revalidation", () => {
|
|||
|
|
const { result } = renderDraftSocket("season-1", "team-1");
|
|||
|
|
|
|||
|
|
doReconnectCycle(socketMock);
|
|||
|
|
|
|||
|
|
expect(result.current.reconnectCount).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("allows consumer to register draft-state-sync handler via on()", () => {
|
|||
|
|
const { result } = renderDraftSocket("season-1", "team-1");
|
|||
|
|
|
|||
|
|
// Initial connect
|
|||
|
|
act(() => {
|
|||
|
|
socketMock.connected = true;
|
|||
|
|
socketMock._trigger("connect");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Consumer registers a handler for draft-state-sync
|
|||
|
|
const syncHandler = vi.fn();
|
|||
|
|
act(() => {
|
|||
|
|
result.current.on("draft-state-sync", syncHandler);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Server sends draft-state-sync
|
|||
|
|
const syncData = {
|
|||
|
|
currentPickNumber: 15,
|
|||
|
|
isPaused: false,
|
|||
|
|
status: "draft",
|
|||
|
|
picks: [
|
|||
|
|
{ id: "pick-1", pickNumber: 1, team: { id: "team-1" } },
|
|||
|
|
{ id: "pick-2", pickNumber: 2, team: { id: "team-2" } },
|
|||
|
|
],
|
|||
|
|
timers: [
|
|||
|
|
{ teamId: "team-1", timeRemaining: 90 },
|
|||
|
|
{ teamId: "team-2", timeRemaining: 120 },
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
act(() => {
|
|||
|
|
socketMock._trigger("draft-state-sync", syncData);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(syncHandler).toHaveBeenCalledWith(syncData);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("allows consumer to register timer-update handler that receives currentPickNumber", () => {
|
|||
|
|
const { result } = renderDraftSocket("season-1");
|
|||
|
|
|
|||
|
|
act(() => {
|
|||
|
|
socketMock.connected = true;
|
|||
|
|
socketMock._trigger("connect");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const timerHandler = vi.fn();
|
|||
|
|
act(() => {
|
|||
|
|
result.current.on("timer-update", timerHandler);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const timerData = {
|
|||
|
|
seasonId: "season-1",
|
|||
|
|
teamId: "team-2",
|
|||
|
|
timeRemaining: 85,
|
|||
|
|
currentPickNumber: 12,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
act(() => {
|
|||
|
|
socketMock._trigger("timer-update", timerData);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(timerHandler).toHaveBeenCalledWith(timerData);
|
|||
|
|
// The handler receives currentPickNumber which the draft room uses to sync
|
|||
|
|
expect(timerHandler.mock.calls[0][0].currentPickNumber).toBe(12);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles visibilitychange-based reconnect when socket is alive (zombie detection)", () => {
|
|||
|
|
const { result } = renderDraftSocket("season-1", "team-1");
|
|||
|
|
|
|||
|
|
// Initial connect
|
|||
|
|
act(() => {
|
|||
|
|
socketMock.connected = true;
|
|||
|
|
socketMock._trigger("connect");
|
|||
|
|
});
|
|||
|
|
expect(result.current.reconnectCount).toBe(0);
|
|||
|
|
|
|||
|
|
// Simulate mobile background return with still-alive socket
|
|||
|
|
act(() => {
|
|||
|
|
Object.defineProperty(document, "visibilityState", {
|
|||
|
|
value: "visible",
|
|||
|
|
configurable: true,
|
|||
|
|
});
|
|||
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Should increment reconnectCount even though socket didn't disconnect
|
|||
|
|
// This is critical for triggering revalidation
|
|||
|
|
expect(result.current.reconnectCount).toBe(1);
|
|||
|
|
|
|||
|
|
// join-draft re-emitted
|
|||
|
|
const joinCalls = socketMock.emit.mock.calls.filter(
|
|||
|
|
([ev]) => ev === "join-draft"
|
|||
|
|
);
|
|||
|
|
expect(joinCalls.length).toBe(2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles long background period: disconnect → many seconds → visibilitychange → reconnect", () => {
|
|||
|
|
const { result } = renderDraftSocket("season-1", "team-1");
|
|||
|
|
|
|||
|
|
// Initial connect
|
|||
|
|
act(() => {
|
|||
|
|
socketMock.connected = true;
|
|||
|
|
socketMock._trigger("connect");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Disconnect (OS suspends the app)
|
|||
|
|
act(() => {
|
|||
|
|
socketMock.connected = false;
|
|||
|
|
socketMock._trigger("disconnect", "ping timeout");
|
|||
|
|
});
|
|||
|
|
expect(result.current.isConnected).toBe(false);
|
|||
|
|
expect(result.current.isReconnecting).toBe(true);
|
|||
|
|
|
|||
|
|
// Much later: tab becomes visible (user opens phone)
|
|||
|
|
act(() => {
|
|||
|
|
Object.defineProperty(document, "visibilityState", {
|
|||
|
|
value: "visible",
|
|||
|
|
configurable: true,
|
|||
|
|
});
|
|||
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Socket was dead → connect() should have been called
|
|||
|
|
expect(socketMock.connect).toHaveBeenCalled();
|
|||
|
|
|
|||
|
|
// Simulate the delayed connect event
|
|||
|
|
act(() => {
|
|||
|
|
vi.runAllTimers();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// After connect: reconnectCount incremented
|
|||
|
|
expect(result.current.reconnectCount).toBe(1);
|
|||
|
|
expect(result.current.isConnected).toBe(true);
|
|||
|
|
});
|
|||
|
|
});
|