orbit/dev/shop-ui.test.mjs

762 lines
42 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 the SELL grid + the BUY list over a real
* shop (16 sell bays; one wide row per stocked good) 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 row buys that good at the shop's buy price;
* Confirm spends the credits and fills the bays.
* - SCROLL — the stock list outgrows the viewport: scroll clamps at both
* ends, rows are CLIPPED to the window (plate slice at the edge, text
* only while fully inside — nothing may render over the title or onto
* the Confirm bar), and a right-edge SCROLLBAR (track + thumb) tracks
* the scroll position; selection is data (stepping an off-screen row
* still works).
* - 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; },
setWordWrapWidth(w) { this._wrapW = w; 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.buyList, null, 'no BUY list 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');
// The trade ACTIONS live on the sub-bar too — [tabs] │ [Clear][Confirm] │
// [NET value] — created up front (persistent across tabs), hidden until a
// selection exists (_syncPreview reveals them), Confirm grayed while the
// trade is invalid.
assert.ok(view._clearBtn, 'Clear lives on the sub-bar (persistent, not in the panel)');
assert.ok(view._confirmBtn, 'Confirm lives on the sub-bar (persistent, not in the panel)');
assert.ok(view._clearBtn.x < view._confirmBtn.x, 'Clear sits left of Confirm on the bar');
assert.ok(view._tabBuyback.x < view._clearBtn.x, '… after the tabs');
assert.ok(view._confirmBtn.x < view._netLabel.x, '… and the NET readout rides to their right');
assert.equal(view._clearBtn.alpha, 0, 'entry state: Clear is hidden (no selection yet)');
assert.equal(view._confirmBtn.alpha, 0, 'entry state: Confirm is hidden (no selection yet)');
assert.equal(view._confirmBtn.disabled, true, 'entry state: Confirm is grayed');
// The bar spans the FULL DECK — the anchor's left/right edges (the main
// action bar's own span): tabs packed at the LEFT edge, the trade actions
// + NET readout packed at the RIGHT edge, the value right-aligned at the
// bar's end (a growing number never shifts the label or the buttons).
const pad = config.get('save.subBar.padding', 16);
assert.ok(view.subBarW > 0 && view.anchor.left < view.anchor.right, 'anchor carries the deck span (left < right)');
assert.equal(view.subBarW, view.anchor.right - view.anchor.left, 'sub-bar spans the full deck width (left edge → right edge)');
assert.equal(view.subBar.x, (view.anchor.left + view.anchor.right) / 2, 'sub-bar is centred on the deck span');
const half = view.subBarW / 2;
const tw = view._tabTrade.style.width;
assert.ok(Math.abs(view._tabTrade.x - (-half + pad + tw / 2)) < 1, `tabs pack at the bar's LEFT edge (tabTrade.x=${view._tabTrade.x}, want ${(-half + pad + tw / 2).toFixed(1)})`);
// The NET value is RIGHT-ANCHORED (origin 1, 0.5) at the bar's end: its
// anchor sits at barRight − pad and its width extends leftward, so a
// longer total never overflows the bar. The label stays clear of even the
// WIDEST value (the reserved slot, _netValueW), so nothing overlaps.
assert.ok(Math.abs(view._netValue.x - (half - pad)) < 1, `NET value right-anchored at the bar's RIGHT edge (netValue.x=${view._netValue.x}, want ${(half - pad).toFixed(1)})`);
assert.ok(view._netLabel.x + view._netLabel.width / 2 < view._netValue.x - view._netValueW + 0.001, 'NET label sits clear of the widest value (reserved slot)');
// No overlap, left to right: tabs │ Clear │ Confirm │ NET label.
const rightEdge = (el) => el.x + (el.style?.width ?? el.width) / 2;
const leftEdge = (el) => el.x - (el.style?.width ?? el.width) / 2;
assert.ok(rightEdge(view._tabBuyback) < leftEdge(view._clearBtn), 'BUYBACK tab does not overlap Clear');
assert.ok(rightEdge(view._clearBtn) < leftEdge(view._confirmBtn), 'Clear does not overlap Confirm');
assert.ok(rightEdge(view._confirmBtn) < leftEdge(view._netLabel), 'Confirm does not overlap the NET label');
// 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.buyList, 'both the SELL grid and BUY list 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.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock');
assert.equal(view.buyList.rows.length, view.buyList.stock.length, 'BUY list has one row per stocked good');
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 row'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 (const row of view.buyList.rows) {
const idx = goodById(row.id)?.icon;
assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`);
assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`);
checkedIcons += 1;
}
assert.ok(checkedIcons >= 1, 'at least one buy row 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.buyList.rows[0].objs.minus?._interactive?.hitAreaCallback, 'function', 'buy row stepper tap has a hitAreaCallback');
assert.equal(typeof view.buyList.rows[0].objs.plus?._interactive?.hitAreaCallback, 'function', 'buy row + tap has a hitAreaCallback');
// The two panels must settle at DIFFERENT layout positions — the slide-in
// tween targets each grid's set position. 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.buyList.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyList.x})`);
assert.notEqual(view.sellGrid.x, view.buyList.x, 'the panels do not overlap at the origin');
assert.ok(view.buyList.width > view.sellGrid.width, 'the BUY list is the wide panel (rows, not 4×4 cells)');
// The panel spans the screen like the HUD: the SELL grid's LEFT edge is
// the planet text's left edge (landing.hud.marginX), and the BUY list's
// RIGHT edge is the SAME padding in from the screen's right edge.
const leftPad = Number(config.get('landing.hud.marginX')) || 24;
const W = 1280; // the stub scene's width
assert.equal(view.sellGrid.x, leftPad, `SELL's left edge is flush with the planet text's left edge (x=${view.sellGrid.x}, hud margin=${leftPad})`);
const rightEdge = view.buyList.x + view.buyList.width;
assert.ok(Math.abs(rightEdge - (W - leftPad)) < 1, `BUY's right edge is the same padding in from the right (rightEdge=${rightEdge}, want ${W - leftPad})`);
// The two panels sit NEXT TO each other with just a bit of padding between
// (grid.centerW — the actions moved onto the sub-bar, so there's no center
// column anymore).
const gapPx = view.buyList.x - (view.sellGrid.x + view.sellGrid.width);
assert.ok(Math.abs(gapPx - 16) < 1, `BUY is right next to SELL, small padding (got gap ${gapPx.toFixed(1)} px, want ~16)`);
}
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.buyList.stock[0]?.id;
const nameB = view.buyList.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: no tooltip — the WIDE ROW carries name + description + price
// inline (the row is wide enough, unlike the icon-only sell cells).
const row0 = view.buyList.rows[0];
assert.equal(row0.objs.name.text, nameB, 'row carries the good name inline');
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price inline');
const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? '';
assert.ok(row0.objs.desc.text.length >= 3, 'row carries the description');
assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)');
// DESCRIPTION WRAP: smaller font (11px), wraps to up to 2 lines, and the
// wrap width stops BEFORE the price/stepper so a wrapped line can never
// overlap them (the char-count budget is the headless fail-safe).
assert.equal(row0.objs.desc._style?.fontSize, '11px', 'desc uses the smaller 11px font');
assert.ok(row0.objs.desc._wrapW > 0, 'desc has a word-wrap width set (real Phaser wraps inside the text column)');
const descMaxW = view.buyList._descMaxW();
assert.ok(descMaxW > 0, 'the text column has a positive width');
assert.ok(row0.objs.desc._wrapW <= descMaxW + 0.001, `… and the wrap width fits inside it (wrap=${row0.objs.desc._wrapW}, col=${descMaxW.toFixed(1)})`);
const budget = Math.max(4, Math.floor(descMaxW / (11 * 0.55))) * 2 + 1; // 2 lines + ellipsis room
assert.ok(row0.objs.desc.text.length <= budget, `desc stays inside the two-line budget (len=${row0.objs.desc.text.length} ≤ ${budget})`);
// The wrapped desc never bleeds into the price: name/desc start at the text
// column's left edge, well clear of the price's right-aligned x.
const priceX = view.buyList._btnXs().price;
assert.ok(view.buyList._textX() + descMaxW < priceX, 'text column + max desc width ends before the price');
// 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.buyList.rows[0].objs.qty)), 'buy row 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');
// The live NET readout rides on the TAB SUB-BAR (not the center column) —
// it mirrors the pending trade's credit delta, one glance from the tabs —
// alongside the Confirm/Clear actions (which light up with a selection).
assert.ok(view._netValue, 'the sub-bar carries the NET value readout');
assert.ok(view._netLabel, '… with the NET label');
assert.equal(view._netValue.text, `+${sellA} cr`, '… it shows the pending +cr');
assert.ok(view._confirmBtn.alpha > 0.5, 'a selection reveals the sub-bar Confirm');
assert.ok(view._clearBtn.alpha > 0.5, '… and Clear');
assert.equal(view._confirmBtn.disabled, false, '… and Confirm is live (valid trade)');
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');
assert.equal(view._netValue.text, '0 cr', '… and the bar’s NET settles back to 0 cr');
assert.equal(view._confirmBtn.alpha, 0, '… and the Confirm/Clear actions hide again (selection gone)');
assert.equal(view._clearBtn.alpha, 0, '… Clear hides too');
}
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.buyList.bumpBuy(C, 2);
assert.deepEqual(view.buyList.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== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
{
const ship = makeShip(10000);
const { view } = makeView(ship);
const list = view.buyList;
assert.ok(list.rows.length >= 8, `the shop stock has ${list.rows.length} rows (≥ 8)`);
// 64+6 px per row vs a ~420 px viewport → the content is TALLER than the
// window, so the list must scroll (rows that don't fit were the old bug).
assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`);
// Scrolling clamps at both ends.
list.scrollBy(100000);
assert.equal(list._scroll, list.maxScroll, 'scroll clamps at the bottom');
list.scrollBy(-100000);
assert.equal(list._scroll, 0, 'scroll clamps back at the top');
// Row-based clipping: mid-scroll, only the window's rows are shown.
list.scrollTo(list.maxScroll / 2);
const shown = list.rows.filter((r) => r.shown);
const hidden = list.rows.filter((r) => !r.shown);
assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`);
assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)');
// ELEMENT CLIPPING — the spill the player saw: a partially-out row used to
// render WHOLE (ghosting over the title above / the Confirm bar below).
// Now: `shown` = overlaps the window; every element that IS rendered fits
// WHOLLY inside (this build can't crop text pixels, so partially-out
// elements hide). Check the invariant on the whole list, at the top of
// the scroll where the bottom rows are the edge case.
list.scrollTo(0);
const vpTop = list.vpY, vpBot = list.vpY + list.vpH;
const inBox = (el) => { const h = (Number(el.height) > 0 ? el.height : 20) * (el.scaleY ?? 1) / 2; return el.y - h >= vpTop - 0.5 && el.y + h <= vpBot + 0.5; };
let edgeRow = null;
for (const row of list.rows) {
const eL = [...Object.values(row.objs)];
const visible = eL.filter((el) => el && el.alpha === 1);
if (row.shown) {
for (const el of visible) assert.ok(inBox(el), `rendered element of row ${row.id} is WHOLLY inside the viewport (y=${el.y.toFixed(1)}, h=${el.height ?? '?'})`);
// a slice row: the window cuts it — some elements in, some out
if (visible.length < eL.filter((el) => el).length) edgeRow = row;
} else {
assert.equal(visible.length, 0, `row ${row.id} is off-window → nothing of it renders`);
}
}
assert.ok(edgeRow, 'at scroll 0 a bottom row is PARTIALLY in (plate slice) with its text clipped out');
assert.ok(edgeRow.clipBot <= vpBot + 0.5, '… the plate slice ends at the window bottom (not past it)');
assert.ok(list.rows[0].objs.name.alpha === 1 && list.rows[0].objs.plus.alpha === 1, '… and the fully-inside top row renders everything');
// SCROLLBAR — a track at the viewport's right edge + a thumb that tracks
// the scroll (and stays inside the panel). Hidden when content fits.
const sb = list._sbGeom();
assert.ok(sb.x >= list.vpX + list.vpW, 'scrollbar sits right of the viewport (in the right pad)');
assert.ok(sb.x + sb.w <= list.width, '… inside the panel');
assert.equal(sb.y, list.vpY, '… aligned to the viewport top');
assert.equal(sb.h, list.vpH, '… spanning the viewport height');
assert.ok(list._sbThumb, 'content outgrows the window → a thumb exists');
const th = list._sbThumb;
assert.ok(th.h >= sb.min && th.h <= sb.h, `thumb height is sane (${th.h.toFixed(1)}px, min ${sb.min}, max ${sb.h})`);
assert.ok(th.h < sb.h, '… and shorter than the track (content > viewport)');
assert.ok(Math.abs(th.h - sb.h * (list.vpH / list.contentH)) < 1, 'thumb height ∝ viewport/content share');
assert.ok(Math.abs(th.y - sb.y) < 0.5, 'scroll 0 → thumb at the track top');
list.scrollTo(list.maxScroll);
assert.ok(Math.abs(list._sbThumb.y - (sb.y + sb.h - th.h)) < 0.5, 'scroll max → thumb at the track bottom');
list.scrollTo(list.maxScroll / 2);
assert.ok(list._sbThumb.y > sb.y + 1 && list._sbThumb.y < sb.y + sb.h - th.h, '… mid-scroll sits between');
// Jump math: press at the track top/bottom → scroll 0 / maxScroll.
assert.equal(list._sbScrollForPoint(sb.y), 0, 'press at track top → scroll 0');
assert.ok(Math.abs(list._sbScrollForPoint(sb.y + sb.h) - list.maxScroll) < 1, 'press at track bottom → max scroll');
// Content that FITS the window → no scrollbar at all.
const fullStock = list.stock.map((s) => ({ ...s }));
list.refresh({ stock: [{ id: 'solo', name: 'Solo', price: 1 }] });
assert.equal(list.maxScroll, 0, 'single row → no scroll');
assert.equal(list._sbThumb, null, '… and no thumb (a full-track bar would lie)');
// Selection is DATA, not position: stepping a good works whether its row
// is on screen or not (the player can scroll, then adjust, freely).
list.refresh({ stock: fullStock });
const last = list.stock[list.stock.length - 1].id;
list.bumpBuy(last, 1);
assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects');
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
list.clearSelection();
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
}
console.log('SCROLL: 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.buyList.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(vb._confirmBtn.disabled, true, '… and the sub-bar Confirm is grayed (invalid trade)');
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.buyList.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)');
assert.equal(vf._confirmBtn.disabled, false, '… and Confirm un-grays (the trade is valid again)');
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');
assert.equal(view._confirmBtn.alpha, 0, '… and the sub-bar Confirm hides again');
assert.equal(view._clearBtn.alpha, 0, '… and Clear');
}
console.log('LOCK + CLEAR: OK');
// ===========================================================================
console.log('\n== TOGGLE — BUY/SELL presses open the panel; again closes it ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship, { reveal: false });
// Entry state: panel closed, sub-bar idle.
assert.equal(view.tab, null, 'entry state: the panel is closed');
assert.equal(view._netValue.text, '0 cr', '… and the NET readout is zero');
assert.equal(view._confirmBtn.alpha, 0, '… and Confirm is hidden');
// Press BUY/SELL (the button handler is _onTabTap) → the panel opens.
view._onTabTap('trade');
assert.equal(view.tab, 'trade', 'pressing BUY/SELL opens the trade panel');
assert.ok(view.sellGrid && view.buyList, '… with both panels built');
// Build a pending trade: sell A (bay 0) + buy 2 of the stocked good.
view.sellGrid.toggleBay(0);
view.buyList.bumpBuy(C, 2);
const netBefore = view.preview().net;
// Press BUY/SELL AGAIN → the panel CLOSES (toggle off)…
view._onTabTap('trade');
assert.equal(view.tab, null, 'pressing BUY/SELL again closes the panel');
assert.equal(view.sellGrid, null, '… the SELL grid is torn down');
assert.equal(view.buyList, null, '… and the BUY list');
// … but the pending trade is SAVED, not lost — the sub-bar stays live.
assert.deepEqual(view._pendingSell, { [A]: 1 }, '… the SELL selection is saved');
assert.deepEqual(view._pendingBuy, { [C]: 2 }, '… and the BUY selection');
assert.equal(view.preview().net, netBefore, '… the preview still sees the same trade');
assert.equal(view._confirmBtn.alpha, 1, '… Confirm is live on the closed sub-bar');
assert.equal(view._clearBtn.alpha, 1, '… and Clear');
// Clear (with the panel closed) cancels the saved trade.
view.clear();
assert.equal(view.preview().net, 0, 'Clear (closed) cancels the saved trade');
assert.equal(view._confirmBtn.alpha, 0, '… Confirm hides again');
// Rebuild a sell, close again, and CONFIRM from the closed state.
view._onTabTap('trade'); // open again (toggle on)
assert.equal(view.tab, 'trade', 'pressing BUY/SELL re-opens the panel');
view.sellGrid.toggleBay(0);
view._onTabTap('trade'); // close again (toggle off)
const creditsBefore = ship.credits;
assert.equal(view.confirm(), true, 'Confirm works with the panel closed');
assert.ok(ship.credits > creditsBefore, '… and the sale landed (credits up)');
assert.equal(view.preview().net, 0, '… the trade is spent');
assert.equal(view.isBuybackEnabled(), true, '… and the sale registered for Buyback');
// Re-open: a FRESH selection (the confirmed trade is gone, not restored).
view._onTabTap('trade');
assert.equal(view.tab, 'trade', '… the panel re-opens');
assert.deepEqual(view.sellGrid.getSellMap(), {}, '… with no carry-over sell selection');
assert.deepEqual(view.buyList.getBuyMap(), {}, '… and no carry-over buy selection');
}
console.log('TOGGLE: OK');
// ===========================================================================
console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the report ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
// The scout exists (over the cargo grid) and starts closed.
assert.ok(view._scout, 'the scout panel exists with the trade content');
assert.equal(view._scoutOpen, null, 'starts closed (cargo grid up)');
assert.equal(view._scout.alpha, 0, '… hidden (alpha 0)');
assert.equal(view.sellGrid.alpha, 1, '… and the cargo grid is visible');
const row = view.buyList.rows[0];
// TAP THE ROW'S BOX → the scout opens on that good.
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, 'the tapped row is scouted');
assert.equal(view._scout.alpha, 1, 'the report is visible (the stub tween lands on alpha 1)');
assert.equal(view.sellGrid.alpha, 0, '… the cargo grid steps aside (alpha 0)');
assert.equal(view.buyList._focusId, row.id, '… the row keeps its focus stroke');
assert.equal(view._scout.report.id, row.id, '… and it shows THAT good');
// The report: identity + baseline + charted exits + profit math.
const d = view._scout.report;
assert.equal(d.localBuy, row.price, 'the profit baseline is this shop\'s buy price for the row');
assert.ok(Array.isArray(d.demand) && d.demand.every((c) => c.label), 'demand ("sought on") classes carry labels');
assert.ok(Array.isArray(d.producer) && d.producer.every((c) => c.label), 'producer ("grown on") classes carry labels');
assert.ok(d.exits.length <= 3, 'top-N (≤ 3) charted exits');
assert.ok(d.exits.every((e) => e.profit === e.price - d.localBuy), 'profit = exit price − local buy');
assert.ok(d.exits.every((e) => e.systemName && e.clsLabel), 'exits carry system + class labels');
assert.ok(d.exits.every((e, i) => i === 0 || d.exits[i - 1].price >= e.price), 'exits ranked high → low');
assert.ok(d.exits.some((e) => e.systemId === sysId), 'the current (charted) system is a candidate exit');
// TAP THE SAME BOX AGAIN → closes, cargo grid restored.
view._onRowTap(row);
assert.equal(view._scoutOpen, null, 'same box again → the scout closes');
assert.equal(view._scout.alpha, 0, '… the report hides');
assert.equal(view.sellGrid.alpha, 1, '… the cargo grid is back');
assert.equal(view.buyList._focusId, null, '… and the row\'s focus stroke is cleared');
// TAP A DIFFERENT ROW → the report swaps (still open).
const other = view.buyList.rows[1] ?? view.buyList.rows[0];
view._onRowTap(other);
assert.equal(view._scoutOpen, other.id, 'a different box swaps the report');
assert.equal(view._scout.report.id, other.id, '… to that good');
assert.equal(view.sellGrid.alpha, 0, '… the cargo grid stays aside');
// TAP THE LIST'S DEAD SPACE (null) → closes too ("anywhere else").
view._onRowTap(null);
assert.equal(view._scoutOpen, null, 'a dead-space tap closes the scout');
assert.equal(view.sellGrid.alpha, 1, '… cargo grid restored');
// The scout's OWN tap (its tap layer → onClose) closes it too.
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, '… reopened for the tap-self test');
view._scout.onClose();
assert.equal(view._scoutOpen, null, 'tapping the scout itself closes it');
assert.equal(view.sellGrid.alpha, 1, '… cargo grid restored');
}
console.log('TRADE SCOUT: OK');
console.log('\nAll shop-UI checks passed \u2714');