460 lines
23 KiB
JavaScript
460 lines
23 KiB
JavaScript
/**
|
||
* Shop UI test (dev tool, run with Node — no browser needed):
|
||
*
|
||
* node --import ./dev/phaser-loader.mjs dev/shop-ui.test.mjs
|
||
*
|
||
* The SHOP SCREEN's economic core (js/ui/shop/ShopView.js + TradeGrid.js),
|
||
* driven headlessly against the real config + a real galaxy + a real
|
||
* CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
|
||
* verified in the browser) — it tests the LOGIC the player feels:
|
||
*
|
||
* - CONSTRUCT — the view builds both grids over a real shop (16 sell bays,
|
||
* 12–16 buy slots) and starts on the Buy/Sell tab.
|
||
* - SELL — checking a cargo bay sells one unit at the shop's sell price;
|
||
* Confirm earns the credits and drops the good out of the hold.
|
||
* - BUY — stepping a stock good buys it at the shop's buy price; Confirm
|
||
* spends the credits and fills the bays.
|
||
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
|
||
* credits) or overflow the hold (cargo-full); selling to free room for a
|
||
* buy lands in one confirm.
|
||
* - BUYBACK — disabled until a sale; after a sale the goods you sold can be
|
||
* bought back at the SALE price (a neutral undo — sell→buyback nets zero,
|
||
* no infinite-credits loop); Clear resets the selection.
|
||
*
|
||
* A fake Scene provides add.text/image/graphics/tweens/etc.; the phaser stub
|
||
* (dev/phaser-stub.mjs) provides the Container the components extend.
|
||
*/
|
||
|
||
process.env.NODE_ENV = 'dev';
|
||
|
||
import { pathToFileURL } from 'node:url';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import assert from 'node:assert/strict';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
// --- Load the real config (data/*.json) into the config singleton ------
|
||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||
const fs = await import('node:fs');
|
||
const dataDir = join(__dirname, '../data');
|
||
const configData = {};
|
||
for (const f of fs.readdirSync(dataDir)) {
|
||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||
}
|
||
config.init(configData);
|
||
|
||
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
|
||
const { CargoHold } = await import(pathToFileURL(join(__dirname, '../js/economy/CargoHold.js')).href);
|
||
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
|
||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||
const { ShopView } = await import(pathToFileURL(join(__dirname, '../js/ui/shop/ShopView.js')).href);
|
||
|
||
const goods = config.get('goods.goods', []);
|
||
assert.ok(goods.length >= 2, 'catalog has goods to trade');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// A headless Scene: just enough for the components to CONSTRUCT + render to
|
||
// no-op stubs. The LOGIC under test lives in the components + Economy, not
|
||
// here — this only keeps `scene.add.text/image/graphics/...` from throwing.
|
||
// ---------------------------------------------------------------------------
|
||
function stubObject(extra = {}) {
|
||
return {
|
||
setOrigin() { return this; },
|
||
setAlpha(a) { this.alpha = a; return this; },
|
||
setScale() { return this; },
|
||
setTint() { return this; },
|
||
setPosition(x, y) { this.x = x; this.y = y; return this; },
|
||
setStrokeStyle() { return this; },
|
||
setSize(w, h) { this.width = w; this.height = h; return this; },
|
||
setBlendMode() { return this; },
|
||
setInteractive(opts) { this._interactive = opts; return this; },
|
||
setTexture(key, frame) { this._textureKey = key; this.frame = frame; return this; },
|
||
on() { return this; },
|
||
once() { return this; },
|
||
off() { return this; },
|
||
emit() { return this; },
|
||
setText(s) { this.text = String(s); return this; },
|
||
setColor(c) { this.color = c; return this; },
|
||
setFontSize() { return this; },
|
||
setWordWrap() { return this; },
|
||
setLineSpacing() { return this; },
|
||
setPadding() { return this; },
|
||
setScrollFactor() { return this; },
|
||
setDepth() { return this; },
|
||
set() { return this; },
|
||
clear() { return this; },
|
||
fillStyle() { return this; },
|
||
lineStyle() { return this; },
|
||
fillPoints() { return this; },
|
||
strokePoints() { return this; },
|
||
lineBetween() { return this; },
|
||
rect() { return this; },
|
||
fillRect() { return this; },
|
||
lineRect() { return this; },
|
||
fill() { return this; },
|
||
stroke() { return this; },
|
||
destroy() { this.destroyed = true; },
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function makeScene() {
|
||
const scene = {
|
||
scale: { width: 1280, height: 720, on() { return this; } },
|
||
add: {
|
||
text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style, width: 60, height: 18 }),
|
||
image: (x, y, key, frame) => stubObject({ x, y, key, frame }),
|
||
graphics: () => stubObject({}),
|
||
rectangle: (x, y, w, h, key, alpha) => stubObject({ x, y, width: w, height: h, key, alpha }),
|
||
existing: (o) => o,
|
||
},
|
||
tweens: {
|
||
// Apply the target values immediately (so the grid ends at its final
|
||
// layout position — the real tween animates to the same place).
|
||
add: (t) => {
|
||
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
|
||
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
|
||
else if (t?.targets) apply(t.targets);
|
||
return { destroy() {}, stop() {} };
|
||
},
|
||
killTweensOf() {},
|
||
},
|
||
time: { now: 0, delayCall: () => ({ stop() {} }) },
|
||
input: { pointer: {}, on() { return this; }, off() { return this; } },
|
||
events: { on() { return this; }, off() { return this; }, once() { return this; }, emit() { return this; } },
|
||
textures: { exists: (k) => k === 'goods_icons' },
|
||
cameras: { main: { setScroll() { return this; }, flash() { return this; } } },
|
||
game: { loop: { now: 0 } },
|
||
};
|
||
scene.add.container = (x, y) => new Phaser.GameObjects.Container(scene, x, y);
|
||
return scene;
|
||
}
|
||
|
||
// A minimal ship: the real CargoHold + the credit ledger the ShopView drives.
|
||
function makeShip(credits = 0) {
|
||
const cargo = new CargoHold(4);
|
||
return {
|
||
credits,
|
||
cargo,
|
||
addCredits(n) { this.credits = Math.max(0, this.credits + Math.round(Number(n) || 0)); return this; },
|
||
spendCredits(n) { n = Math.round(Number(n) || 0); if (n > this.credits) return false; this.credits -= n; return true; },
|
||
};
|
||
}
|
||
|
||
const galaxy = Galaxy.create('orbit-shop-ui');
|
||
const classesOf = (id) => {
|
||
const s = galaxy.economy.classBySystem.get(id);
|
||
return s ? [...s] : [];
|
||
};
|
||
const sysId = [...galaxy.byId.keys()].find((id) => classesOf(id).some((c) => c !== 'capital'));
|
||
const cls = classesOf(sysId).find((c) => c !== 'capital') ?? 'rocky';
|
||
const stock = E.shopFor(cls, sysId, galaxy).buy;
|
||
assert.ok(stock.length >= 1, 'the shop has goods to buy');
|
||
|
||
const A = goods[0].id;
|
||
const B = goods[1].id;
|
||
const C = stock[0].id; // a good the shop actually sells
|
||
|
||
function makeView(ship, { reveal = true, topY = 120 } = {}) {
|
||
const scene = makeScene();
|
||
const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; } };
|
||
const view = new ShopView(scene, {
|
||
ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {},
|
||
// topY = the surface HUD's planet-text bottom + padding (what
|
||
// SurfaceScene.shopTopY() computes). The panel top should sit here.
|
||
topY,
|
||
});
|
||
if (reveal) view.setTab('trade'); // reveal the panel (what clicking "Buy/Sell" does)
|
||
return { scene, view, gs, topY };
|
||
}
|
||
|
||
// ===========================================================================
|
||
console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals the panel ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A, B]);
|
||
const { view, topY } = makeView(ship, { reveal: false });
|
||
// Entry state: shop video + tab bar only. No trade panel yet — the player
|
||
// must click "Buy/Sell" to reveal it (showcase the clip before the UI).
|
||
assert.equal(view.tab, null, 'starts unopened (entry state)');
|
||
assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)');
|
||
assert.equal(view.buyGrid, null, 'no BUY grid yet');
|
||
assert.ok(view._tabTrade, 'the Buy/Sell tab is present in the entry state');
|
||
assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)');
|
||
assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale');
|
||
|
||
// The tabs are cyberpunk MenuButtons in a sub-bar that rests ABOVE the
|
||
// deck (the MenuSubBar recipe) — not the old top-of-screen custom buttons.
|
||
assert.equal(view._tabBuyback.disabled, true, 'Buyback tab is disabled (grayed) before a sale');
|
||
assert.equal(view._tabTrade.disabled, false, 'Buy/Sell tab is enabled (the entry point)');
|
||
assert.ok(view.subBar, 'the sub-bar container exists');
|
||
assert.ok(view.subBar.y > view._H() - 80, `sub-bar rests above the deck (bar.y=${view.subBar.y}, H=${view._H()})`);
|
||
assert.ok(view.y === 0, 'ShopView root stays at the screen origin (0,0) so the trade panel renders in screen space');
|
||
assert.ok(view.subBarW > 0 && view.subBarH > 0, 'sub-bar has a measured width + height');
|
||
|
||
// Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear.
|
||
view.setTab('trade');
|
||
assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab');
|
||
assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist after reveal');
|
||
// The panel's TOP edge sits at topY (just under the planet text). A
|
||
// TradeGrid lays its plate/cells out in local (0,0)→(w,h) and a
|
||
// Container's (x,y) is a pure translation of that local space (verified
|
||
// in dev/cbox-test.html) — so the grid's position IS the panel's
|
||
// top-left corner: gridTop == grid.y. (Setting it to topY + h/2 treated
|
||
// the position like a centered origin and dropped the panel half a grid
|
||
// lower, clipping the bottom rows.)
|
||
const gridTop = view.sellGrid.y;
|
||
assert.ok(Math.abs(gridTop - topY) < 2, `panel top is at topY (gridTop=${gridTop.toFixed(1)}, topY=${topY})`);
|
||
assert.ok(view.sellGrid.y > 80 && view.sellGrid.y < view._H() - 40, `trade panel is on-screen (sellGrid.y=${view.sellGrid.y})`);
|
||
assert.ok(view.sellGrid.y + view.sellGrid.height <= view._H(), `panel bottom stays on-screen (${view.sellGrid.y} + ${view.sellGrid.height} <= ${view._H()})`);
|
||
assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays');
|
||
assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots');
|
||
assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock');
|
||
assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
|
||
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet');
|
||
}
|
||
console.log('CONSTRUCT: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCallback ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A, B]);
|
||
const { view } = makeView(ship);
|
||
const goodById = (id) => goods.find((g) => g.id === id);
|
||
|
||
// Each BUY cell's icon must carry the correct spritesheet frame (set via
|
||
// setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
|
||
// corrupted the texture source and crashed the real WebGL render).
|
||
let checkedIcons = 0;
|
||
for (let i = 0; i < view.buyGrid.cells.length; i++) {
|
||
const cell = view.buyGrid.cells[i];
|
||
const id = view.buyGrid.stock[i]?.id;
|
||
if (!id) continue;
|
||
const idx = goodById(id)?.icon;
|
||
assert.ok(typeof idx === 'number', `good ${id} has an icon index`);
|
||
assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
|
||
assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`);
|
||
checkedIcons += 1;
|
||
}
|
||
assert.ok(checkedIcons >= 1, 'at least one buy cell got its icon bound');
|
||
|
||
// Every tappable element must be interactive WITH a hitAreaCallback — a bare
|
||
// setInteractive() leaves no callback and crashes Phaser v4's input (the
|
||
// exact browser error). The stub now mirrors Phaser.Geom.Rectangle so the
|
||
// real hitArea/hitAreaCallback path is exercised.
|
||
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback');
|
||
assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect');
|
||
assert.equal(typeof view.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback');
|
||
|
||
// The two panels must settle at DIFFERENT layout positions — the slide-in
|
||
// tween targets each grid's set position (the ShopView centers
|
||
// [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels
|
||
// at the left edge so the cargo/sell panel sat hidden behind the buy panel.
|
||
assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`);
|
||
assert.ok(view.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`);
|
||
assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin');
|
||
}
|
||
console.log('INTERACTION: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A, B]);
|
||
const { view } = makeView(ship);
|
||
const nameA = goods.find((g) => g.id === A).name;
|
||
const sellA = E.sellPrice(A, cls, sysId, galaxy);
|
||
const buyId = view.buyGrid.stock[0]?.id;
|
||
const nameB = view.buyGrid.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
|
||
const buyB = E.buyPrice(buyId, cls, sysId, galaxy);
|
||
|
||
// Cells no longer paint their own name/price text — the info lives in the
|
||
// shared per-grid tooltip (one per side), revealed by hover.
|
||
assert.equal(view.sellGrid.cells[0].name, undefined, 'no name text over the icon anymore');
|
||
assert.equal(view.sellGrid.cells[0].price, undefined, 'no price text over the icon anymore');
|
||
assert.ok(view.sellGrid.tip, 'the SELL grid has a shared tooltip');
|
||
assert.equal(view.sellGrid.tipVisible, false, 'tooltip starts hidden');
|
||
|
||
// Hover a filled bay → name + "sell for N cr" appear.
|
||
view.sellGrid.hoverCell(0);
|
||
assert.equal(view.sellGrid.tipVisible, true, 'hovering a filled bay reveals the tooltip');
|
||
assert.ok(view.sellGrid.tip.alpha > 0.5, `tooltip is visible (alpha=${view.sellGrid.tip.alpha})`);
|
||
assert.equal(view.sellGrid.tipName.text, nameA, `tooltip shows the good's name (${nameA})`);
|
||
assert.equal(view.sellGrid.tipPrice.text, `sell for ${Math.round(sellA)} cr`, `tooltip shows the sell price (${sellA})`);
|
||
|
||
// Locked bays (past capacity) + empty bays have nothing to reveal.
|
||
const lockedIdx = view.sellGrid.capacity; // first locked bay
|
||
view.sellGrid.hoverCell(lockedIdx);
|
||
assert.equal(view.sellGrid.tipVisible, false, 'locked bay: no tooltip (nothing to sell)');
|
||
if (view.sellGrid.bays[2] == null) {
|
||
view.sellGrid.hoverCell(2);
|
||
assert.equal(view.sellGrid.tipVisible, false, 'empty bay: no tooltip (no good in it)');
|
||
}
|
||
|
||
// pointerout → hidden again.
|
||
view.sellGrid.hoverCell(0);
|
||
view.sellGrid.unhover();
|
||
assert.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip');
|
||
assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down');
|
||
|
||
// BUY side: name + "buy for N cr".
|
||
view.buyGrid.hoverCell(0);
|
||
assert.equal(view.buyGrid.tipVisible, true, 'hovering a stock cell reveals the tooltip');
|
||
assert.equal(view.buyGrid.tipName.text, nameB, 'buy tooltip shows the good name');
|
||
assert.equal(view.buyGrid.tipPrice.text, `buy for ${Math.round(buyB)} cr`, `buy tooltip shows the buy price (${buyB})`);
|
||
|
||
// THEME FONTS — the shop UI speaks the project's type (data/theme.json):
|
||
// Ethnocentric for section headers, Centauri for body text + buttons.
|
||
const fam = (o) => o?._style?.fontFamily ?? '';
|
||
assert.ok(/Ethnocentric/.test(fam(view.sellGrid.title)), `SELL/BUY title uses the header face (got "${fam(view.sellGrid.title)}")`);
|
||
assert.ok(/Centauri/.test(fam(view.sellGrid.tipName)), `tooltip name uses the body face (got "${fam(view.sellGrid.tipName)}")`);
|
||
assert.ok(/Centauri/.test(fam(view.sellGrid.tipPrice)), 'tooltip price uses the body face');
|
||
assert.ok(/Centauri/.test(fam(view.buyGrid.cells[0].stepQty)), 'buy stepper uses the body face');
|
||
assert.ok(/Centauri/.test(fam(view.sellGrid.cells[view.sellGrid.capacity]?.label)), 'locked-bay label uses the body face');
|
||
}
|
||
console.log('HOVER TOOLTIP: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A, B]);
|
||
const { view, gs } = makeView(ship);
|
||
const sellA = E.sellPrice(A, cls, sysId, galaxy);
|
||
assert.ok(sellA > 0, 'the sell price is positive');
|
||
|
||
view.sellGrid.toggleBay(0); // bay 0 holds A
|
||
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 1 }, 'checking bay 0 → sell one A');
|
||
let pv = view.preview();
|
||
assert.equal(pv.sellTotal, sellA, 'sellTotal = the A sale');
|
||
assert.equal(pv.net, sellA, '… net = the sale (nothing bought)');
|
||
assert.equal(pv.valid, true, '… and it\u2019s legal');
|
||
|
||
const before = ship.credits;
|
||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||
assert.equal(ship.credits, before + sellA, 'credits rise by the sale');
|
||
assert.deepEqual(ship.cargo.snapshot(), [B], 'A leaves the hold, B stays');
|
||
assert.equal(view.isBuybackEnabled(), true, '… and A is now buyback-eligible');
|
||
assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed');
|
||
}
|
||
console.log('SELL: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays ==');
|
||
{
|
||
const ship = makeShip(10000); // empty hold (room for 4)
|
||
const { view, gs } = makeView(ship);
|
||
const buyC = E.buyPrice(C, cls, sysId, galaxy);
|
||
assert.ok(buyC > 0, 'the buy price is positive');
|
||
|
||
view.buyGrid.bumpBuy(C, 2);
|
||
assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
|
||
let pv = view.preview();
|
||
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
|
||
assert.equal(pv.net, -buyC * 2, '… net = the spend');
|
||
assert.equal(pv.valid, true, '… affordable and room for two');
|
||
|
||
const before = ship.credits;
|
||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||
assert.equal(ship.credits, before - buyC * 2, 'credits fall by the spend');
|
||
assert.deepEqual(ship.cargo.snapshot(), [C, C], '… and the two C fill the hold');
|
||
assert.equal(gs.refreshed, 1, '… and the HUD refreshes');
|
||
}
|
||
console.log('BUY: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== CAPS — blocked when negative or over the hold ==');
|
||
{
|
||
// Not enough credits.
|
||
const broke = makeShip(0);
|
||
const buyC = E.buyPrice(C, cls, sysId, galaxy);
|
||
const { view: vb } = makeView(broke);
|
||
vb.buyGrid.bumpBuy(C, 1);
|
||
let pv = vb.preview();
|
||
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
|
||
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
|
||
assert.equal(vb.confirm(), false, '… and Confirm is refused');
|
||
assert.equal(broke.credits, 0, '… nothing spent');
|
||
assert.equal(broke.cargo.used, 0, '… nothing bought');
|
||
|
||
// Over the hold (full 4, buy 1 → 5).
|
||
const full = makeShip(100000);
|
||
full.cargo.add([A, B, A, B]); // 4 bays, no room
|
||
const { view: vf } = makeView(full);
|
||
vf.buyGrid.bumpBuy(C, 1);
|
||
pv = vf.preview();
|
||
assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
|
||
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full');
|
||
assert.equal(vf.confirm(), false, '… and Confirm is refused');
|
||
|
||
// …but SELL one to free a bay, then buy one → valid in the same confirm.
|
||
vf.sellGrid.toggleBay(0); // sell bay 0 (an A)
|
||
pv = vf.preview();
|
||
assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits');
|
||
assert.equal(pv.valid, true, '… now it\u2019s legal (room freed)');
|
||
const before = full.credits;
|
||
assert.equal(vf.confirm(), true, '… and it goes through');
|
||
assert.deepEqual(full.cargo.snapshot().sort(), [A, B, B, C].sort(), 'one A left, one C arrived (net hold 4)');
|
||
assert.equal(full.credits, before + E.sellPrice(A, cls, sysId, galaxy) - E.buyPrice(C, cls, sysId, galaxy), '… credits = +sale − buy');
|
||
}
|
||
console.log('CAPS: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== BUYBACK — sell, then buy it back at the sale price (neutral) ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A]);
|
||
const { view } = makeView(ship);
|
||
const sellA = E.sellPrice(A, cls, sysId, galaxy);
|
||
|
||
// Sell one A.
|
||
view.sellGrid.toggleBay(0);
|
||
assert.equal(view.confirm(), true, 'sell the A');
|
||
assert.equal(ship.credits, 10000 + sellA, '… earned the sale price');
|
||
assert.deepEqual(ship.cargo.snapshot(), [], '… hold is empty');
|
||
assert.equal(view.isBuybackEnabled(), true, 'Buyback is now enabled');
|
||
|
||
// Switch to the Buyback tab and buy the A back.
|
||
view.setTab('buyback');
|
||
assert.equal(view.tab, 'buyback', '… on the Buyback tab');
|
||
assert.deepEqual(view._bbRows(), [{ id: A, name: goods[0].name, qty: 1, price: sellA, sel: 0 }], '… the sold A is listed at the sale price');
|
||
|
||
view.bumpBuyback(A, 1);
|
||
assert.deepEqual(view.getBuybackMap(), { [A]: 1 }, 'stepping once → buy back one A');
|
||
let pv = view.preview();
|
||
assert.equal(pv.buyTotal, sellA, '… it costs the SALE price (the neutral undo)');
|
||
assert.equal(pv.net, -sellA, '… net = −sale');
|
||
assert.equal(pv.valid, true, '… affordable and room for one');
|
||
|
||
const before = ship.credits;
|
||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||
assert.equal(ship.credits, before - sellA, '… spends the sale price → back to 10000');
|
||
assert.equal(ship.credits, 10000, '… so sell→buyback nets ZERO (no free-credit loop)');
|
||
assert.deepEqual(ship.cargo.snapshot(), [A], '… and the A is back in the hold');
|
||
assert.equal(view.isBuybackEnabled(), false, '… fully bought back → Buyback disables again');
|
||
}
|
||
console.log('BUYBACK: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A]);
|
||
const { view } = makeView(ship);
|
||
assert.equal(view.isBuybackEnabled(), false, 'no sale yet → Buyback disabled');
|
||
view.setTab('buyback');
|
||
assert.equal(view.tab, 'trade', '… and it refuses to open (stays on Buy/Sell)');
|
||
|
||
view.sellGrid.toggleBay(0);
|
||
assert.equal(view.sellGrid.hasSelection(), true, 'a bay is checked');
|
||
view.clear();
|
||
assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection');
|
||
assert.equal(view.preview().net, 0, '… and the net returns to zero');
|
||
}
|
||
console.log('LOCK + CLEAR: OK');
|
||
|
||
console.log('\nAll shop-UI checks passed \u2714');
|