orbit/dev/shop-ui.test.mjs

460 lines
23 KiB
JavaScript
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.

/**
* 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');