Files
Dune/tests/hand-browser-controls.test.tsx
T
Quintine e3cfd82540 Add private-hand search, category filters and display sorting
Wrap existing physical hand cards with local name search, printed-category
filters and saved/name/category display order. Preserve card IDs, actions and
inspectors, keep required decisions outside filtering, and recompute results
from current owned cards. Provide count, clear filters, keyboard scrolling,
separate empty/no-match states and internal guidance. Filters reset on leaving
the hand tab, changing seats or refreshing; saved order is never modified.
Correct the stale Harass availability note to reflect connected allocation.

Six focused tests cover normalized literal search, duplicate physical IDs,
canonical expansion categories, stable immutable sorting, current holdings,
JSON restoration, full-table per-seat privacy and empty setup messaging.
Independent review found no material issue. Types, lint, all 4996 offline
tests and production build pass on stable source. No HTTP, auth, schema or
persistence implementation changed, so HTTP tests were not repeated.

Browser QA used a new two-player Basic Harkonnen/Easy Atreides game created
through genuine setup. Search/category/reset, named inspector and all three
sort orders worked. An active no-match filter updated from 0 of 2 to 0 of 4
when a real auction purchase and Harkonnen bonus arrived. Leaving the hand tab
and refreshing restored all four cards in their original saved order while
the second auction remained at a human decision. Enter in search performed
no game action. The internal guide worked by keyboard. At 390px, controls
were 44px high with 16px text and 14px labels, form width/scroll width 339px;
the focused overflowing card row responded to ArrowRight. Normal viewport
restored. Screenshot capture timed out; full visual/screen-reader acceptance
and complete expansion/card-effect combinations remain open.

All 1538 opening rooms, 3258 seats and auxiliary rows
remain unchanged. SQLite integrity and foreign keys pass. Only one isolated
QA game, its human seat and entry receipt were added; the AI remains in game state. Healthy server reused without restart,
migration, reset or automation. No AI strategy or mode/publication gate changed.

Private evidence: 20260921-hand-browsing.
Verified source tree: 39dce28efa52dd66baf019baaf8610e6795601b3b569ac4ac0b6f405bffc1d96
2026-09-21 12:00:34 +10:00

57 lines
2.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import { baseDeck } from '../game/cards';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(m => [m[1], m[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { HandBrowser } = await import('../components/hand-browser');
const { GameTable } = await import('../components/game-table');
aliases.deregister();
void test('live hand browsing receives only the current seat cards and preserves named inspectors while busy', () => {
const g = createGame('HANDBROWSE', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
// Projection/render fixture only; starting-game rules are tested separately.
const cards = baseDeck();
g.players[0].hand = cards.filter(c => c.name === 'Shield');
g.players[1].hand = cards.filter(c => c.name === 'Lasgun');
g.deck = cards.filter(c => !g.players.some(p => p.hand.some(h => h.id === c.id)));
const before = JSON.stringify(g);
for (const id of ['a', 'h']) {
const html = renderToStaticMarkup(createElement(GameTable, {
game: viewGame(g, id), busy: true, send: async () => { assert.fail('Browsing must not submit a game action.'); }, onExit() {},
}));
assert.match(html, /<search aria-label="Browse your private hand">/);
assert.match(html, /Search your hand/);
assert.match(html, /Your hand cards/);
assert.match(html, /tabindex="0"/);
assert.match(html, id === 'a' ? /Inspect card: Shield/ : /Inspect card: Lasgun/);
assert.doesNotMatch(html, id === 'a' ? /Inspect card: Lasgun/ : /Inspect card: Shield/);
}
assert.equal(JSON.stringify(g), before);
});
void test('empty hand keeps its setup explanation and does not offer useless search controls', () => {
const html = renderToStaticMarkup(
<HandBrowser cards={[]} empty={<p>Starting cards remain undealt.</p>}>
{() => { assert.fail('No card should render.'); }}
</HandBrowser>,
);
assert.match(html, /Starting cards remain undealt/);
assert.doesNotMatch(html, /<search|No cards match|Show all cards/);
});