diff --git a/tests/utils.test.cjs b/tests/utils.test.cjs new file mode 100644 index 0000000..295d0e6 --- /dev/null +++ b/tests/utils.test.cjs @@ -0,0 +1,414 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const ROOT = path.resolve(__dirname, '..'); + +// --- Minimal DOM mock for browser-dependent code --- +class Element { + constructor(tagName = 'div') { + this.tagName = String(tagName).toUpperCase(); + this.id = ''; + this.style = {}; + this.children = []; + this.parentNode = null; + this.innerHTML = ''; + this.textContent = ''; + this.className = ''; + this.classList = { + add: (...n) => { const s = new Set(this.className.split(/\s+/).filter(Boolean)); n.forEach(x => s.add(x)); this.className = [...s].join(' '); }, + remove: (...n) => { const r = new Set(n); this.className = this.className.split(/\s+/).filter(x => !r.has(x)).join(' '); }, + contains: (n) => this.className.split(/\s+/).includes(n), + toggle: (n, force) => { if (force === undefined) force = !this.classList.contains(n); if (force) this.classList.add(n); else this.classList.remove(n); } + }; + this.attributes = {}; + } + appendChild(c) { c.parentNode = this; this.children.push(c); return c; } + removeChild(c) { this.children = this.children.filter(x => x !== c); if (c.parentNode === this) c.parentNode = null; return c; } + remove() { if (this.parentNode) this.parentNode.removeChild(this); } + setAttribute(n, v) { this.attributes[n] = v; } + querySelector() { return null; } + querySelectorAll() { return []; } + closest(sel) { return null; } +} + +function createDocument() { + const head = new Element('head'); + const body = new Element('body'); + return { + createElement: (tag) => new Element(tag), + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + head, body, + addEventListener: () => {}, + hidden: false + }; +} + +// Load utils.js in a VM sandbox +function loadUtils() { + const code = fs.readFileSync(path.join(ROOT, 'js', 'utils.js'), 'utf8'); + const sandbox = { + console, + Math, Number, String, Object, Array, Set, Map, parseInt, isNaN, Infinity, + document: createDocument(), + window: { addEventListener: () => {} }, + G: { + isLoading: false, + buyAmount: 1, + phase: 1, + codeBoost: 1, + buildings: { autocoder: 0 }, + code: 0, compute: 0, knowledge: 0, trust: 0, ops: 0, + totalCode: 0, totalCompute: 0, totalKnowledge: 0, totalUsers: 0, totalImpact: 0, + deployFlag: 0, sovereignFlag: 0, pactFlag: 0 + }, + BDEF: [ + { id: 'autocoder', name: 'Auto-Code Generator', baseCost: { code: 15 }, costMult: 1.15, rates: { code: 1 }, unlock: () => true, phase: 1 }, + { id: 'server', name: 'Home Server', baseCost: { code: 750 }, costMult: 1.15, rates: { code: 20, compute: 1 }, unlock: () => true, phase: 1 }, + { id: 'evaluator', name: 'Eval Harness', baseCost: { knowledge: 3000, trust: 500 }, costMult: 1.15, rates: { trust: 1, ops: 1 }, unlock: () => true, phase: 2 } + ] + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox; +} + +// ============================================= +// fmt() tests +// ============================================= +test('fmt: returns 0 for null/undefined/NaN', () => { + const { fmt } = loadUtils(); + assert.equal(fmt(null), '0'); + assert.equal(fmt(undefined), '0'); + assert.equal(fmt(NaN), '0'); +}); + +test('fmt: returns infinity symbols', () => { + const { fmt } = loadUtils(); + assert.equal(fmt(Infinity), '∞'); + assert.equal(fmt(-Infinity), '-∞'); +}); + +test('fmt: formats negative numbers with minus prefix', () => { + const { fmt } = loadUtils(); + assert.equal(fmt(-500), '-500'); + assert.equal(fmt(-1500), '-1.5K'); +}); + +test('fmt: formats small numbers with locale string', () => { + const { fmt } = loadUtils(); + assert.equal(fmt(0), '0'); + assert.equal(fmt(42), '42'); + assert.equal(fmt(999), '999'); + assert.equal(fmt(12.7), '12'); // floors +}); + +test('fmt: abbreviates thousands through decillions', () => { + const { fmt } = loadUtils(); + assert.equal(fmt(1000), '1.0K'); + assert.equal(fmt(1500), '1.5K'); + assert.equal(fmt(1000000), '1.0M'); + assert.equal(fmt(2500000), '2.5M'); + assert.equal(fmt(1000000000), '1.0B'); + assert.equal(fmt(1000000000000), '1.0T'); + assert.equal(fmt(1e15), '1.0Qa'); + assert.equal(fmt(1e18), '1.0Qi'); + assert.equal(fmt(1e21), '1.0Sx'); + assert.equal(fmt(1e24), '1.0Sp'); + assert.equal(fmt(1e27), '1.0Oc'); + assert.equal(fmt(1e30), '1.0No'); + assert.equal(fmt(1e33), '1.0Dc'); +}); + +test('fmt: switches to spellf at undecillion (scale >= 12)', () => { + const { fmt } = loadUtils(); + const result = fmt(1e36); // undecillion + assert.ok(result.includes('undecillion'), `Expected undecillion in "${result}"`); +}); + +test('fmt: handles mid-scale numbers correctly', () => { + const { fmt } = loadUtils(); + assert.equal(fmt(42000), '42.0K'); + assert.equal(fmt(999999), '1000.0K'); // just under 1M boundary + assert.equal(fmt(1234567890), '1.2B'); +}); + +// ============================================= +// spellf() tests +// ============================================= +test('spellf: handles edge cases', () => { + const { spellf } = loadUtils(); + assert.equal(spellf(null), 'zero'); + assert.equal(spellf(undefined), 'zero'); + assert.equal(spellf(NaN), 'zero'); + assert.equal(spellf(Infinity), 'infinity'); + assert.equal(spellf(-Infinity), 'negative infinity'); +}); + +test('spellf: spells small numbers', () => { + const { spellf } = loadUtils(); + assert.equal(spellf(0), 'zero'); + assert.equal(spellf(1), 'one'); + assert.equal(spellf(5), 'five'); + assert.equal(spellf(10), 'ten'); + assert.equal(spellf(13), 'thirteen'); + assert.equal(spellf(20), 'twenty'); + assert.equal(spellf(42), 'forty two'); + assert.equal(spellf(100), 'one hundred'); + assert.equal(spellf(999), 'nine hundred ninety nine'); +}); + +test('spellf: spells thousands', () => { + const { spellf } = loadUtils(); + assert.equal(spellf(1000), 'one thousand'); + assert.equal(spellf(1500), 'one thousand five hundred'); + assert.equal(spellf(10000), 'ten thousand'); + assert.equal(spellf(100000), 'one hundred thousand'); +}); + +test('spellf: spells millions and beyond', () => { + const { spellf } = loadUtils(); + assert.equal(spellf(1000000), 'one million'); + assert.equal(spellf(2500000), 'two million five hundred thousand'); + assert.equal(spellf(1000000000), 'one billion'); + assert.equal(spellf(1e12), 'one trillion'); +}); + +test('spellf: handles negative numbers', () => { + const { spellf } = loadUtils(); + assert.equal(spellf(-42), 'negative forty two'); + assert.equal(spellf(-1000), 'negative one thousand'); +}); + +test('spellf: spells large scales by name', () => { + const { spellf } = loadUtils(); + assert.equal(spellf(1e33), 'one decillion'); + assert.equal(spellf(1e36), 'one undecillion'); + assert.equal(spellf(1e63), 'one vigintillion'); +}); + +// ============================================= +// getScaleName() tests +// ============================================= +test('getScaleName: returns empty for small numbers', () => { + const { getScaleName } = loadUtils(); + assert.equal(getScaleName(0), ''); + assert.equal(getScaleName(999), ''); +}); + +test('getScaleName: returns correct scale names', () => { + const { getScaleName } = loadUtils(); + assert.equal(getScaleName(1000), 'thousand'); + assert.equal(getScaleName(1000000), 'million'); + assert.equal(getScaleName(1000000000), 'billion'); + assert.equal(getScaleName(1e12), 'trillion'); + assert.equal(getScaleName(1e15), 'quadrillion'); +}); + +// ============================================= +// getBuildingCost() tests +// ============================================= +test('getBuildingCost: returns empty for unknown building', () => { + const s = loadUtils(); + const cost = s.getBuildingCost('nonexistent'); + assert.equal(Object.keys(cost).length, 0); +}); + +test('getBuildingCost: first purchase is base cost', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 0; + const cost = s.getBuildingCost('autocoder'); + assert.equal(cost.code, 15); +}); + +test('getBuildingCost: scales with count using costMult', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 5; + const cost = s.getBuildingCost('autocoder'); + // 15 * 1.15^5 = 15 * 2.011357... = 30.17... → floor = 30 + assert.equal(cost.code, Math.floor(15 * Math.pow(1.15, 5))); +}); + +test('getBuildingCost: handles multi-resource costs', () => { + const s = loadUtils(); + s.G.buildings.evaluator = 0; + const cost = s.getBuildingCost('evaluator'); + assert.equal(cost.knowledge, 3000); + assert.equal(cost.trust, 500); +}); + +// ============================================= +// canAffordBuilding() / spendBuilding() tests +// ============================================= +test('canAffordBuilding: true when resources sufficient', () => { + const s = loadUtils(); + s.G.code = 100; + assert.equal(s.canAffordBuilding('autocoder'), true); +}); + +test('canAffordBuilding: false when insufficient', () => { + const s = loadUtils(); + s.G.code = 5; + assert.equal(s.canAffordBuilding('autocoder'), false); +}); + +test('canAffordBuilding: checks all required resources', () => { + const s = loadUtils(); + s.G.knowledge = 5000; + s.G.trust = 0; // evaluator needs 500 trust + assert.equal(s.canAffordBuilding('evaluator'), false); +}); + +test('spendBuilding: deducts correct amount', () => { + const s = loadUtils(); + s.G.code = 100; + s.G.buildings.autocoder = 0; + s.spendBuilding('autocoder'); + assert.equal(s.G.code, 100 - 15); +}); + +// ============================================= +// canAffordProject() / spendProject() tests +// ============================================= +test('canAffordProject: checks project cost', () => { + const s = loadUtils(); + s.G.trust = 50; + assert.equal(s.canAffordProject({ cost: { trust: 10 } }), true); + assert.equal(s.canAffordProject({ cost: { trust: 100 } }), false); +}); + +test('spendProject: deducts project cost', () => { + const s = loadUtils(); + s.G.trust = 50; + s.G.code = 1000; + s.spendProject({ cost: { trust: 10, code: 500 } }); + assert.equal(s.G.trust, 40); + assert.equal(s.G.code, 500); +}); + +// ============================================= +// getMaxBuyable() tests +// ============================================= +test('getMaxBuyable: returns 0 when cannot afford any', () => { + const s = loadUtils(); + s.G.code = 5; + assert.equal(s.getMaxBuyable('autocoder'), 0); +}); + +test('getMaxBuyable: returns correct count for affordable range', () => { + const s = loadUtils(); + s.G.code = 10000; + s.G.buildings.autocoder = 0; + const max = s.getMaxBuyable('autocoder'); + assert.ok(max > 0, 'Should be able to buy at least one'); + + // Verify the cost matches getBulkCost + const bulkCost = s.getBuildingCost ? null : null; // just check the count is positive +}); + +test('getMaxBuyable: returns 0 for unknown building', () => { + const s = loadUtils(); + assert.equal(s.getMaxBuyable('nonexistent'), 0); +}); + +// ============================================= +// getBulkCost() tests +// ============================================= +test('getBulkCost: returns empty for zero qty', () => { + const s = loadUtils(); + const cost = s.getBulkCost('autocoder', 0); + assert.equal(Object.keys(cost).length, 0); +}); + +test('getBulkCost: returns empty for unknown building', () => { + const s = loadUtils(); + const cost = s.getBulkCost('nonexistent', 3); + assert.equal(Object.keys(cost).length, 0); +}); + +test('getBulkCost: single purchase equals getBuildingCost', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 0; + const single = s.getBuildingCost('autocoder'); + const bulk1 = s.getBulkCost('autocoder', 1); + assert.deepEqual(bulk1, single); +}); + +test('getBulkCost: cumulative cost for multiple purchases', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 0; + const bulk3 = s.getBulkCost('autocoder', 3); + // Manual: cost[0] = 15, cost[1] = floor(15*1.15) = 17, cost[2] = floor(15*1.15^2) = 19 + const c0 = Math.floor(15 * Math.pow(1.15, 0)); + const c1 = Math.floor(15 * Math.pow(1.15, 1)); + const c2 = Math.floor(15 * Math.pow(1.15, 2)); + assert.equal(bulk3.code, c0 + c1 + c2); +}); + +// ============================================= +// getClickPower() tests +// ============================================= +test('getClickPower: base is 1 with no buildings', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 0; + s.G.phase = 1; + s.G.codeBoost = 1; + assert.equal(s.getClickPower(), 1); +}); + +test('getClickPower: autocoder adds 0.5 per level (floored)', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 4; + s.G.phase = 1; + s.G.codeBoost = 1; + // (1 + floor(4 * 0.5) + 0) * 1 = (1 + 2 + 0) * 1 = 3 + assert.equal(s.getClickPower(), 3); +}); + +test('getClickPower: phase adds 2 per level above 1', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 0; + s.G.phase = 3; + s.G.codeBoost = 1; + // (1 + 0 + (3-1)*2) * 1 = 5 + assert.equal(s.getClickPower(), 5); +}); + +test('getClickPower: codeBoost multiplies result', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 0; + s.G.phase = 1; + s.G.codeBoost = 2.5; + // (1 + 0 + 0) * 2.5 = 2.5 + assert.equal(s.getClickPower(), 2.5); +}); + +test('getClickPower: combined calculation', () => { + const s = loadUtils(); + s.G.buildings.autocoder = 10; + s.G.phase = 4; + s.G.codeBoost = 1.5; + // (1 + floor(10*0.5) + (4-1)*2) * 1.5 = (1 + 5 + 6) * 1.5 = 18 + assert.equal(s.getClickPower(), 18); +}); + +// ============================================= +// showToast() tests +// ============================================= +test('showToast: no-op when isLoading', () => { + const s = loadUtils(); + s.G.isLoading = true; + // Should not throw even without DOM + s.showToast('test', 'info'); +}); + +test('showToast: no-op when no container', () => { + const s = loadUtils(); + s.G.isLoading = false; + // document.getElementById returns null in our mock + s.showToast('test', 'info'); +});