import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { ShipmentExpressionFields } from '../components/shipment-expression-fields'; import { TruthShipmentQuestion } from '../components/truth-shipment-question'; import { TruthNoFieldShipmentFields, type NoFieldShipmentInput } from '../components/truth-no-field-shipment-fields'; import { createGame, joinGame, newPlayer, viewGame, type Action } from '../game/engine'; import type { ShipmentExpression } from '../game/shipment-promises'; type ElementProps = { children?: ReactNode; [key: string]: unknown }; type Element = ReactElement; function elements(node: ReactNode): Element[] { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement(node)) return []; return [node, ...elements(node.props.children)]; } function byId(tree: ReactNode, id: string): Element { const element = elements(tree).find(item => item.props.id === id); assert.ok(element, `Expected ${id}`); return element; } function choose(element: Element, value: string) { (element.props.onChange as (event: {currentTarget: {value: string}}) => void)({currentTarget: {value}}); } void test('No-Field condition authors any destination or one printed territory and sector', () => { let value: NoFieldShipmentInput = {kind: 'noField'}; const tree = () => TruthNoFieldShipmentFields({id: 'marker', value, disabled: false, onChange(next) { value = next; }}); const html = renderToStaticMarkup(createElement(TruthNoFieldShipmentFields, {id: 'marker', value, disabled: false, onChange() {}})); assert.match(html, /Any destination/); assert.doesNotMatch(html, /token ID|token value/i); choose(byId(tree(), 'marker-no-field-territory'), 'arrakeen'); assert.deepEqual(value, {kind:'noField', territory:'arrakeen'}); choose(byId(tree(), 'marker-no-field-sector'), '10'); assert.deepEqual(value, {kind:'noField', territory:'arrakeen', sector:10}); choose(byId(tree(), 'marker-no-field-territory'), 'carthag'); assert.deepEqual(value, {kind:'noField', territory:'carthag'}, 'changing destination removes the old sector'); choose(byId(tree(), 'marker-no-field-sector'), '10'); assert.deepEqual(value, {kind:'noField', territory:'carthag'}, 'invalid sector is ignored'); choose(byId(tree(), 'marker-no-field-territory'), ''); assert.deepEqual(value, {kind:'noField'}); }); void test('disabled marker fields ignore direct callback invocation', () => { let changes = 0; const value: NoFieldShipmentInput = {kind:'noField', territory:'arrakeen', sector:10}; const tree = TruthNoFieldShipmentFields({id:'locked', value, disabled:true, onChange() { changes++; }}); assert.equal(byId(tree, 'locked-no-field-territory').props.disabled, true); assert.equal(byId(tree, 'locked-no-field-sector').props.disabled, true); choose(byId(tree, 'locked-no-field-territory'), 'carthag'); choose(byId(tree, 'locked-no-field-sector'), ''); assert.equal(changes, 0); }); void test('mixed shipment expression can change one condition to No-Field without replacing the physical condition', () => { let value: ShipmentExpression = {op:'or', terms:[{territory:'carthag',minimum:2}, {territory:'arrakeen',minimum:3}]}; const tree = () => ShipmentExpressionFields({value, id:'question', disabled:false, allowNoField:true, onChange(next) { value = next; }}); choose(byId(tree(), 'question-1.2-condition-type'), 'noField'); assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {kind:'noField'}]}); const marker = elements(tree()).find(element => element.type === TruthNoFieldShipmentFields); assert.ok(marker); (marker.props.onChange as (claim: NoFieldShipmentInput) => void)({kind:'noField',territory:'arrakeen',sector:10}); assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {kind:'noField',territory:'arrakeen',sector:10}]}); choose(byId(tree(), 'question-1.2-condition-type'), 'physical'); assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {territory:'carthag',minimum:1}]}); }); void test('a non-Richese target cannot add a No-Field condition and can remove a previously selected one', () => { let value: ShipmentExpression = {kind:'noField'}; const tree = () => ShipmentExpressionFields({value, id:'other', disabled:false, allowNoField:false, onChange(next) { value = next; }}); const html = renderToStaticMarkup(createElement(ShipmentExpressionFields, {value, id:'other', disabled:false, allowNoField:false, onChange() {}})); assert.match(html, /Choose Richese for a No-Field shipment condition/); choose(byId(tree(), 'other-1-condition-type'), 'physical'); assert.deepEqual(value, {territory:'carthag',minimum:1}); assert.equal(elements(tree()).some(element => element.props.id === 'other-1-condition-type'), false); }); void test('shipment question asks an exact No-Field claim for Richese and blocks it for another faction', () => { const source = createGame('MARKERQUESTION', newPlayer('asker','Asker','atreides'), false, ['choam']); joinGame(source, newPlayer('richese','Richese','richese')); joinGame(source, newPlayer('emperor','Emperor','emperor')); const game = viewGame(source, 'asker'); game.truthtrance = {stage:'ask', queue:[{player:'asker',card:'truth'}], passed:[], question:null}; game.truthShipmentTargets = ['richese','emperor']; let target = 'richese'; let claim: ShipmentExpression = {territory:'carthag',minimum:1}; const actions: Action[] = []; const question = () => ({kind:'shipment' as const, target, claim}); const wrapper = () => TruthShipmentQuestion({game, question:question(), busy:false, act(action) { actions.push(action); }, onChange(next) { claim = next; }}); const fieldTree = () => { const field = elements(wrapper()).find(element => element.type === ShipmentExpressionFields); assert.ok(field); return ShipmentExpressionFields(field.props as Parameters[0]); }; const button = () => { const found = elements(wrapper()).find(element => element.props['data-testid'] === 'truth-shipment-ask'); assert.ok(found); return found; }; choose(byId(fieldTree(), 'truth-shipment-1-condition-type'), 'noField'); assert.deepEqual(claim, {kind:'noField'}); const marker = elements(fieldTree()).find(element => element.type === TruthNoFieldShipmentFields); assert.ok(marker); (marker.props.onChange as (value: NoFieldShipmentInput) => void)({kind:'noField', territory:'arrakeen', sector:10}); assert.equal(button().props.disabled, false); (button().props.onClick as () => void)(); assert.deepEqual(actions, [{type:'truthAsk', question:{kind:'shipment', target:'richese', claim:{kind:'noField', territory:'arrakeen', sector:10}}}]); target = 'emperor'; assert.equal(button().props.disabled, true); (button().props.onClick as () => void)(); assert.equal(actions.length, 1); choose(byId(fieldTree(), 'truth-shipment-1-condition-type'), 'physical'); assert.deepEqual(claim, {territory:'carthag',minimum:1}); assert.equal(button().props.disabled, false); });