1149 lines
63 KiB
JavaScript
1149 lines
63 KiB
JavaScript
/**
|
||
* Shop UI test (dev tool, run with Node — no browser needed):
|
||
*
|
||
* node 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';
|
||
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
|
||
|
||
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 { sellsHere } = await import(pathToFileURL(join(__dirname, '../js/economy/TradeScout.js')).href);
|
||
const Stock = await import(pathToFileURL(join(__dirname, '../js/economy/Stock.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; // the world's ONE good (model v2: its signature, batch-limited)
|
||
|
||
// HUB fixtures — model v2: a plain world sells exactly ONE good (its
|
||
// signature), so the multi-row tests (SCROLL, the scout's row-swap) run on
|
||
// a station, whose floor is the hub-pool selection + its own signature.
|
||
const hubSys = [...galaxy.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
|
||
const hubCls = 'station';
|
||
const hubStock = E.shopFor(hubCls, hubSys, galaxy).buy;
|
||
assert.ok(hubStock.length >= 8, `the hub floor carries the hub-pool selection (${hubStock.length} rows, ≥ 8)`);
|
||
|
||
function makeView(ship, { reveal = true, topY = 120, tradeUnlocked = true, worldName = 'Somewhere', homeWorld = null, soldSpy = null, sysId: SYS = sysId, cls: CLS = cls } = {}) {
|
||
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: CLS, systemId: SYS, 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 the info inline. The
|
||
// TITLE LINE is name · inventory · price · stepper (one line, with
|
||
// padding between each); line 2 is the description.
|
||
const row0 = view.buyList.rows[0];
|
||
const nameText = row0.objs.name.text;
|
||
assert.ok(nameText === nameB || (nameText.endsWith('…') && nameText.startsWith(nameB.slice(0, 3))),
|
||
`row carries the good name on the title line (whole, or truncated to the title-line budget) — got "${nameText}" for "${nameB}"`);
|
||
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price on the title line');
|
||
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)');
|
||
|
||
// TITLE-LINE PADDING (the layout ask): name ─ gap ─ inventory ─ gap ─
|
||
// price, all on the product-title line. stockGap pads both gaps; the
|
||
// name's budget (_nameMaxW) stops short of the inventory strip.
|
||
const bx = view.buyList._btnXs();
|
||
const nameW = view.buyList._nameMaxW(row0);
|
||
assert.ok(view.buyList._textX() + nameW + view.buyList.stockGap <= bx.left,
|
||
'padding between the product title and the inventory level');
|
||
assert.ok(bx.left + view.buyList.stockGap <= bx.price,
|
||
'padding between the inventory level and the price');
|
||
|
||
// DESCRIPTION (line 2 — its own line; the right block is on the title
|
||
// line above): smaller font, wraps to up to 2 lines, full row width.
|
||
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 desc line 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})`);
|
||
assert.ok(view.buyList._textX() + descMaxW <= view.buyList.vpW / 2 - view.buyList.pad + 0.001,
|
||
'desc column fits the row (line 2 is its own line, the right block is above)');
|
||
|
||
// 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== PROFIT XP — a profitable sale pays 1 XP per 1 credit above cost ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
const sellA = E.sellPrice(A, cls, sysId, galaxy);
|
||
assert.ok(sellA > 5, 'the sell price clears the cost (a profitable sale)');
|
||
const COST = 5; // the credits/unit the player PAID for these two bays
|
||
ship.cargo.add([A, A], COST);
|
||
const grants = [];
|
||
const { view, gs } = makeView(ship);
|
||
// The scene's XP pipe (GameScene.combat.xp) — the SAME pipe as kills /
|
||
// sidequests; a spy records every grant + its source tag.
|
||
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); return { gained: n, level: 1, levelsGained: 0, leveledUp: false }; } } };
|
||
|
||
view.sellGrid.toggleBay(0); // bay 0 holds A
|
||
view.sellGrid.toggleBay(1); // bay 1 holds A
|
||
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 2 }, 'both A bays are selected');
|
||
const pv = view.preview();
|
||
assert.equal(pv.sellTotal, sellA * 2, 'sellTotal = 2 × the A sale');
|
||
assert.equal(pv.valid, true, '… and it is legal');
|
||
|
||
const before = ship.credits;
|
||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||
assert.equal(ship.credits, before + sellA * 2, 'credits rise by the sale');
|
||
assert.deepEqual(ship.cargo.snapshot(), [], '… and the hold is empty');
|
||
|
||
// THE PROFIT XP — 1 XP per 1 credit above the purchase cost, per bay:
|
||
// (sellA − COST) × 2, paid through the scene's XP pipe (source 'trade').
|
||
const expectedXp = (sellA - COST) * 2;
|
||
assert.ok(expectedXp > 0, '… a positive profit (the sale beats the cost)');
|
||
assert.deepEqual(grants, [[expectedXp, 'trade']], `profit XP ${expectedXp} granted ONCE, tagged 'trade'`);
|
||
// The CONFIRMED line tells the player the XP the payout earned (the
|
||
// credit delta is unchanged; the XP is appended, not a separate grant).
|
||
assert.ok(view._lastStatus, 'the status line is recorded');
|
||
assert.ok(view._lastStatus.includes(`+${expectedXp} XP`), `the CONFIRMED line shows the +${expectedXp} XP payout`);
|
||
assert.ok(view._lastStatus.includes(`+${sellA * 2} cr`), '… and still shows the credit delta');
|
||
}
|
||
|
||
// …and a sale with NO profit basis (no cost on file; or at/below cost)
|
||
// grants NOTHING — the pipe is never called (no 0-grant, no negative XP).
|
||
{
|
||
// (a) a bay with no cost on file (a handoff) — no basis, no profit.
|
||
{
|
||
const ship = makeShip(10000);
|
||
ship.cargo.add([A, A]); // NO unitCost → null cost on file
|
||
const grants = [];
|
||
const { view, gs } = makeView(ship);
|
||
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); } } };
|
||
view.sellGrid.toggleBay(0);
|
||
view.sellGrid.toggleBay(1);
|
||
assert.equal(view.confirm(), true, 'Confirm goes through (the sale itself stands)');
|
||
assert.deepEqual(grants, [], 'no cost on file → no profit basis → NO XP grant');
|
||
assert.ok(!view._lastStatus.includes('XP'), '… and the CONFIRMED line shows no XP payout');
|
||
}
|
||
// (b) a sale AT/BELOW cost — no margin, no XP.
|
||
{
|
||
const ship = makeShip(10000);
|
||
const sellA = E.sellPrice(A, cls, sysId, galaxy);
|
||
ship.cargo.add([A, A], sellA); // bought at exactly the sell price → 0 margin
|
||
const grants = [];
|
||
const { view, gs } = makeView(ship);
|
||
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); } } };
|
||
view.sellGrid.toggleBay(0);
|
||
view.sellGrid.toggleBay(1);
|
||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||
assert.deepEqual(grants, [], 'a zero-margin sale earns NO XP');
|
||
assert.ok(!view._lastStatus.includes('XP'), '… and the CONFIRMED line shows no XP payout');
|
||
}
|
||
}
|
||
console.log('PROFIT XP: profitable sale pays 1 XP/credit above cost; no-basis / no-margin sells earn none: 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');
|
||
// THE COST RECORD — the bays now carry the credits/unit actually paid
|
||
// (the shop's own price): the ship console's bay manifest compares its
|
||
// exit sell prices against this number, not a guess.
|
||
assert.deepEqual(ship.cargo.costs, [buyC, buyC], '… each bought bay records the shop price it was bought at');
|
||
assert.equal(ship.cargo.costAt(0), buyC, 'costAt() reads the bay\u2019s recorded cost');
|
||
assert.equal(ship.cargo.totalCost(), buyC * 2, 'totalCost() = the credits actually spent');
|
||
assert.equal(gs.refreshed, 1, '… and the HUD refreshes');
|
||
}
|
||
console.log('BUY: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== BATCH — the origin\u2019s daily batch: "N left", the clamp, SOLD OUT ==');
|
||
{
|
||
const batch = Stock.dailyBatch(); // the daily batch (data/economy.json)
|
||
const ship = makeShip(100000);
|
||
ship.cargo.capacity = batch; // room for the full batch (the default 4 bays are the starting ship)
|
||
// A REAL stock seam: the scene owns economyStock + spendStock (the
|
||
// reference-swap the ShopView reads live on every query).
|
||
const gs = {
|
||
refreshed: 0,
|
||
refreshCreditsHud() { this.refreshed += 1; },
|
||
isTradeUnlocked: () => true,
|
||
economyStock: {}, // a fresh day → every batch starts full
|
||
spendStock(sys, qty) {
|
||
const day = Stock.economyDay();
|
||
const cur = this.economyStock[sys] ?? { day, left: batch };
|
||
const left = Math.max(0, (cur.day === day ? cur.left : batch) - qty);
|
||
this.economyStock = { ...this.economyStock, [sys]: { day, left } }; // NEW reference (the seam)
|
||
return left;
|
||
},
|
||
};
|
||
const scene = makeScene();
|
||
const view = new ShopView(scene, { ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {}, topY: 120, worldName: 'Somewhere' });
|
||
view.setTab('trade');
|
||
|
||
const row = view.buyList.rows.find((r) => r.id === C);
|
||
assert.ok(row, 'the world\u2019s signature row is on the floor');
|
||
assert.equal(row.left, batch, `… showing its FULL daily batch ("${batch} left")`);
|
||
|
||
// The stepper allows EXACTLY the batch, no more.
|
||
view.buyList.bumpBuy(C, batch);
|
||
assert.deepEqual(view.buyList.getBuyMap(), { [C]: batch }, 'stepping to the full batch is allowed');
|
||
let pv = view.preview();
|
||
const unit = E.buyPrice(C, cls, sysId, galaxy);
|
||
assert.equal(pv.buyTotal, unit * batch, '… all batch units at the origin price');
|
||
assert.equal(pv.valid, true, '… affordable and room for the batch');
|
||
view.buyList.bumpBuy(C, 1);
|
||
assert.equal(view.buyList.getBuyMap()[C], batch, '… the stepper refuses to exceed the batch');
|
||
|
||
// Confirm drains the batch (the save state) + the row goes SOLD OUT.
|
||
const before = ship.credits;
|
||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||
assert.equal(ship.credits, before - unit * batch, '… credits fall by the full-batch spend');
|
||
assert.equal(gs.economyStock[sysId].left, 0, '… and the origin\u2019s batch is now 0 (the saved state)');
|
||
const row2 = view.buyList.rows.find((r) => r.id === C);
|
||
assert.equal(row2.left, 0, '… the re-rendered row drops to 0 left');
|
||
assert.equal(String(row2.objs.left.text), 'SOLD OUT', '… and it reads SOLD OUT');
|
||
|
||
// A fresh selection is blocked at BOTH seams: the stepper clamps at 0,
|
||
// and even a forced selection fails the engine's stock-cap backstop.
|
||
view.buyList.bumpBuy(C, 1);
|
||
assert.deepEqual(view.buyList.getBuyMap(), {}, '… the stepper can\u2019t select a sold-out batch');
|
||
view.buyList.buyQty.set(C, 1); // force a selection past the clamp (backstop check)
|
||
pv = view.preview();
|
||
assert.equal(pv.valid, false, '… and the engine refuses it (insufficient-stock)');
|
||
assert.ok(pv.errors.includes('insufficient-stock'), '… flagged as insufficient-stock');
|
||
}
|
||
console.log('BATCH: OK');
|
||
|
||
// ===========================================================================
|
||
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
|
||
{
|
||
const ship = makeShip(10000);
|
||
// A HUB (model v2 — a plain world's floor is exactly ONE row, no scroll).
|
||
const { view } = makeView(ship, { sysId: hubSys, cls: hubCls });
|
||
const list = view.buyList;
|
||
assert.ok(list.rows.length >= 8, `the hub 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');
|
||
// Hub-pool rows are UNLIMITED (left === null) — the stepper has no batch cap.
|
||
assert.equal(list.rows[0].left, null, 'hub rows carry no "N left" (unlimited resale)');
|
||
}
|
||
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]);
|
||
// A HUB — the scout's row-swap test needs ≥ 2 stock rows (a plain world
|
||
// sells exactly one good, model v2).
|
||
const { view } = makeView(ship, { sysId: hubSys, cls: hubCls });
|
||
|
||
// 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');
|
||
|
||
// A stock row THIS system wants — so the current (charted) system is a
|
||
// valid exit in the report (any stock row would do for the UI
|
||
// assertions; rows[0] is not guaranteed to be bought locally).
|
||
const row =
|
||
view.buyList.rows.find((r) => classesOf(hubSys).some((c) => sellsHere(r.id, c))) ??
|
||
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 === hubSys), '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, { sysId: hubSys, cls: hubCls });
|
||
const calls = [];
|
||
scene.askSetDestination = (exit) => { calls.push(exit); };
|
||
|
||
// A row whose report has exits to tap (one the current system wants —
|
||
// rows[0] is not guaranteed to be bought locally).
|
||
const row =
|
||
view.buyList.rows.find((r) => classesOf(hubSys).some((c) => sellsHere(r.id, c))) ??
|
||
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');
|