Files
Dune/tests/leader-skills-controls.test.ts
T
Quintine af360c836b Connect Suk Graduate casualty rescues and saved continuation
Implement both rescue bands with legal physical casualty routing, owner controls,
all four bot profiles and durable battle receipts. Preserve paid support, elite
identity, killed/returned leader custody and replay rejection. New Advanced
Atreides skill choices exclude Suk while the KH loss-count ruling is pending;
legacy assigned combinations retain their preflight guard. Public gates remain.

Independent review fixed stale dead trainer assignments and detached rescue
receipts. Final focused Suk/Leader Skills union: 46 passed. Typecheck, lint,
4,452 offline tests, production build and 43 HTTP tests passed on unchanged
source tree 3b76239a46d0ba874299495e64295a5eaaa8f2415c3e9a041bee944bbce50c5a.

Actual browser rescue and refresh retained counters, private hand and pending
winner card cleanup. Two four-profile Basic/Advanced smoke games completed
with JSON restoration and no rejected actions; neither exercised a Suk rescue.
After the outage report the server was healthy. All 360 pre-check rooms and
original seat/recovery records are unchanged; checks added 33 rooms. SQLite
integrity and foreign-key checks pass. No server restart or database reset.
2026-09-13 19:02:32 +10:00

234 lines
7.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { LeaderSkillsPanel } from '../components/leader-skills';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { LeaderSkillsView } from '../game/leader-skills';
const leaders = [
{ id: 'duke-leto', name: 'Duke Leto Atreides' },
{ id: 'lady-jessica', name: 'Lady Jessica' },
{ id: 'thufir-hawat', name: 'Thufir Hawat' },
] as const;
const players = [
{ id: 'a', name: 'Atreides' },
{ id: 'e', name: 'Emperor' },
] as const;
function view(overrides: Partial<LeaderSkillsView> = {}): LeaderSkillsView {
return {
assignments: [],
offer: null,
eligibleLeaders: [],
battleChoice: null,
...overrides,
};
}
function markup(skills: LeaderSkillsView | null, busy = false): string {
return renderToStaticMarkup(
createElement(LeaderSkillsPanel, {
skills,
leaders,
players,
busy,
act() {},
}),
);
}
function disabledControls(html: string): number {
return (html.match(/<(?:button|input|select)\b[^>]*\sdisabled=""/g) ?? [])
.length;
}
void test('renders nothing when the projected module is absent and never invents a private offer', () => {
assert.equal(markup(null), '');
const publicOnly = markup(
view({
assignments: [
{
owner: 'a',
controller: 'a',
captured: false,
leader: 'duke-leto',
skill: 'warmaster',
faceUp: true,
},
],
}),
);
assert.match(publicOnly, /Public Leader Skill assignments/);
assert.match(publicOnly, /Atreides · Duke Leto Atreides · Face up/);
assert.doesNotMatch(
publicOnly,
/Your private offer|Choose one private skill/,
);
assert.doesNotMatch(publicOnly, /Bureaucrat|Spice Banker|Diplomat/);
});
void test('every public assignment card exposes its complete canonical normal and battle text', () => {
for (const card of LEADER_SKILL_CARDS) {
const html = markup(
view({
assignments: [
{
owner: 'a',
controller: 'a',
captured: false,
leader: 'duke-leto',
skill: card.id,
faceUp: false,
},
],
}),
);
assert.match(
html,
new RegExp(`aria-label="${card.name} Leader Skill card"`),
);
assert.match(html, new RegExp(`Inspect ${card.name}`));
assert.match(html, /Atreides · Duke Leto Atreides · Behind shield/);
assert.ok(html.includes(`aria-label="${card.name} normal effect"`));
assert.ok(html.includes(`aria-label="${card.name} battle effect"`));
for (const line of [...card.normal, ...card.battle])
assert.ok(html.includes(line), `${card.name} omitted: ${line}`);
}
});
void test('a private setup offer renders only its two physical cards and eligible leader selection', () => {
const skills = view({
offer: {
event: 'setup-event',
cards: ['bureaucrat', 'diplomat'],
leader: null,
},
eligibleLeaders: [leaders[0], leaders[1]],
});
const html = markup(skills);
assert.match(html, /aria-label="Choose a Leader Skill"/);
assert.match(html, /Keep Bureaucrat/);
assert.match(html, /Keep Diplomat/);
assert.match(html, /Bureaucrat Leader Skill card/);
assert.match(html, /Diplomat Leader Skill card/);
assert.doesNotMatch(
html,
/Mentat Leader Skill card|Spice Banker Leader Skill card/,
);
assert.match(html, /aria-label="Eligible Leader Skill leader"/);
assert.match(html, /Duke Leto Atreides/);
assert.match(html, /Lady Jessica/);
assert.match(html, /Assign selected skill/);
assert.equal((html.match(/type="radio"/g) ?? []).length, 2);
assert.equal((html.match(/checked=""/g) ?? []).length, 1);
const disabled = markup(skills, true);
assert.equal(disabledControls(disabled), 4);
});
void test('an unavailable offered skill remains inspectable while the other card is selected', () => {
const reason = 'Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.';
const html = markup(view({
offer: { event: 'guarded-offer', cards: ['suk-graduate', 'warmaster'], leader: null },
eligibleLeaders: [leaders[0]],
unavailableSkills: { 'suk-graduate': reason },
}));
const blocked = html.match(/<input\b[^>]*value="suk-graduate"[^>]*>/)?.[0];
const available = html.match(/<input\b[^>]*value="warmaster"[^>]*>/)?.[0];
assert.ok(blocked && available);
assert.match(blocked, /disabled=""/);
assert.doesNotMatch(blocked, /checked=""/);
assert.match(available, /checked=""/);
assert.doesNotMatch(available, /disabled=""/);
assert.ok(html.includes(reason));
assert.match(html, /Inspect Suk Graduate/);
assert.match(html, /Assign selected skill/);
assert.equal(disabledControls(html), 1, 'the legal assignment remains usable');
});
void test('revival asks before preview and does not offer a decline after cards are drawn', () => {
const undrawn = markup(
view({
offer: { event: 'revival-event', cards: [], leader: 'thufir-hawat' },
eligibleLeaders: [leaders[2]],
}),
);
assert.match(undrawn, /Skill for Thufir Hawat/);
assert.match(undrawn, /before seeing either private card/);
assert.match(undrawn, /Draw two skills/);
assert.match(undrawn, /Continue without a skill/);
assert.doesNotMatch(undrawn, /Bureaucrat|Diplomat|Your private offer/);
assert.equal(
disabledControls(
markup(
view({
offer: { event: 'revival-event', cards: [], leader: 'thufir-hawat' },
eligibleLeaders: [leaders[2]],
}),
true,
),
),
2,
);
const drawn = markup(
view({
offer: {
event: 'revival-event',
cards: ['bureaucrat', 'diplomat'],
leader: 'thufir-hawat',
},
eligibleLeaders: [leaders[2]],
}),
);
assert.match(drawn, /Assigned leader: <strong>Thufir Hawat<\/strong>/);
assert.match(drawn, /choosing one is mandatory/);
assert.match(drawn, /Assign selected skill/);
assert.doesNotMatch(drawn, /Continue without a skill|Draw two skills/);
assert.doesNotMatch(drawn, /aria-label="Eligible Leader Skill leader"/);
});
void test('battle visibility choice names the skilled leader and disables both actions while busy', () => {
const skills = view({
assignments: [
{
owner: 'a',
controller: 'a',
captured: false,
leader: 'duke-leto',
skill: 'master-of-assassins',
faceUp: true,
},
],
battleChoice: {
event: 'battle-event',
leader: 'duke-leto',
skill: 'master-of-assassins',
},
});
const html = markup(skills);
assert.match(html, /aria-label="Leader Skill battle visibility"/);
assert.match(html, /Duke Leto Atreides in this battle/);
assert.match(html, /Keep leader face up/);
assert.match(html, /Move leader behind shield/);
assert.equal((html.match(/disabled=""/g) ?? []).length, 0);
assert.equal(disabledControls(markup(skills, true)), 2);
});
void test('the panel states the exact five connected role modifiers and unfinished boundary', () => {
const html = markup(view());
for (const name of [
'Warmaster',
'Master of Assassins',
'Swordmaster of Ginaz',
'Killer Medic',
'Prana-Bindu Adept',
])
assert.match(html, new RegExp(name));
assert.match(html, /development-only module/);
assert.match(html, /Other Leader Skill effects are unfinished/);
});