Prototype Truthtrance ally funding commitments
Verify and publish checkpoint container / container (push) Successful in 10m48s
Verify and publish checkpoint container / container (push) Successful in 10m48s
This commit is contained in:
1 parent
68643925e7
commit
c414a03a19
14 files changed
+795
-4
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PledgePromiseError, livePledgePromises, parsePledgeClaim, pledgePromiseRequirements,
|
||||
reconcilePledgePromises, recordPromisedPledge, validatePledgePromises,
|
||||
type PledgePromise, type PledgeReconciliation,
|
||||
} from '../game/pledge-promises';
|
||||
|
||||
const promise = (overrides: Partial<PledgePromise> = {}): PledgePromise => ({
|
||||
turn: 2, phase: 3, ally: 'ally', minimum: 3,
|
||||
event: 'event-1', player: 'donor', asker: 'asker', answer: true, ...overrides,
|
||||
});
|
||||
const context = (overrides: Partial<PledgeReconciliation> = {}): PledgeReconciliation => ({
|
||||
turn: 2, phase: 3, mutualAlly: player => player === 'donor' ? 'ally' : null,
|
||||
canFulfill: () => true, ...overrides,
|
||||
});
|
||||
|
||||
void test('a pledge question requires a current supported phase, current ally identity and positive safe threshold', () => {
|
||||
assert.deepEqual(parsePledgeClaim({turn: 2, phase: 5, ally: 'ally', minimum: 4}),
|
||||
{turn: 2, phase: 5, ally: 'ally', minimum: 4});
|
||||
for (const value of [null, {}, {turn: 0, phase: 3, ally: 'ally', minimum: 1},
|
||||
{turn: 2, phase: 4, ally: 'ally', minimum: 1},
|
||||
{turn: 2, phase: 3, ally: ' ally ', minimum: 1},
|
||||
{turn: 2, phase: 3, ally: 'ally', minimum: 0},
|
||||
{turn: 2, phase: 3, ally: 'ally', minimum: Number.MAX_SAFE_INTEGER + 1}])
|
||||
assert.throws(() => parsePledgeClaim(value), PledgePromiseError);
|
||||
});
|
||||
|
||||
void test('overlapping future Yes and No answers combine into a single bounded update', () => {
|
||||
const rows = [promise({minimum: 2}), promise({event: 'event-2', minimum: 4}),
|
||||
promise({event: 'event-3', answer: false, minimum: 6})];
|
||||
assert.deepEqual(pledgePromiseRequirements(rows, 'donor', 2, 3, 'ally'),
|
||||
{ally: 'ally', minimum: 4, maximum: 5});
|
||||
assert.equal(pledgePromiseRequirements([...rows, promise({event: 'event-4', answer: false, minimum: 4})],
|
||||
'donor', 2, 3, 'ally'), null);
|
||||
assert.equal(pledgePromiseRequirements(rows, 'donor', 2, 3, 'different'), null);
|
||||
assert.deepEqual(pledgePromiseRequirements(rows, 'donor', 2, 5, 'ally'), {ally: 'ally', minimum: 0});
|
||||
});
|
||||
|
||||
void test('only a future accepted native funding update satisfies Yes, including an explicit same-value update', () => {
|
||||
const rows = [promise({minimum: 3}), promise({event: 'event-2', minimum: 5}),
|
||||
promise({event: 'event-3', answer: false, minimum: 6})];
|
||||
const before = structuredClone(rows);
|
||||
const low = recordPromisedPledge(rows, 'donor', 2, 3, {ally: 'ally', amount: 3});
|
||||
assert.equal(low[0].fulfilled, true);
|
||||
assert.equal(low[1].fulfilled, undefined);
|
||||
assert.equal(low[2].fulfilled, undefined);
|
||||
assert.deepEqual(rows, before);
|
||||
const full = recordPromisedPledge(low, 'donor', 2, 3, {ally: 'ally', amount: 5});
|
||||
assert.equal(full[1].fulfilled, true);
|
||||
assert.equal(full[2].fulfilled, undefined, 'No continues to restrict later same-phase updates');
|
||||
assert.throws(() => recordPromisedPledge(full, 'donor', 2, 3, {ally: 'ally', amount: 6}), PledgePromiseError);
|
||||
assert.deepEqual(rows, before);
|
||||
assert.deepEqual(recordPromisedPledge(rows, 'donor', 2, 3, {ally: 'other', amount: 10}), rows);
|
||||
assert.deepEqual(recordPromisedPledge(rows, 'donor', 2, 5, {ally: 'ally', amount: 10}), rows);
|
||||
});
|
||||
|
||||
void test('an owner cannot voluntarily evade a feasible Yes while a foreign impossibility releases it', () => {
|
||||
const rows = [promise(), promise({event: 'no', answer: false, minimum: 5})];
|
||||
const before = structuredClone(rows);
|
||||
const keep = reconcilePledgePromises(rows, context({canFulfill: (_row, quote) => {
|
||||
assert.deepEqual(quote, {ally: 'ally', minimum: 3, maximum: 4}); return true;
|
||||
}}));
|
||||
assert.deepEqual(keep, rows);
|
||||
assert.throws(() => reconcilePledgePromises(rows, context({canFulfill: () => false, voluntaryActor: 'donor'})),
|
||||
PledgePromiseError);
|
||||
assert.deepEqual(rows, before);
|
||||
const released = reconcilePledgePromises(rows, context({canFulfill: () => false, voluntaryActor: 'asker'}));
|
||||
assert.equal(released[0].released, true);
|
||||
assert.equal(released[1].fulfilled, undefined, 'The No survives a temporary affordability change');
|
||||
assert.deepEqual(rows, before);
|
||||
});
|
||||
|
||||
void test('phase or reciprocal alliance changes expire No and release an unfulfilled Yes', () => {
|
||||
const rows = [promise(), promise({event: 'no', answer: false})];
|
||||
for (const changed of [context({phase: 5}), context({turn: 3}), context({mutualAlly: () => null})]) {
|
||||
const result = reconcilePledgePromises(rows, changed);
|
||||
assert.equal(result[0].released, true);
|
||||
assert.equal(result[1].fulfilled, true);
|
||||
assert.deepEqual(livePledgePromises(result, 'donor', changed.turn, changed.phase), []);
|
||||
}
|
||||
assert.throws(() => reconcilePledgePromises(rows, context({phase: 5, voluntaryActor: 'donor'})), PledgePromiseError);
|
||||
assert.throws(() => reconcilePledgePromises(rows, context({mutualAlly: () => null, voluntaryActor: 'donor'})), PledgePromiseError);
|
||||
});
|
||||
|
||||
void test('saved rows retain distinct events, valid participants and noncontradictory terminal flags', () => {
|
||||
const players = ['donor', 'asker', 'ally'];
|
||||
assert.doesNotThrow(() => validatePledgePromises([promise()], players));
|
||||
for (const bad of [
|
||||
[promise(), promise()], [promise({player: 'ally'})], [promise({asker: 'donor'})],
|
||||
[promise({event: ''})], [promise({fulfilled: true, released: true})],
|
||||
[promise({answer: false, released: true})],
|
||||
]) assert.throws(() => validatePledgePromises(bad, players), PledgePromiseError);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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 { PledgeClaimFields, pledgeClaimInputError } from '../components/pledge-claim-fields';
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function input(props: Parameters<typeof PledgeClaimFields>[0]) {
|
||||
const field = elements(PledgeClaimFields(props)).find(element => element.props.id === 'truth-pledge-minimum');
|
||||
assert.ok(field && typeof field.props.onChange === 'function');
|
||||
return field;
|
||||
}
|
||||
function edit(props: Parameters<typeof PledgeClaimFields>[0], value: number) {
|
||||
(input(props).props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)(
|
||||
{ currentTarget: { valueAsNumber: value } });
|
||||
}
|
||||
|
||||
void test('ally funding question checks positive safe totals and respects disabled controls', () => {
|
||||
const changes: number[] = [];
|
||||
const props = {minimum: 2, allyName: 'House Guild', disabled: false, onChange: (value: number) => changes.push(value)};
|
||||
assert.equal(pledgeClaimInputError(props.minimum), null);
|
||||
edit(props, 1); edit(props, Number.MAX_SAFE_INTEGER);
|
||||
for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.ok(pledgeClaimInputError(value));
|
||||
edit(props, value);
|
||||
}
|
||||
edit({ ...props, disabled: true }, 3);
|
||||
assert.deepEqual(changes, [1, Number.MAX_SAFE_INTEGER]);
|
||||
assert.match(renderToStaticMarkup(createElement(PledgeClaimFields, { ...props, minimum: 0 })), /role="alert"/);
|
||||
});
|
||||
|
||||
void test('ally funding explanation distinguishes an update, previous pledge and payment', () => {
|
||||
const html = renderToStaticMarkup(createElement(PledgeClaimFields, {
|
||||
minimum: 3, allyName: 'House Guild', disabled: false, onChange() {},
|
||||
}));
|
||||
assert.match(html, /Current ally: House Guild/);
|
||||
assert.match(html, /after the answer during this phase/);
|
||||
assert.match(html, /same total again counts/);
|
||||
assert.match(html, /earlier pledge alone does not/);
|
||||
assert.match(html, /not a payment/);
|
||||
assert.doesNotMatch(html, /current balance|available wallet/i);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { actTruthAlliance as act, advanceTruthAlliance as advance,
|
||||
createTruthAllianceFixture, truthAllianceClean as clean, truthAllianceInventory as inventory,
|
||||
truthAlliancePlayer as player } from './fixture-truth-alliance';
|
||||
import { declareTruthBribe } from './fixture-truth-bribe';
|
||||
|
||||
type PledgePromise = { player: string; ally: string; minimum: number; answer: boolean;
|
||||
turn: number; phase: number; fulfilled?: boolean; released?: boolean };
|
||||
const promise = (game: Game): PledgePromise => {
|
||||
const rows = (game as Game & { pledgePromises?: PledgePromise[] }).pledgePromises;
|
||||
assert.ok(rows?.length); return rows.at(-1)!;
|
||||
};
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** A real Basic setup, storm, worm, ordinary Nexus, reciprocal consent and
|
||||
* later Bidding opening. Only the source fixture's original unopened deal and
|
||||
* Spice order are selected; no alliance, purse, aid or question is staged. */
|
||||
function fixture(phase: 3 | 5 = 3) {
|
||||
const base = createTruthAllianceFixture({ consent: 'allied' });
|
||||
const game = advance(base.game, state => state.phase === phase && (phase !== 3 || !!state.auction) && clean(state));
|
||||
assert.equal(player(game, base.target).ally, base.partner);
|
||||
assert.equal(player(game, base.partner).ally, base.target);
|
||||
assert.deepEqual(inventory(game), base.originalCards);
|
||||
return { ...base, game, ally: base.partner, physical: inventory(game) };
|
||||
}
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
function ask(f: Fixture, source: Game, minimum: number) {
|
||||
let game = declareTruthBribe(f, source).game;
|
||||
const askerView = viewGame(game, f.asker);
|
||||
assert.ok(askerView.truthPledgeTargets.includes(f.target));
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'pledgeAid', target: f.target,
|
||||
turn: game.turn, phase: game.phase, ally: f.ally, minimum } });
|
||||
return game;
|
||||
}
|
||||
function answer(game: Game, actor: string, response: 'yes' | 'no') {
|
||||
return act(game, actor, { type: 'truthAnswer', answer: response });
|
||||
}
|
||||
|
||||
void test('Basic Yes binds a future accepted same-value ally-funding update without treating it as a payment', () => {
|
||||
const f = fixture();
|
||||
let game = act(f.game, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
const priorSpice = player(game, f.target).spice;
|
||||
const originalAid = structuredClone(game.aid[f.target]);
|
||||
game = ask(f, game, 3);
|
||||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(game, f.asker).truthPledgeAnswers, null);
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.equal(promise(game).player, f.target);
|
||||
assert.equal(promise(game).ally, f.ally);
|
||||
assert.equal(promise(game).minimum, 3);
|
||||
assert.ok(!promise(game).fulfilled, 'A pre-answer pledge does not fulfill a future update');
|
||||
assert.deepEqual(game.aid[f.target], originalAid);
|
||||
const guide = viewGame(game, f.target).pledgePromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'pledgeAid'); assert.equal(guide.action.amount, 3);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target);
|
||||
view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => act(game, f.target, action));
|
||||
}
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(game.aid[f.target], originalAid);
|
||||
assert.equal(player(game, f.target).spice, priorSpice);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('Basic No rejects a qualifying pledge without changing the real wallet or escrow', () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 3);
|
||||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('no'));
|
||||
game = answer(game, f.target, 'no');
|
||||
assert.equal(promise(game).answer, false);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 2 });
|
||||
const aid = structuredClone(game.aid[f.target]), spice = player(game, f.target).spice;
|
||||
rejected(game, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
assert.deepEqual(game.aid[f.target], aid);
|
||||
assert.equal(player(game, f.target).spice, spice);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('an unaffordable Yes is unavailable and cannot be forced by the client', () => {
|
||||
const f = fixture(), available = player(f.game, f.target).spice;
|
||||
const game = ask(f, f.game, available + 1);
|
||||
assert.deepEqual(viewGame(game, f.target).truthPledgeAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
});
|
||||
|
||||
void test('an unfulfilled Yes prevents the owner voluntarily spending its remaining pledge capacity', () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 4);
|
||||
game = answer(game, f.target, 'yes');
|
||||
const available = player(game, f.target).spice;
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: available - 1 });
|
||||
assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||||
const guide = viewGame(game, f.target).pledgePromiseGuide; assert.ok(guide);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('a No pledge commitment stops applying when actual Bidding closes', () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 3);
|
||||
game = answer(game, f.target, 'no');
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.ok(promise(game).released || promise(game).phase !== game.phase,
|
||||
'The earlier phase commitment cannot constrain a new phase');
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('Basic Shipment and Movement Yes preserves existing escrow and uses native pledge funding once', () => {
|
||||
const f = fixture(5);
|
||||
let game = act(f.game, f.target, { type: 'pledgeAid', amount: 1 });
|
||||
const before = structuredClone(game), recipientSpice = player(game, f.ally).spice;
|
||||
game = ask(f, game, 4);
|
||||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.equal(promise(game).phase, 5);
|
||||
assert.equal(game.aid[f.target].amount, 1);
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice);
|
||||
const guide = viewGame(game, f.target).pledgePromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.amount, 4);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(game.aid[f.target].recipient, f.ally);
|
||||
assert.equal(game.aid[f.target].amount, 4);
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice - 3);
|
||||
assert.equal(player(game, f.ally).spice, recipientSpice, 'Pledged spice has not been paid to the ally');
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('Basic Shipment and Movement No rejects qualifying aid and retains the accepted lower escrow', () => {
|
||||
const f = fixture(5);
|
||||
let game = ask(f, f.game, 4);
|
||||
game = answer(game, f.target, 'no');
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
const before = structuredClone(game);
|
||||
rejected(game, f.target, { type: 'pledgeAid', amount: 4 });
|
||||
assert.deepEqual(game.aid[f.target], before.aid[f.target]);
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 2 });
|
||||
assert.equal(game.aid[f.target].amount, 2);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
for (const response of ['yes', 'no'] as const) {
|
||||
void test(`a live ${response} pledge excludes a new independent structured action question`, () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 3);
|
||||
game = answer(game, f.target, response);
|
||||
const remainingTruth = f.truths.find(id => player(game, f.asker).hand.some(card => card.id === id));
|
||||
assert.ok(remainingTruth, 'The native source retains its second physical Truthtrance');
|
||||
game = declareTruthBribe({ ...f, truths: [remainingTruth] }, game).game;
|
||||
rejected(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||||
turn: game.turn, recipient: f.asker, minimum: 1 } });
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
}
|
||||
|
||||
void test('another player’s final deal acceptance releases a now-unaffordable pledge Yes after the real payment', () => {
|
||||
const f = fixture();
|
||||
const minimum = 4, available = player(f.game, f.target).spice;
|
||||
const amount = available - minimum + 1;
|
||||
assert.ok(amount > 0);
|
||||
// The donor actually offers and therefore consents to the immediate
|
||||
// payment before the later Truthtrance answer. No pending deal is staged.
|
||||
let game = act(f.game, f.target, { type: 'dealOffer', target: f.asker,
|
||||
terms: 'A previously agreed payment.',
|
||||
payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount } });
|
||||
const deal = game.deals?.at(-1); assert.ok(deal);
|
||||
game = ask(f, game, minimum);
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||||
const before = structuredClone(game), oldBribes = player(game, f.asker).bribes;
|
||||
// The other player’s final acceptance, rather than a new voluntary action
|
||||
// by the payer, executes the already-consented native immediate bribe.
|
||||
game = act(game, f.asker, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice - amount);
|
||||
assert.equal(player(game, f.asker).bribes, oldBribes + amount);
|
||||
assert.deepEqual(game.aid[f.target], before.aid[f.target]);
|
||||
assert.equal(promise(game).released, true);
|
||||
assert.ok(!promise(game).fulfilled);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('a hidden pledge amount does not reveal whether a public Yes is fulfilled', () => {
|
||||
const f = fixture();
|
||||
let bound = ask(f, f.game, 3);
|
||||
bound = answer(bound, f.target, 'yes');
|
||||
// Both are real updates to the same alliance after the same public answer.
|
||||
// Their publicly logged action and event count are identical, but only the
|
||||
// private escrow amount and fulfillment differ.
|
||||
const below = act(bound, f.target, { type: 'pledgeAid', amount: 2 });
|
||||
const fulfilled = act(bound, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
assert.ok(!promise(below).fulfilled);
|
||||
assert.equal(promise(fulfilled).fulfilled, true);
|
||||
const left = viewGame(below, f.asker), right = viewGame(fulfilled, f.asker);
|
||||
assert.deepEqual(left.log, right.log);
|
||||
assert.deepEqual(left.truthHistory, right.truthHistory);
|
||||
assert.deepEqual(left.aid, right.aid);
|
||||
assert.deepEqual(left.pledgePromises, []);
|
||||
assert.deepEqual(right.pledgePromises, []);
|
||||
assert.deepEqual(left.truthPledgeTargets, right.truthPledgeTargets);
|
||||
assert.deepEqual(left.truthBribeTargets, right.truthBribeTargets);
|
||||
|
||||
const remainingTruth = f.truths.find(id => player(below, f.asker).hand.some(card => card.id === id));
|
||||
assert.ok(remainingTruth);
|
||||
const rejection = (source: Game) => {
|
||||
const waiting = declareTruthBribe({ ...f, truths: [remainingTruth] }, source).game;
|
||||
const before = structuredClone(waiting);
|
||||
let reason = '';
|
||||
try {
|
||||
act(waiting, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||||
turn: waiting.turn, recipient: f.asker, minimum: 1 } });
|
||||
} catch (error) {
|
||||
reason = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
assert.ok(reason, 'The independent future-action question remains unavailable');
|
||||
assert.deepEqual(waiting, before);
|
||||
return reason;
|
||||
};
|
||||
assert.equal(rejection(below), rejection(fulfilled));
|
||||
assert.deepEqual(inventory(below), f.physical);
|
||||
assert.deepEqual(inventory(fulfilled), f.physical);
|
||||
});
|
||||
Reference in new issue
Block a user