/** * 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. * - QUEST CARGO — the 'Special Delivery' package (tradeable:false) is NOT * FOR SALE anywhere except the home world, where it sells for ZERO * credits and the sale itself stands the delivery (the seam fires with * the world's name; the parcel is not buyback-eligible). * * 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, tradeUnlocked = true, worldName = 'Somewhere', homeWorld = null, soldSpy = null } = {}) { const scene = makeScene(); // gameScene carries the HUD refresh seam + the TRADE gate // (GameScene.isTradeUnlocked — the 'Special Delivery' capital landing). const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; }, isTradeUnlocked: () => tradeUnlocked }; if (homeWorld !== null) { // The delivery gate + seam (GameScene.isHomeWorld / onQuestPackageSold // — the 'Special Delivery' step 3: the parcel's home-world sale). gs.isHomeWorld = (n) => String(n ?? '') === homeWorld; gs.onQuestPackageSold = (n) => { soldSpy?.push(n); return true; }; } 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, // worldName = this WORLD (SurfaceScene.planetName — the delivery gate // runs the home check against it; the default is no home, ever). worldName, }); 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== TRADE GATE — Buy/Sell stays grayed + dead until GameScene.isTradeUnlocked =='); { // The gate CLOSED (pre-'Special Delivery' capital landing): the shop screen // itself opens (the deck's Shop button is always live), but the Buy/Sell // tab is grayed and refuses to reveal the trade panel. const ship = makeShip(10000); ship.cargo.add([A]); const { view } = makeView(ship, { reveal: false, tradeUnlocked: false }); assert.equal(view._tabTrade.disabled, true, 'gate closed: Buy/Sell tab is grayed'); assert.equal(view._tabBuyback.disabled, true, 'buyback stays grayed (no sale)'); view.setTab('trade'); assert.equal(view.tab, null, 'setTab(trade) is refused while the gate is closed'); assert.equal(view.sellGrid, null, 'no SELL grid while the gate is closed'); assert.equal(view.buyList, null, 'no BUY list while the gate is closed'); view._onTabTap('trade'); assert.equal(view.tab, null, 'the tab tap is swallowed while the gate is closed'); // The gate LIFTED (the capital-landing witness lands): the same view's tab // re-arms on the next refresh and the panel reveals. view.gameScene = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; }, isTradeUnlocked: () => true }; view._refreshTabs(); assert.equal(view._tabTrade.disabled, false, 'gate lifted: Buy/Sell tab re-arms'); view.setTab('trade'); assert.equal(view.tab, 'trade', 'gate lifted: Buy/Sell reveals the panel'); assert.ok(view.sellGrid && view.buyList, 'both panels exist after the gate lifts'); } console.log('TRADE GATE: 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== SELECT VISUAL — checked bay: green stroke + tint + corner check; uncheck reverts =='); { const ship = makeShip(10000); ship.cargo.add([A]); const { view } = makeView(ship); const grid = view.sellGrid; const cell = grid.cells[0]; const slotCfg = config.get('shopui.slot', {}); const hexInt = (h) => { const m = String(h).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : null; }; const GREEN = hexInt(slotCfg.selected ?? '#7ce8a4'); const BG = hexInt(slotCfg.bgColor ?? '#0e1728'); const BORDER = hexInt(slotCfg.border ?? '#1d2e4c'); const SELBG = hexInt(slotCfg.selBgColor ?? BG); assert.ok(GREEN != null && BG != null && SELBG != null, 'the slot config carries the selection colors'); assert.notEqual(SELBG, BG, 'the selected fill is a distinct (tinted) shade'); // Record the draw calls on the cell's plate + check graphics. const wrap = (o) => { const calls = { fill: [], line: [], between: [] }; if (!o) return calls; if (typeof o.fillStyle === 'function') o.fillStyle = (...a) => { calls.fill.push(a); return o; }; if (typeof o.lineStyle === 'function') o.lineStyle = (...a) => { calls.line.push(a); return o; }; if (typeof o.lineBetween === 'function') o.lineBetween = (...a) => { calls.between.push(a); return o; }; return calls; }; // CHECK the bay → GREEN stroke + tinted fill on the plate, and the // corner check (green strokes in the upper-left) on the cell's sel graphics. const plate = wrap(cell.plate); const sel = wrap(cell.sel); grid.toggleBay(0); assert.deepEqual(grid.getSellMap(), { [A]: 1 }, 'bay 0 is checked (1 unit queued)'); assert.ok(plate.line.length >= 1, 'the plate was re-styled on the check'); assert.equal(plate.line[0][1], GREEN, `checked bay strokes the SELECTION GREEN (got 0x${(plate.line[0]?.[1] ?? 0).toString(16)}, want 0x${GREEN.toString(16)})`); assert.ok(plate.line[0][0] >= 2, `… a heavier stroke than the idle border (width ${plate.line[0][0]})`); assert.equal(plate.fill[0][0], SELBG, `… and the fill is the selected tint (got 0x${(plate.fill[0]?.[0] ?? 0).toString(16)}, want 0x${SELBG.toString(16)})`); assert.ok(sel.between.length >= 2, `the corner check is drawn (≥ 2 strokes, got ${sel.between.length})`); assert.ok(sel.line.some((l) => l[1] === GREEN), `… in the selection green (line styles: ${JSON.stringify(sel.line)})`); // The check sits in the cell's UPPER-LEFT quadrant (up + left of centre). const { x, y } = grid._cellXY(0); for (const b of sel.between) { assert.ok(b[0] < x && b[1] < y && b[2] < x && b[3] < y, `check stroke (${b.map((v) => Math.round(v)).join(',')}) is up-left of the cell centre (${x},${y})`); } // UNCHECK the bay → the plate reverts to the idle border/fill and the // check is cleared (nothing new drawn on the sel graphics). const plate2 = wrap(cell.plate); const sel2 = wrap(cell.sel); grid.toggleBay(0); assert.deepEqual(grid.getSellMap(), {}, 'bay 0 is checked off again'); assert.equal(plate2.line[0][1], BORDER, `unchecked bay strokes the IDLE border (got 0x${(plate2.line[0]?.[1] ?? 0).toString(16)}, want 0x${BORDER.toString(16)})`); assert.equal(plate2.line[0][0], 1, `… the thin idle border again (width ${plate2.line[0][0]})`); assert.equal(plate2.fill[0][0], BG, `… and the plain idle fill (got 0x${(plate2.fill[0]?.[0] ?? 0).toString(16)}, want 0x${BG.toString(16)})`); assert.equal(sel2.between.length, 0, 'the corner check is gone (cleared, no new strokes)'); } console.log('SELECT VISUAL: 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== QUEST CARGO — the "Special Delivery" package: home-world-only, zero-credit sale =='); { const PKG = 'special-package'; assert.ok(goods.some((g) => g.id === PKG), 'the catalog carries the package'); const mkShip = (list) => { const s = makeShip(10000); s.cargo.add(list); return s; }; // 1) OFF the home world: the package's bay is NOT FOR SALE. { const ship = mkShip([A, PKG]); const spy = []; const { view } = makeView(ship, { worldName: 'Capital City', homeWorld: 'Home World', soldSpy: spy }); const pkgIdx = ship.cargo.items.indexOf(PKG); assert.ok(pkgIdx >= 0, 'the package is aboard'); view.sellGrid.toggleBay(pkgIdx); assert.equal(view.sellGrid.sellSel.size, 0, 'off-home: the package bay refuses the check (NOT FOR SALE)'); assert.deepEqual(view.sellGrid.getSellMap(), {}, '… nothing queued'); view.sellGrid.hoverCell(pkgIdx); assert.equal(view.sellGrid.tipPrice.text, 'NOT FOR SALE', 'off-home: the tooltip says so'); // Ordinary bays still sell (only the parcel is refused). view.sellGrid.toggleBay(0); assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 1 }, 'off-home: ordinary goods are still saleable'); assert.equal(view.preview().sell.length, 1, '… the preview carries only the ordinary sale'); assert.equal(spy.length, 0, '… and the delivery seam never fires'); } // 2) ON the home world: the package is saleable — at ZERO credits — and // the sale stands the delivery (the seam fires with the world's name). { const ship = mkShip([A, PKG]); const spy = []; const { view } = makeView(ship, { worldName: 'Home World', homeWorld: 'Home World', soldSpy: spy }); const pkgIdx = ship.cargo.items.indexOf(PKG); view.sellGrid.toggleBay(pkgIdx); assert.ok(view.sellGrid.sellSel.has(pkgIdx), 'home: the package bay IS saleable (the delivery)'); view.sellGrid.hoverCell(pkgIdx); assert.equal(view.sellGrid.tipPrice.text, 'sell for 0 cr', 'home: the tooltip prices the delivery at zero'); const pv = view.preview(); const pkgRow = pv.sell.find((s) => s.id === PKG); assert.ok(pkgRow, 'the preview carries the parcel sale'); assert.equal(pkgRow.unit, 0, '… at zero credits (the delivery, not a reward)'); assert.equal(pkgRow.total, 0, '… zero total'); assert.equal(pv.valid, true, '… and the trade is legal'); const before = ship.credits; assert.equal(view.confirm(), true, 'Confirm goes through'); assert.equal(ship.credits, before, '… the sale earns ZERO credits'); assert.ok(!ship.cargo.items.includes(PKG), '… the parcel leaves the hold (delivered)'); assert.equal(spy.length, 1, '… and the delivery seam fired exactly once'); assert.equal(spy[0], 'Home World', '… with the world the sale ran on'); // The delivery is not a trade: the parcel is NOT buyback-eligible. assert.ok(!view.soldThisSession.some((r) => r.id === PKG), '… and the parcel is not on the Buyback list (the delivery, not a trade)'); } // 3) The home check is name-based: the same package on a world the scene // does NOT call home stays NOT FOR SALE. { const ship = mkShip([PKG]); const spy = []; const { view } = makeView(ship, { worldName: 'Astra-6', homeWorld: 'Home World', soldSpy: spy }); view.sellGrid.toggleBay(0); assert.equal(view.sellGrid.sellSel.size, 0, 'non-home world: the parcel stays NOT FOR SALE'); assert.equal(view.preview().sell.length, 0, '… nothing queued, nothing priced'); assert.equal(spy.length, 0, '… no seam (the shop never offered the sale)'); } } console.log('QUEST CARGO: 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('\n== SET DESTINATION — tap a best-exit row on the scout: the scene is offered the exit =='); { const ship = makeShip(10000); ship.cargo.add([A]); const { view, scene } = makeView(ship); const calls = []; scene.askSetDestination = (exit) => { calls.push(exit); }; const row = view.buyList.rows[0]; view._onRowTap(row); assert.equal(view._scoutOpen, row.id, 'scout open on the row'); const d = view._scout.report; assert.ok(d.exits.length > 0, 'the report has charted exits to tap'); // One tappable record per exit row, in rank order. assert.equal(view._scout._exitRows.length, d.exits.length, 'one tappable record per exit row'); assert.ok(view._scout._exitRows.every((r, i) => r.exit.systemId === d.exits[i].systemId), '… rows keep the report\'s rank order'); // TAP ROW 1 → the scene is offered THAT exit, and the panel does NOT close. const best = d.exits[0]; let stopped = false; view._scout._exitRows[0].onTap({ stopPropagation() { stopped = true; } }); assert.equal(calls.length, 1, 'the scene was offered the exit'); assert.equal(calls[0].systemId, best.systemId, '… for the tapped (best) system'); assert.equal(stopped, true, '… propagation stopped (the panel tap-layer is not fired)'); assert.equal(view._scoutOpen, row.id, '… and the scout stays open (no close)'); // TAP ROW 2 (when there is one) → the scene is offered THAT row\'s exit. if (d.exits.length > 1) { view._scout._exitRows[1].onTap({}); assert.equal(calls.length, 2, 'second row tap → second offer'); assert.equal(calls[1].systemId, d.exits[1].systemId, '… for that row\'s system'); } // Re-showing the report rebuilds the rows (no stale tap surfaces). view._onRowTap(null); // close view._onRowTap(row); // reopen → show() runs again assert.equal(view._scout._exitRows.length, view._scout.report.exits.length, 'rows rebuilt for the fresh report'); } console.log('SET DESTINATION (panel → scene): OK'); console.log('\nAll shop-UI checks passed \u2714');