Files
ark-stronghold-protocol/test/render/drag.test.js
T

348 lines
20 KiB
JavaScript
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.
// test/render/drag.test.js — drop target resolution, the pointer state machine (no DOM, fake timers) and the tile a
// dragged unit is drawn standing on (dragStandTile; the harness's 100 px grid stands in for the camera's tile pick).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { GEO } from '../../shared/constants.js';
import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController, dragStandTile } from '../../public/js/render/drag.js';
describe('slots & targets', () => {
test('tileSlot classifies hand, temp, board and everything else', () => {
assert.deepEqual(tileSlot({ row: 7, col: 0 }), { area: 'hand', idx: 0 });
assert.deepEqual(tileSlot({ row: 7, col: 9 }), { area: 'hand', idx: 9 });
assert.equal(tileSlot({ row: 7, col: 10 }), null);
assert.deepEqual(tileSlot({ row: 8, col: 4 }), { area: 'temp', idx: 0 });
assert.deepEqual(tileSlot({ row: 8, col: 8 }), { area: 'temp', idx: 4 });
assert.equal(tileSlot({ row: 8, col: 3 }), null);
assert.deepEqual(tileSlot({ row: 9, col: 2 }), { area: 'board', row: 9, col: 2 });
assert.deepEqual(tileSlot({ row: 12, col: 10 }), { area: 'board', row: 12, col: 10 });
assert.equal(tileSlot({ row: 12, col: 11 }), null, 'partner half is not ours');
assert.equal(tileSlot({ row: 9, col: 1 }), null);
assert.equal(tileSlot({ row: 13, col: 5 }), null);
assert.equal(tileSlot({ row: 3, col: 5 }), null, 'boss field is not a prep target');
assert.equal(tileSlot(null), null);
assert.equal(tileSlot({ row: 9.5, col: 3 }), null);
});
test('pieceSlot / pieceTile for hand, temp, board', () => {
assert.deepEqual(pieceSlot({ uid: 1, area: 'hand', idx: 3 }), { area: 'hand', idx: 3 });
assert.deepEqual(pieceTile({ uid: 1, area: 'hand', idx: 3 }), { row: GEO.HAND_ROW, col: 3 });
assert.deepEqual(pieceSlot({ uid: 1, area: 'temp', idx: 2 }), { area: 'temp', idx: 2 });
assert.deepEqual(pieceTile({ uid: 1, area: 'temp', idx: 2 }), { row: GEO.TEMP_ROW, col: GEO.TEMP_C0 + 2 });
assert.deepEqual(pieceSlot({ uid: 1, area: 'board', row: 10, col: 4 }), { area: 'board', row: 10, col: 4 });
assert.deepEqual(pieceTile({ uid: 1, row: 10, col: 4 }), { row: 10, col: 4 });
assert.equal(pieceSlot(null), null);
assert.equal(pieceTile({ uid: 1 }), null);
assert.ok(sameSlot({ area: 'hand', idx: 1 }, { area: 'hand', idx: 1 }));
assert.ok(!sameSlot({ area: 'hand', idx: 1 }, { area: 'board', row: 1, col: 1 }));
});
test('resolveDrop: board, hand, outside, cancel cases', () => {
const piece = { uid: 5, area: 'hand', idx: 2 };
const from = pieceSlot(piece);
const base = { piece, from, overCanvas: true, clientX: 10, clientY: 20 };
assert.deepEqual(resolveDrop({ ...base, tile: { row: 10, col: 5 } }), { kind: 'drop', target: { area: 'board', row: 10, col: 5 } });
assert.deepEqual(resolveDrop({ ...base, tile: { row: 7, col: 6 } }), { kind: 'drop', target: { area: 'hand', idx: 6 } });
assert.equal(resolveDrop({ ...base, tile: { row: 7, col: 2 } }).reason, 'same');
assert.equal(resolveDrop({ ...base, tile: { row: 8, col: 5 } }).reason, 'temp');
assert.equal(resolveDrop({ ...base, tile: { row: 15, col: 9 } }).reason, 'notTarget');
assert.deepEqual(resolveDrop({ ...base, tile: null }), { kind: 'drop', target: { area: 'outside', clientX: 10, clientY: 20 } });
assert.deepEqual(resolveDrop({ ...base, tile: { row: 10, col: 5 }, overCanvas: false }).target.area, 'outside');
const canPlace = (p, r, c) => !(r === 10 && c === 5);
assert.equal(resolveDrop({ ...base, tile: { row: 10, col: 5 }, canPlace }).reason, 'illegal');
assert.equal(resolveDrop({ ...base, tile: { row: 10, col: 6 }, canPlace }).kind, 'drop');
// hand targets ask canPlace with (HAND_ROW, idx)
const seen = [];
resolveDrop({ ...base, tile: { row: 7, col: 8 }, canPlace: (p, r, c, t) => { seen.push([r, c, t.area]); return true; } });
assert.deepEqual(seen, [[GEO.HAND_ROW, 8, 'hand']]);
});
test('isLegal: throwing callback ⇒ illegal; no callback ⇒ legal; temp/outside never', () => {
assert.equal(isLegal(() => { throw new Error('x'); }, {}, { area: 'board', row: 9, col: 3 }), false);
assert.equal(isLegal(null, {}, { area: 'board', row: 9, col: 3 }), true);
assert.equal(isLegal(null, {}, { area: 'temp', idx: 0 }), false);
assert.equal(isLegal(null, {}, null), false);
});
});
/** Harness: pieces at canvas points, tiles from a simple 100px grid (col = x/100, row = 12 - y/100). */
function harness(opts = {}) {
const events = [];
const timers = new Map();
let nextTimer = 1;
const pieces = opts.pieces || [
{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }, // tile (7,0)
{ uid: 2, area: 'board', row: 10, col: 4, x: 450, y: 250 },
{ uid: 3, area: 'hand', idx: 1, x: 150, y: 550, draggable: false },
];
const c = createDragController({
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900),
canPlace: opts.canPlace,
emit: (name, p) => events.push([name, p]),
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id; },
clearTimeout: (id) => timers.delete(id),
});
const ev = (type, x, y, extra = {}) => c[type]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y, ...extra });
const fire = () => { for (const [id, t] of [...timers]) { timers.delete(id); t.fn(); } };
const names = () => events.map((e) => e[0]).filter((n) => n !== 'pieceDragMove' && n !== 'tileHover');
return { c, events, ev, fire, names, timers };
}
describe('drag controller', () => {
test('click without movement emits pieceClick (not a drag)', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 52, 551);
h.ev('pointerUp', 52, 551);
assert.deepEqual(h.names(), ['pieceClick']);
assert.equal(h.events[0][1].uid, 1);
assert.equal(h.events[0][1].detail, false);
});
test('drag hand → board emits start, moves, drop with board target, end', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 70, 540);
assert.ok(h.c.dragging);
h.ev('pointerMove', 350, 250);
h.ev('pointerUp', 350, 250);
assert.deepEqual(h.names(), ['pieceDragStart', 'pieceDrop', 'pieceDragEnd']);
const drop = h.events.find((e) => e[0] === 'pieceDrop')[1];
assert.deepEqual(drop.target, { area: 'board', row: 10, col: 3 });
assert.deepEqual(drop.from, { area: 'hand', idx: 0 });
const moves = h.events.filter((e) => e[0] === 'pieceDragMove');
assert.ok(moves.length >= 2);
assert.equal(moves[moves.length - 1][1].legal, true);
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 10 && e[1].col === 3));
assert.equal(h.c.dragging, false);
});
test('illegal tile (canPlace false) cancels; legality shown during the move', () => {
const h = harness({ canPlace: (p, r, c) => c !== 3 });
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 350, 250);
const mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.equal(mv.legal, false);
assert.deepEqual(mv.target, { area: 'board', row: 10, col: 3 });
h.ev('pointerUp', 350, 250);
assert.deepEqual(h.names(), ['pieceDragStart', 'pieceDragEnd']);
assert.equal(h.events.pop()[1].cancelled, true);
});
test('release over DOM covering the canvas ⇒ outside target with client coords', () => {
const h = harness({ domCover: true });
h.c.setEditable(true);
h.ev('pointerDown', 450, 250);
h.ev('pointerMove', 470, 950);
h.ev('pointerUp', 470, 950);
const drop = h.events.find((e) => e[0] === 'pieceDrop')[1];
assert.deepEqual(drop.target, { area: 'outside', clientX: 470, clientY: 950 });
assert.deepEqual(drop.from, { area: 'board', row: 10, col: 4 });
});
test('not editable: press+move does not drag; release gives no click; hover still works', () => {
const h = harness();
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 300, 300);
h.ev('pointerUp', 300, 300);
assert.deepEqual(h.names(), []);
h.ev('pointerMove', 450, 250);
assert.deepEqual(h.names(), ['pieceHover']);
assert.equal(h.events.find((e) => e[0] === 'pieceHover')[1].uid, 2);
h.ev('pointerMove', 1000, 250);
assert.equal(h.events.filter((e) => e[0] === 'pieceHover').pop()[1].uid, null);
});
test('non-draggable pieces never start a drag', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 150, 550);
h.ev('pointerMove', 400, 300);
h.ev('pointerUp', 400, 300);
assert.deepEqual(h.names(), []);
});
test('right click ⇒ pieceClick(detail) + pieceDetail, no drag', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { button: 2 });
assert.deepEqual(h.names(), ['pieceClick', 'pieceDetail']);
assert.equal(h.events[0][1].detail, true);
h.ev('pointerMove', 600, 300);
assert.equal(h.c.dragging, false);
});
test('touch long-press ⇒ detail; the later release is not a click', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.fire();
h.ev('pointerUp', 450, 250, { pointerType: 'touch' });
assert.deepEqual(h.names(), ['pieceClick', 'pieceDetail']);
assert.equal(h.events[0][1].detail, true);
});
test('touch drag cancels the long-press timer and uses a larger threshold', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 457, 250, { pointerType: 'touch' });
assert.equal(h.c.dragging, false, 'below touch threshold');
h.ev('pointerMove', 470, 250, { pointerType: 'touch' });
assert.equal(h.c.dragging, true);
assert.equal(h.timers.size, 0, 'long-press timer cleared');
h.ev('pointerUp', 550, 350, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 9, col: 5 });
});
test('setEditable(false) during a drag cancels it; pointerCancel cancels; other pointers ignored', () => {
const h = harness();
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 300, 300);
h.c.setEditable(false);
assert.equal(h.c.dragging, false);
assert.deepEqual(h.names(), ['pieceDragStart', 'pieceDragEnd']);
h.c.setEditable(true);
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 300, 300, { pointerId: 2 });
assert.equal(h.c.dragging, false, 'second pointer ignored');
h.ev('pointerMove', 300, 300);
h.c.pointerCancel({ pointerId: 1 });
assert.equal(h.c.dragging, false);
assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0);
});
// user playtest #4 item 1: everything is judged at the pointer — the tile under it is the target (tileHover, legality,
// the drop) for a mouse and a finger alike (v2.2 lifted a touch drag's target 0.6 tile above the finger)
test('the target, tileHover, legality and the drop are the tile under the pointer (mouse and touch)', () => {
for (const pointerType of ['mouse', 'touch']) {
const h = harness({ canPlace: (p, r) => r !== 11 });
h.c.setEditable(true);
h.ev('pointerDown', 50, 550, { pointerType });
h.ev('pointerMove', 450, 350, { pointerType });
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.x, mv.y, mv.target, mv.legal], [450, 350, { area: 'board', row: 9, col: 4 }, true], pointerType);
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 9 && e[1].col === 4), `${pointerType}: tileHover = the target`);
h.ev('pointerMove', 450, 150, { pointerType });
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.target, mv.legal], [{ area: 'board', row: 11, col: 4 }, false], `${pointerType}: row 11 under the pointer is illegal`);
h.ev('pointerMove', 450, 350, { pointerType });
h.ev('pointerUp', 450, 350, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 9, col: 4 }, pointerType);
}
});
test('presses and hover pick the piece at the pointer (the view\'s hitPiece: the piece on the tile there)', () => {
const asked = [];
const pieces = [{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }];
const c = createDragController({
hitPiece: (x, y) => { asked.push([x, y]); return pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null; },
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
emit: () => {},
});
const ev = (type, x, y, pointerType = 'mouse') => c[type]({ pointerId: 1, pointerType, button: 0, x, y, clientX: x, clientY: y });
c.setEditable(true);
ev('pointerMove', 60, 560);
ev('pointerDown', 55, 545, 'touch');
assert.deepEqual(asked, [[60, 560], [55, 545]], 'hover and press ask at the pointer itself');
ev('pointerMove', 355, 255, 'touch');
ev('pointerUp', 355, 255, 'touch');
assert.equal(asked.length, 2, 'a drag asks no hit-test');
});
// on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it: a release with the finger
// on the shop bar is 'outside' (the piece goes back); on the bench slot above it, it lands there
test('DOM cover is judged at the pointer: a touch release over the shop bar is outside, on the bench it lands', () => {
const h = harness({ domCover: true });
h.c.setEditable(true);
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 250, 550, { pointerType: 'touch' });
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.target, mv.legal], [{ area: 'hand', idx: 2 }, true]);
h.ev('pointerUp', 250, 550, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'hand', idx: 2 });
h.events.length = 0;
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.equal(mv.target, null, 'the finger on the DOM: no target');
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] === null), 'no tile lit');
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'outside', clientX: 250, clientY: 950 });
});
test('a throwing listener or hit-test never breaks the controller', () => {
const c = createDragController({
hitPiece: () => { throw new Error('boom'); },
pickTile: () => { throw new Error('boom'); },
emit: () => { throw new Error('listener'); },
});
c.setEditable(true);
const orig = console.error; console.error = () => {};
try {
assert.equal(c.pointerDown({ pointerId: 1, x: 1, y: 1, button: 0 }), false);
c.pointerMove({ pointerId: 1, x: 5, y: 5 });
c.pointerUp({ pointerId: 1, x: 5, y: 5 });
c.pointerLeave({});
c.reset();
} finally { console.error = orig; }
});
});
// The official deploy drag (the owner's recording of 2026-10-09; the report in PR #403): the dragged unit is drawn
// standing on the tile under the pointer while that is a legal target, and held under the pointer elsewhere. The tile
// is the drag target itself, so the pick, tileHover, the highlight and the drop stay the pointer's tile.
describe('the tile a dragged unit stands on (dragStandTile)', () => {
test('a legal board tile or bench slot is stood on; an illegal tile, a temp slot, no target hold it under the pointer', () => {
assert.deepEqual(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: true }), { row: 10, col: 3, bench: false });
assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: true }), { row: GEO.HAND_ROW, col: 6, bench: true });
assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: false }), null, 'an illegal tile');
assert.equal(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: false }), null, 'the own bench slot / an illegal slot');
assert.equal(dragStandTile({ target: { area: 'temp', idx: 1 }, legal: true }), null, 'a temp slot is never a target');
assert.equal(dragStandTile({ target: { area: 'outside', clientX: 1, clientY: 2 }, legal: true }), null);
assert.equal(dragStandTile({ target: null, legal: false }), null, 'off the grid, over the shop bar');
assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: 1 }), null, 'legal must be true');
assert.equal(dragStandTile({ target: { area: 'board', row: 9.5, col: 3 }, legal: true }), null);
assert.equal(dragStandTile({ target: { area: 'hand' }, legal: true }), null);
assert.equal(dragStandTile(null), null);
assert.equal(dragStandTile(undefined), null);
assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 2 }, legal: true }, { HAND_ROW: 3 }), { row: 3, col: 2, bench: true }, 'geo is a parameter');
});
test('over the drag path: one tile while the pointer moves inside it, the next legal tile at once, none where no drop lands (mouse and touch)', () => {
for (const pointerType of ['mouse', 'touch']) {
const h = harness({ domCover: true, canPlace: (p, r, c) => !(r === 11 && c === 4) });
h.c.setEditable(true);
const stand = () => dragStandTile(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1]);
const at = (x, y) => { h.ev('pointerMove', x, y, { pointerType }); return stand(); };
h.ev('pointerDown', 50, 550, { pointerType });
assert.equal(at(50, 530), null, `${pointerType}: on its own bench slot (no target) it is held under the pointer`);
assert.ok(h.c.dragging);
// the pointer anywhere on (9,4) (x 400–499, y 300–399 in the harness grid): the unit stands on (9,4) the whole time
const inside = [[410, 390], [450, 350], [495, 305], [402, 301]].map(([x, y]) => at(x, y));
assert.deepEqual(inside, Array(4).fill({ row: 9, col: 4, bench: false }), `${pointerType}: moving inside the tile leaves it there`);
assert.deepEqual(at(450, 299), { row: 10, col: 4, bench: false }, `${pointerType}: the next tile takes it at once`);
assert.equal(at(450, 150), null, `${pointerType}: an illegal tile (11,4)`);
assert.equal(at(550, 450), null, `${pointerType}: a temp slot`);
assert.deepEqual(at(250, 550), { row: GEO.HAND_ROW, col: 2, bench: true }, `${pointerType}: another bench slot`);
assert.equal(at(250, 950), null, `${pointerType}: over the shop bar (DOM over the canvas)`);
assert.equal(at(-20, 300), null, `${pointerType}: off the grid`);
assert.deepEqual(at(350, 250), { row: 10, col: 3, bench: false });
h.ev('pointerUp', 350, 250, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 }, `${pointerType}: dropped on the tile it stood on`);
// a board piece lifts on its own tile (a legal drop: the direction wheel re-orients it in place)
h.events.length = 0;
h.ev('pointerDown', 450, 250, { pointerType });
assert.deepEqual(at(470, 230), { row: 10, col: 4, bench: false }, `${pointerType}: a board piece stands on its own tile`);
h.ev('pointerUp', 470, 230, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
}
});
});