mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-06 05:23:56 -07:00
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|