359 lines
24 KiB
TypeScript
359 lines
24 KiB
TypeScript
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/);
|
||
});
|