Files
Dune/tests/public-deals-travel-controls.test.tsx
T
Quintine 6c1530382d
Verify and publish checkpoint container / container (push) Successful in 10m49s
Support immediate payments in group travel deals
2026-10-29 17:41:58 +11:00

359 lines
24 KiB
TypeScript
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.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as board from '../game/board';
import * as catalog from '../game/catalog';
import * as coalitionAlliance from '../game/coalition-alliance';
import * as publicDeals from '../game/public-deals';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import { TravelPromises } from '../components/travel-promises';
import type { Action, GameView } from '../game/engine';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{children?:ReactNode; [key:string]:unknown}>(node)) return [];
return [node, ...elements(node.props.children)];
}
const travel = {kind:'ship', territory:'carthag', minimum:3, answer:true} as const;
const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const;
const deal = {id:'deal', from:'a', to:'b', terms:'The agreed journey.', turn:2,
status:'active', releaseVotes:[], travel};
const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState:null,
players:[{id:'a',name:'Alice',faction:'atreides',ally:null}, {id:'b',name:'Basil',faction:'harkonnen',ally:null}],
deals:[deal], dealTravelStatus:{deal:'pending'}} as unknown as GameView;
const markup = (game:GameView, disabled = false) =>
renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled}));
void test('the offer packet retains legacy two-party terms and validates an explicitly named group traveler', () => {
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', travel});
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'c'}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel, actor:'c'}});
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn:2}),
{type:'dealOffer', target:'b', terms:'Terms', travel}, 'Current-turn packet keeps the legacy shape.');
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, {...travel,dueTurn:4}),
{type:'dealOffer', target:'b', terms:'Terms', payment, travel:{...travel,dueTurn:4}});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel,actor:'c',dueTurn:4}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel,actor:'c',dueTurn:4}});
for (const dueTurn of [0, 1, 2.5, Number.NaN, undefined])
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn}), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'a'}), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'d'}), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel, actor:'b'}), null);
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, {...travel, actor:'c'}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', payment, travel:{...travel, actor:'c'}});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', {...payment,payer:'b',recipient:'c'}, 'a', 2, {...travel, actor:'c'}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', payment:{...payment,payer:'b',recipient:'c'}, travel:{...travel, actor:'c'}});
assert.equal(publicDealOfferAction(['b','c'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, {...travel, actor:'c'}), null);
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
const reverse = {...payment,payer:'b',recipient:'a'};
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', reverse, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', payment:reverse, travel});
assert.equal(publicDealOfferAction(['b'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null);
for (const invalid of [
{...travel, minimum:0}, {...travel, minimum:21}, {...travel, minimum:1.5},
{...travel, territory:'not-a-territory'}, {...travel, answer:'yes'},
{...travel, kind:'ride'}, {...travel, extra:true},
]) assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2,
invalid as unknown as typeof travel), null);
// Older callers retain their original exact wire packets.
assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), {type:'dealOffer', target:'b', terms:'Terms'});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'});
});
void test('the combined offer explains payment timing and the recipient can accept or decline', () => {
const offered = {...base, deals:[{...deal,status:'offered',payment}], dealTravelStatus:{},
bribeOptions:{blocked:null}} as unknown as GameView;
const html = markup(offered);
assert.match(html, /Alice \(Atreides\) pays 2 spice to Basil \(Harkonnen\) on final acceptance/);
assert.match(html, /recipient collects their share at the next Mentat opening/);
assert.match(html, /cannot spend this payment before its next Mentat collection/);
const sent:Action[] = [];
const tree = PublicDealList({game:offered, disabled:false, act(action) {sent.push(action);}});
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
const decline = elements(tree).find(node => node.props.children === 'Decline deal');
assert.ok(accept); assert.ok(decline);
assert.equal(accept.props.disabled, false); assert.equal(decline.props.disabled, false);
(accept.props.onClick as () => void)();
assert.deepEqual(sent, [{type:'dealRespond',deal:'deal',accept:true}]);
const busyTree = PublicDealList({game:offered, disabled:true, act() {}});
assert.equal(elements(busyTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
});
/** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */
function combinedForm(gameOverride?: GameView) {
const states:unknown[] = [], sent:Action[] = [];
let cursor = 0;
const exported:{PublicDeals?:typeof PublicDeals} = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url),'utf8'), {
compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX},
}).outputText, {exports:exported, require(name:string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return {useState(initial:unknown) {
const index = cursor++;
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
return [states[index], (next:unknown) => {states[index] = typeof next === 'function' ? next(states[index]) : next;}];
}, useRef:() => ({current:null}), useEffect:() => {}};
if (name === '@/components/ui/button') return {Button:'button'};
if (name === '@/game/board') return board;
if (name === '@/game/catalog') return catalog;
if (name === '@/game/coalition-alliance') return coalitionAlliance;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
}});
assert.ok(exported.PublicDeals);
const game = gameOverride ?? {...base, me:'a', deals:[]} as GameView;
const render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));};
const check = (index:number) => {
const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
assert.ok(node); assert.equal(node.props.disabled,false);
(node.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:true}});
};
const select = (index:number,value:string) => {
const node = render().filter(element => element.type === 'select')[index]; assert.ok(node);
(node.props.onChange as (event:{target:{value:string}}) => void)({target:{value}});
};
return {render, check, select, sent};
}
void test('the authoring controls compose a proposer payment with recipient travel and reject invalid combinations', () => {
const ui = combinedForm();
ui.check(0); // Select recipient.
ui.check(1); // Include payment.
ui.select(1,'a'); ui.select(2,'b');
ui.check(2); // Include travel while payment is selected.
ui.select(5,'carthag');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}});
const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
assert.equal(button()?.props.disabled,false);
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Travel for spice',
payment:{kind:'immediateBribe',payer:'a',recipient:'b',amount:1},
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true);
ui.select(0,'immediateBribe'); ui.select(1,'b'); ui.select(2,'a');
assert.equal(button()?.props.disabled,false);
const reverseForm = ui.render().find(node => node.type === 'form'); assert.ok(reverseForm);
(reverseForm.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Travel for spice',
payment:{kind:'immediateBribe',payer:'b',recipient:'a',amount:1},
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
});
void test('a payment can also be added after selecting the travel term', () => {
const ui = combinedForm();
ui.check(0); ui.check(2);
ui.select(2,'carthag');
ui.check(1);
ui.select(1,'a'); ui.select(2,'b');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}});
const submit = ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
assert.equal(submit?.props.disabled,false);
});
void test('the public deal shows the exact recipient, travel intent, turn and authoritative status', () => {
const html = markup(base);
assert.match(html, /Current-turn travel term/);
assert.match(html, /Basil \(Harkonnen\) agrees to make a reserve shipment of at least 3 physical forces in one action to Carthag during turn 2/);
assert.match(html, /Travel status: pending/);
const avoid = {...base, me:'a', deals:[{...deal, travel:{kind:'move',territory:'arrakeen',minimum:2,answer:false}}],
dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView;
assert.match(markup(avoid), /agrees to avoid a ground movement of at least 2 physical forces in one action to Arrakeen/);
assert.match(markup(avoid), /Travel status: fulfilled/);
for (const status of ['void','released'] as const)
assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`));
});
void test('group offers name the traveler, require their last vote and still permit declining early', () => {
const players = [...base.players, {id:'c',name:'Cora',faction:'emperor',ally:null},
{id:'d',name:'Dara',faction:'guild',ally:null}];
const group = {...deal, participants:['a','b','c','d'], acceptVotes:['a','b'], status:'offered',
travel:{...travel,actor:'c'}};
const game = {...base, players, deals:[group], me:'c', dealTravelStatus:{}} as unknown as GameView;
assert.match(markup(game), /Cora \(Emperor\) would make a reserve shipment/);
assert.match(markup(game), /Cora \(Emperor\) must accept last/);
const actions:Action[] = [];
const early = elements(PublicDealList({game,disabled:false,act(action) {actions.push(action);}}));
assert.equal(early.find(node => node.props.children === 'Accept deal')?.props.disabled,true);
const decline = early.find(node => node.props.children === 'Decline deal');
assert.ok(decline); assert.equal(decline.props.disabled,false);
(decline.props.onClick as () => void)();
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:false}]);
assert.deepEqual(publicDealRequests(game), [['offer','deal','waiting']], 'The traveler can see and decline the offer before other votes are in.');
const ordinary = {...game,me:'d'};
assert.deepEqual(publicDealRequests(ordinary), [['offer','deal']]);
assert.equal(elements(PublicDealList({game:ordinary,disabled:false,act() {}}))
.find(node => node.props.children === 'Accept deal')?.props.disabled,false);
const final = {...game,deals:[{...group,acceptVotes:['a','b','d']}]} as unknown as GameView;
assert.deepEqual(publicDealRequests(final), [['offer','deal','ready']], 'A changed request reopens the panel for the final vote.');
const finalActions:Action[] = [];
const accept = elements(PublicDealList({game:final,disabled:false,act(action) {finalActions.push(action);}}))
.find(node => node.props.children === 'Accept deal');
assert.ok(accept); assert.equal(accept.props.disabled,false);
(accept.props.onClick as () => void)();
assert.deepEqual(finalActions.at(-1),{type:'dealRespond',deal:'deal',accept:true});
const paid = {...final, bribeOptions:{blocked:'Your own funds are committed'},
deals:[{...group,acceptVotes:['a','b','d'],payment:{kind:'immediateBribe',payer:'b',recipient:'d',amount:2}}]} as unknown as GameView;
assert.equal(elements(PublicDealList({game:paid,disabled:false,act() {}}))
.find(node => node.props.children === 'Accept deal')?.props.disabled,false,
'The traveler’s own bribe quote cannot reject another participant’s payment.');
const active = {...game, deals:[{...group,status:'active',acceptVotes:['a','b','c','d']}],
dealTravelStatus:{deal:'pending'}} as unknown as GameView;
assert.deepEqual(publicDealRequests(active), [['travel','deal']]);
assert.deepEqual(publicDealRequests({...active,me:'b'}), []);
});
void test('the group offer form selects an invited traveler and revalidates participant changes', () => {
const game = {...base, me:'a', deals:[], players:[...base.players,
{id:'c',name:'Cora',faction:'emperor',ally:null}, {id:'d',name:'Dara',faction:'guild',ally:null}]} as unknown as GameView;
const ui = combinedForm(game);
ui.check(0); ui.check(1); ui.check(4); // Basil, Cora, then travel.
const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
ui.select(0,'c'); ui.select(3,'carthag');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Group travel'}});
assert.equal(button()?.props.disabled,false);
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',targets:['b','c'],terms:'Group travel',
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'c'}});
const cora = ui.render().filter(node => node.type === 'input' && node.props.type === 'checkbox')[1];
(cora.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:false}});
ui.check(2); // Add Dara after removing the chosen traveler.
assert.equal(button()?.props.disabled,true);
ui.select(0,'d'); assert.equal(button()?.props.disabled,false);
ui.check(3); assert.equal(button()?.props.disabled,true, 'The payer and recipient must be selected.');
ui.select(1,'b'); ui.select(2,'d');
assert.equal(button()?.props.disabled,false);
const paidForm = ui.render().find(node => node.type === 'form'); assert.ok(paidForm);
(paidForm.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',targets:['b','d'],terms:'Group travel',
payment:{kind:'immediateBribe',payer:'b',recipient:'d',amount:1},
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'d'}});
ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true, 'Deferred group payment remains unavailable.');
});
void test('the author chooses a future travel turn and can return to the legacy current-turn packet', () => {
const ui = combinedForm();
ui.check(0); ui.check(2);
ui.select(2,'carthag');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Later journey'}});
const deadline = () => ui.render().find(node => node.type === 'input' && node.props.type === 'number' && node.props.min === 2);
const submit = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
assert.ok(deadline());
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'4'}});
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
assert.equal(submit()?.props.disabled,false);
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Later journey',
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,dueTurn:4}});
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'1'}});
assert.equal(submit()?.props.disabled,true);
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'2'}});
assert.equal(submit()?.props.disabled,false);
const current = ui.render().find(node => node.type === 'form'); assert.ok(current);
(current.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Later journey',
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
});
void test('future offers stay actionable until the selected turn and scheduled terms do not prompt the traveler early', () => {
const offered = {...base,turn:3, deals:[{...deal,status:'offered',travel:{...travel,dueTurn:4}}],
dealTravelStatus:{}} as unknown as GameView;
assert.match(markup(offered), /during turn 4/);
assert.match(markup(offered), /Before turn 4, this term reserves no forces or spice/);
const accept = (game:GameView) => elements(PublicDealList({game,disabled:false,act() {}}))
.find(node => node.props.children === 'Accept deal');
assert.equal(accept(offered)?.props.disabled,false);
assert.equal(accept({...offered,turn:4})?.props.disabled,false);
const expired = {...offered,turn:5};
assert.equal(accept(expired)?.props.disabled,true);
assert.match(markup(expired), /travel term’s turn has passed/);
const scheduled = {...offered, deals:[{...deal,travel:{...travel,dueTurn:4}}],
dealTravelStatus:{deal:'scheduled'}} as unknown as GameView;
assert.match(markup(scheduled), /Travel status: scheduled/);
assert.deepEqual(publicDealRequests(scheduled), []);
assert.deepEqual(publicDealRequests({...scheduled,turn:4,dealTravelStatus:{deal:'pending'}} as unknown as GameView),
[['travel','deal']]);
});
void test('only the obligated recipient gets a persistent pending reminder', () => {
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/);
assert.deepEqual(publicDealRequests({...base,me:'a'}), []);
assert.deepEqual(publicDealRequests({...base,dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView), []);
assert.deepEqual(publicDealRequests({...base,deals:[{...deal,status:'released'}]} as unknown as GameView), []);
});
void test('an offered travel term remains actionable to the recipient until its current turn has passed', () => {
const offered = {...base,deals:[{...deal,status:'offered'}],dealTravelStatus:{}} as unknown as GameView;
const actions:Action[] = [];
const tree = PublicDealList({game:offered,disabled:false,act(action) {actions.push(action);}});
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
assert.ok(accept); assert.equal(accept.props.disabled,false);
(accept.props.onClick as () => void)();
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:true}]);
const expired = {...offered,turn:3};
assert.match(markup(expired), /travel term’s turn has passed/);
const expiredTree = PublicDealList({game:expired,disabled:false,act() {}});
assert.equal(elements(expiredTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
assert.equal(elements(expiredTree).find(node => node.props.children === 'Decline deal')?.props.disabled,false);
});
void test('declined and withdrawn travel offers do not claim to be awaiting acceptance', () => {
for (const status of ['declined', 'withdrawn'] as const) {
const closed = {...base, deals:[{...deal, status}], dealTravelStatus:{}} as unknown as GameView;
const html = markup(closed);
assert.match(html, /The term did not become binding/);
assert.match(html, /Basil \(Harkonnen\) would make a reserve shipment/);
assert.doesNotMatch(html, /Basil \(Harkonnen\) agrees to/);
assert.doesNotMatch(html, /The term becomes binding on final acceptance/);
}
});
void test('the recipient guide distinguishes a void deal from unanimous release', () => {
// Staged public projections isolate presentation; native creation and lifecycle
// are exercised by the separate runtime integration cases.
const promise = {event:'travel-event', sourceDeal:'deal', player:'b', asker:'a', turn:2,
mixed:{kind:'action', action:{kind:'ship', territory:'carthag', minimum:3}},
answer:true, fulfilled:false, released:true};
for (const [status, expected, absent] of [
['void', /Void: this agreement is no longer possible/, /Released: this whole commitment/],
['released', /Released: this whole commitment is no longer binding/, /Void: this agreement/],
] as const) {
const game = {...base, allLeaders:[], combatLocations:[{id:'carthag',name:'Carthag'}],
travelPromises:[promise], dealTravelStatus:{deal:status}} as unknown as GameView;
const html = renderToStaticMarkup(createElement(TravelPromises,{game, busy:false, act() {}}));
assert.match(html, /Public deal: agreed Yes/);
assert.match(html, expected);
assert.doesNotMatch(html, absent);
assert.match(html, /after the answer or deal acceptance/);
assert.equal(TravelPromises({game:{...game, me:'a'}, busy:false, act() {}}), null);
}
});
void test('the real offer form asks for recipients before a travel term is selected', () => {
const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false}));
assert.match(html, /Include a travel term/);
assert.match(html, /Select at least one human recipient to add a travel term/);
assert.match(html, /cannot automatically enforce free-text promises/);
});