Cockatrice/webclient/src/containers/Game/useGameDnd.spec.ts
2026-04-25 14:42:41 -05:00

328 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { renderHook } from '@testing-library/react';
import type { DragEndEvent } from '@dnd-kit/core';
import { App, Data } from '@app/types';
import { makeCard } from '../../store/game/__mocks__/fixtures';
import {
CARD_WIDTH_PX,
MARGIN_LEFT_PX,
PADDING_X_PX,
STACKED_CARD_OFFSET_X_PX,
} from '../../components/Game/Battlefield/gridMath';
const { mockUseWebClient } = vi.hoisted(() => ({ mockUseWebClient: vi.fn() }));
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return { ...actual, useWebClient: mockUseWebClient };
});
import { useGameDnd } from './useGameDnd';
// Build a minimal DragEndEvent with only the fields useGameDnd reads. The
// real dnd-kit Event type has many more fields; we cast via `unknown` so
// tests stay tied to actual handler inputs, not dnd-kit internals.
type DropShape = {
sourceCard: Data.ServerInfo_Card;
sourcePlayerId: number;
sourceZone: string;
targetPlayerId: number;
targetZone: string;
targetRow?: number;
rowCards?: Data.ServerInfo_Card[];
// Drop-point is expressed as the card center's x relative to the row's
// content edge (same convention as gridMath.mapToGridX).
pointerXInRow?: number;
attachTarget?: boolean;
targetCardId?: number;
};
const ROW_LEFT = 500;
const CARD_WIDTH_ON_SCREEN = CARD_WIDTH_PX;
function buildEvent(d: DropShape): DragEndEvent {
const pointerXInRow = d.pointerXInRow ?? 0;
// Reverse the math computePointerXInRow does: cardCenterX - overRect.left - MARGIN_LEFT.
const cardCenterXAbs = pointerXInRow + ROW_LEFT + MARGIN_LEFT_PX;
const activeLeft = cardCenterXAbs - CARD_WIDTH_ON_SCREEN / 2;
return {
active: {
id: d.sourceCard.id,
data: {
current: {
card: d.sourceCard,
sourcePlayerId: d.sourcePlayerId,
sourceZone: d.sourceZone,
},
},
rect: {
current: {
translated: {
left: activeLeft,
top: 0,
width: CARD_WIDTH_ON_SCREEN,
height: 204,
right: activeLeft + CARD_WIDTH_ON_SCREEN,
bottom: 204,
},
initial: null,
},
},
},
over: {
id: `battlefield-${d.targetPlayerId}-${d.targetRow ?? 0}`,
// Row height = 204 so the effective card width derived by useGameDnd
// (laneHeight × 146/204) equals CARD_WIDTH_PX = 146, keeping the
// pointer-X math aligned with the test's logical gridMath expectations.
rect: {
left: ROW_LEFT,
top: 0,
width: 1000,
height: 204,
right: ROW_LEFT + 1000,
bottom: 204,
} as any,
data: {
current: {
targetPlayerId: d.targetPlayerId,
targetZone: d.targetZone,
row: d.targetRow ?? 0,
rowCards: d.rowCards ?? [],
attachTarget: d.attachTarget,
targetCardId: d.targetCardId,
},
},
disabled: false,
} as any,
delta: { x: 0, y: 0 },
collisions: null,
activatorEvent: new Event('pointerdown'),
} as unknown as DragEndEvent;
}
function makeWebClient() {
return {
request: {
game: {
moveCard: vi.fn(),
attachCard: vi.fn(),
},
},
} as any;
}
function setupHook() {
const webClient = makeWebClient();
mockUseWebClient.mockReturnValue(webClient);
const onDragStart = vi.fn();
const { result } = renderHook(() => useGameDnd({ gameId: 42, onDragStart }));
return { webClient, onDragStart, handleDragEnd: result.current.handleDragEnd };
}
describe('useGameDnd', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('drops on battlefield', () => {
it('sends moveCard with gridX=0 when dropping into an empty row', () => {
const { webClient, handleDragEnd } = setupHook();
const card = makeCard({ id: 10, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: card,
sourcePlayerId: 1,
sourceZone: App.ZoneName.HAND,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 1,
rowCards: [],
pointerXInRow: 10,
}),
);
expect(webClient.request.game.moveCard).toHaveBeenCalledTimes(1);
const args = webClient.request.game.moveCard.mock.calls[0][1];
expect(args.x).toBe(0);
expect(args.y).toBe(1);
expect(args.targetZone).toBe(App.ZoneName.TABLE);
});
it('sends moveCard with gridX=3 when dropping into the next stack past a 1-card stack', () => {
const { webClient, handleDragEnd } = setupHook();
const existing = makeCard({ id: 20, x: 0, y: 0 });
const dragging = makeCard({ id: 21, x: 0, y: 2 }); // from a different row
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
rowCards: [existing],
// Pointer well inside stack 1's territory.
pointerXInRow: CARD_WIDTH_PX + PADDING_X_PX + 10,
}),
);
const args = webClient.request.game.moveCard.mock.calls[0][1];
expect(args.x).toBe(3);
});
it('allows in-row reorder (same-row drop no longer silently rejected)', () => {
const { webClient, handleDragEnd } = setupHook();
const leftmost = makeCard({ id: 30, x: 0, y: 1 });
const dragged = makeCard({ id: 31, x: 1, y: 1 });
handleDragEnd(
buildEvent({
sourceCard: dragged,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 1,
// Dragged card still appears in rowCards from Redux during the drag.
rowCards: [leftmost, dragged],
// Drop far enough right that the dragged card lands in a new stack.
pointerXInRow: CARD_WIDTH_PX + PADDING_X_PX + 10,
}),
);
expect(webClient.request.game.moveCard).toHaveBeenCalledTimes(1);
// Dragged card excluded from occupancy → stack 1 base (3) is free.
expect(webClient.request.game.moveCard.mock.calls[0][1].x).toBe(3);
});
it('places into sub-position 1 when the stack base is occupied by another card', () => {
const { webClient, handleDragEnd } = setupHook();
const occupying = makeCard({ id: 40, x: 0, y: 0 });
const dragging = makeCard({ id: 41, x: 0, y: 2 }); // different row
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
rowCards: [occupying],
// Inside stack 0's territory (mapToGridX returns something in [0,2]).
pointerXInRow: STACKED_CARD_OFFSET_X_PX / 2,
}),
);
// base 0 occupied → closestGridPoint bumps to 1.
expect(webClient.request.game.moveCard.mock.calls[0][1].x).toBe(1);
});
it('rejects a drop silently when the target stack is fully occupied', () => {
const { webClient, handleDragEnd } = setupHook();
const dragging = makeCard({ id: 50, x: 0, y: 2 });
const full = [
makeCard({ id: 51, x: 0, y: 0 }),
makeCard({ id: 52, x: 1, y: 0 }),
makeCard({ id: 53, x: 2, y: 0 }),
];
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
rowCards: full,
pointerXInRow: 0, // mapToGridX → 0; all three sub-slots full.
}),
);
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
});
it('sends the logical y from target.row without re-inverting (mirrored boards)', () => {
// BattlefieldRow receives `row=rowIdx` where rowIdx iterates rowOrder
// (mirrored: [2,1,0]). Visual top of a mirrored opponent yields
// target.row = 2 which IS the correct server-side y. Re-inverting here
// would send y=0 and render the card at the bottom of the opponent's
// area — the bug this test guards against.
const { webClient, handleDragEnd } = setupHook();
const dragging = makeCard({ id: 60, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.HAND,
targetPlayerId: 2,
targetZone: App.ZoneName.TABLE,
targetRow: 2, // visual top of mirrored opponent = logical y=2
rowCards: [],
pointerXInRow: 0,
}),
);
expect(webClient.request.game.moveCard.mock.calls[0][1].y).toBe(2);
});
});
describe('non-TABLE drops', () => {
it('sends x=0, y=0 when dropping into a pile zone (graveyard)', () => {
const { webClient, handleDragEnd } = setupHook();
const card = makeCard({ id: 70, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: card,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.GRAVE,
targetRow: 0,
pointerXInRow: 500, // ignored for non-TABLE
}),
);
const args = webClient.request.game.moveCard.mock.calls[0][1];
expect(args.x).toBe(0);
expect(args.y).toBe(0);
expect(args.targetZone).toBe(App.ZoneName.GRAVE);
});
it('skips dispatch for same-zone drops on non-TABLE zones', () => {
const { webClient, handleDragEnd } = setupHook();
const card = makeCard({ id: 80, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: card,
sourcePlayerId: 1,
sourceZone: App.ZoneName.HAND,
targetPlayerId: 1,
targetZone: App.ZoneName.HAND,
targetRow: 0,
}),
);
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
});
});
describe('attach drops', () => {
it('dispatches attachCard when dropping a table card onto another table card', () => {
const { webClient, handleDragEnd } = setupHook();
const source = makeCard({ id: 90, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: source,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
attachTarget: true,
targetCardId: 91,
}),
);
expect(webClient.request.game.attachCard).toHaveBeenCalledTimes(1);
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
});
});
});