diff --git a/tests/utils.test.cjs b/tests/utils.test.cjs new file mode 100644 index 0000000..502946b --- /dev/null +++ b/tests/utils.test.cjs @@ -0,0 +1,440 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); + +// Minimal DOM/document mock for showToast and spawnParticles +class Element { + constructor(tagName = 'div') { + this.tagName = String(tagName).toUpperCase(); + this.id = ''; + this.style = {}; + this.children = []; + this.parentNode = null; + this.previousElementSibling = null; + this.innerHTML = ''; + this.textContent = ''; + this.className = ''; + this.dataset = {}; + this.attributes = {}; + this.classList = { + add: (...names) => { + const set = new Set(this.className.split(/\s+/).filter(Boolean)); + names.forEach((name) => set.add(name)); + this.className = Array.from(set).join(' '); + }, + remove: (...names) => { + const remove = new Set(names); + this.className = this.className.split(/\s+/).filter(n => !remove.has(n)).join(' '); + } + }; + } + appendChild(child) { + this.children.push(child); + child.parentNode = this; + } + removeChild(child) { + this.children = this.children.filter(c => c !== child); + } + remove() { + if (this.parentNode) this.parentNode.removeChild(this); + } + querySelector() { return null; } + setAttribute() {} + getAttribute() { return null; } +} + +// Create a sandbox with game globals +function createSandbox() { + const container = new Element(); + container.id = 'toast-container'; + + const document = { + createElement: (tag) => new Element(tag), + getElementById: (id) => id === 'toast-container' ? container : null, + body: new Element('body') + }; + + const sandbox = { + console, + Math, + Infinity, + NaN, + isNaN, + parseFloat, + parseInt, + document, + setTimeout: (fn, ms) => { /* no-op in test */ }, + G: { + buildings: {}, + code: 0, + compute: 0, + knowledge: 0, + users: 0, + impact: 0, + ops: 5, + trust: 5, + creativity: 0, + harmony: 50, + phase: 1, + buyAmount: 1, + codeBoost: 1, + isLoading: false, + tutorialDone: true + }, + BDEF: [ + { + id: 'autocoder', + baseCost: { code: 10 }, + costMult: 1.15, + prod: { code: 1 } + }, + { + id: 'server', + baseCost: { code: 50 }, + costMult: 1.14, + prod: { compute: 1 } + } + ], + render: () => {} + }; + + vm.createContext(sandbox); + + // Load utils.js into sandbox + const utilsCode = fs.readFileSync(path.join(ROOT, 'js', 'utils.js'), 'utf8'); + vm.runInContext(utilsCode, sandbox); + + return sandbox; +} + +// ===================== fmt() tests ===================== + +test('fmt: returns "0" for null, undefined, NaN', () => { + const s = createSandbox(); + assert.equal(s.fmt(null), '0'); + assert.equal(s.fmt(undefined), '0'); + assert.equal(s.fmt(NaN), '0'); +}); + +test('fmt: returns infinity symbols', () => { + const s = createSandbox(); + assert.equal(s.fmt(Infinity), '\u221E'); + assert.equal(s.fmt(-Infinity), '-\u221E'); +}); + +test('fmt: formats small numbers with locale string', () => { + const s = createSandbox(); + assert.equal(s.fmt(0), '0'); + assert.equal(s.fmt(42), '42'); + assert.equal(s.fmt(999), '999'); +}); + +test('fmt: formats negative numbers', () => { + const s = createSandbox(); + const result = s.fmt(-500); + assert.ok(result.startsWith('-'), 'negative should start with dash'); + assert.ok(result.includes('500'), 'should contain 500'); +}); + +test('fmt: abbreviates thousands with K', () => { + const s = createSandbox(); + const result = s.fmt(1000); + assert.ok(result.includes('K'), 'should contain K for thousands'); + assert.ok(result.startsWith('1.0'), 'should start with 1.0'); +}); + +test('fmt: abbreviates millions with M', () => { + const s = createSandbox(); + const result = s.fmt(1500000); + assert.ok(result.includes('M'), 'should contain M for millions'); +}); + +test('fmt: abbreviates billions with B', () => { + const s = createSandbox(); + const result = s.fmt(2500000000); + assert.ok(result.includes('B'), 'should contain B for billions'); +}); + +test('fmt: uses spellf for numbers >= 10^36 (undecillion)', () => { + const s = createSandbox(); + const result = s.fmt(1e36); + // Should be spelled out, not just abbreviated + assert.ok(!result.includes('UDc'), 'should use word form, not abbreviation'); + assert.ok(result.length > 3, 'should be a word, not abbreviation'); +}); + +test('fmt: falls back to exponential for numbers beyond scale table', () => { + const s = createSandbox(); + const result = s.fmt(1e306); + // Should be exponential notation since it's beyond centillion + assert.ok(result.length > 0, 'should produce a result'); +}); + +// ===================== spellf() tests ===================== + +test('spellf: returns "zero" for null, undefined, NaN', () => { + const s = createSandbox(); + assert.equal(s.spellf(null), 'zero'); + assert.equal(s.spellf(undefined), 'zero'); + assert.equal(s.spellf(NaN), 'zero'); +}); + +test('spellf: returns "zero" for 0', () => { + const s = createSandbox(); + assert.equal(s.spellf(0), 'zero'); +}); + +test('spellf: returns "infinity" for Infinity', () => { + const s = createSandbox(); + assert.equal(s.spellf(Infinity), 'infinity'); + assert.equal(s.spellf(-Infinity), 'negative infinity'); +}); + +test('spellf: spells small numbers', () => { + const s = createSandbox(); + assert.equal(s.spellf(1), 'one'); + assert.equal(s.spellf(5), 'five'); + assert.equal(s.spellf(13), 'thirteen'); + assert.equal(s.spellf(42), 'forty two'); + assert.equal(s.spellf(100), 'one hundred'); + assert.equal(s.spellf(999), 'nine hundred ninety nine'); +}); + +test('spellf: spells thousands', () => { + const s = createSandbox(); + const result = s.spellf(1500); + assert.ok(result.includes('thousand'), 'should include "thousand"'); + assert.ok(result.includes('one'), 'should include "one"'); +}); + +test('spellf: spells millions', () => { + const s = createSandbox(); + const result = s.spellf(2500000); + assert.ok(result.includes('million'), 'should include "million"'); +}); + +test('spellf: handles negative numbers', () => { + const s = createSandbox(); + const result = s.spellf(-42); + assert.ok(result.startsWith('negative'), 'should start with "negative"'); + assert.ok(result.includes('forty'), 'should include the number words'); +}); + +test('spellf: spells billions', () => { + const s = createSandbox(); + const result = s.spellf(1e9); + assert.ok(result.includes('billion'), 'should include "billion"'); +}); + +test('spellf: spells decillion', () => { + const s = createSandbox(); + const result = s.spellf(1e33); + assert.ok(result.includes('decillion'), 'should include "decillion"'); +}); + +// ===================== getScaleName() tests ===================== + +test('getScaleName: returns empty for small numbers', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(0), ''); + assert.equal(s.getScaleName(500), ''); +}); + +test('getScaleName: returns "thousand" for 1000s', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(1000), 'thousand'); + assert.equal(s.getScaleName(5000), 'thousand'); +}); + +test('getScaleName: returns "million" for millions', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(1e6), 'million'); +}); + +test('getScaleName: returns "billion" for billions', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(1e9), 'billion'); +}); + +test('getScaleName: returns "trillion" for trillions', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(1e12), 'trillion'); +}); + +test('getScaleName: returns "quadrillion" for quadrillions', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(1e15), 'quadrillion'); +}); + +test('getScaleName: returns "centillion" for 10^303', () => { + const s = createSandbox(); + assert.equal(s.getScaleName(1e303), 'centillion'); +}); + +// ===================== getBuildingCost() tests ===================== + +test('getBuildingCost: returns empty object for unknown building', () => { + const s = createSandbox(); + const cost = s.getBuildingCost('nonexistent'); + assert.equal(Object.keys(cost).length, 0, 'should return empty object'); +}); + +test('getBuildingCost: returns base cost for first building', () => { + const s = createSandbox(); + const cost = s.getBuildingCost('autocoder'); + assert.equal(cost.code, 10); +}); + +test('getBuildingCost: scales cost with existing buildings', () => { + const s = createSandbox(); + s.G.buildings.autocoder = 5; + const cost = s.getBuildingCost('autocoder'); + // cost = floor(10 * 1.15^5) = floor(20.113...) = 20 + assert.equal(cost.code, 20); + assert.ok(cost.code > 10, 'cost should increase with building count'); +}); + +// ===================== getMaxBuyable() tests ===================== + +test('getMaxBuyable: returns 0 for unknown building', () => { + const s = createSandbox(); + assert.equal(s.getMaxBuyable('nonexistent'), 0); +}); + +test('getMaxBuyable: returns 0 when no resources', () => { + const s = createSandbox(); + s.G.code = 0; + assert.equal(s.getMaxBuyable('autocoder'), 0); +}); + +test('getMaxBuyable: returns correct count for available resources', () => { + const s = createSandbox(); + s.G.code = 1000; + s.G.buildings.autocoder = 0; + const max = s.getMaxBuyable('autocoder'); + assert.ok(max > 0, 'should be able to buy at least one'); + assert.ok(max < 200, 'should be bounded'); +}); + +// ===================== getBulkCost() tests ===================== + +test('getBulkCost: returns empty for invalid input', () => { + const s = createSandbox(); + assert.equal(Object.keys(s.getBulkCost('nonexistent', 5)).length, 0, 'unknown building should be empty'); + assert.equal(Object.keys(s.getBulkCost('autocoder', 0)).length, 0, 'qty=0 should be empty'); + assert.equal(Object.keys(s.getBulkCost('autocoder', -1)).length, 0, 'qty=-1 should be empty'); +}); + +test('getBulkCost: returns correct total for qty=1', () => { + const s = createSandbox(); + const singleCost = s.getBuildingCost('autocoder'); + const bulkCost = s.getBulkCost('autocoder', 1); + assert.deepEqual(bulkCost, singleCost); +}); + +test('getBulkCost: accumulates cost for multiple purchases', () => { + const s = createSandbox(); + s.G.buildings.autocoder = 0; + const bulk2 = s.getBulkCost('autocoder', 2); + const cost0 = Math.floor(10 * Math.pow(1.15, 0)); // 10 + const cost1 = Math.floor(10 * Math.pow(1.15, 1)); // 11 + assert.equal(bulk2.code, cost0 + cost1); +}); + +// ===================== canAffordBuilding() tests ===================== + +test('canAffordBuilding: false when cannot afford', () => { + const s = createSandbox(); + s.G.code = 0; + assert.equal(s.canAffordBuilding('autocoder'), false); +}); + +test('canAffordBuilding: true when can afford', () => { + const s = createSandbox(); + s.G.code = 100; + assert.equal(s.canAffordBuilding('autocoder'), true); +}); + +test('canAffordBuilding: false for unknown building', () => { + const s = createSandbox(); + // Unknown building has no cost, so empty cost object means all conditions pass + // This is actually a design quirk — no resources needed = can always afford + const result = s.canAffordBuilding('nonexistent'); + assert.equal(result, true, 'empty cost means always affordable'); +}); + +// ===================== spendBuilding() tests ===================== + +test('spendBuilding: deducts resources', () => { + const s = createSandbox(); + s.G.code = 100; + s.spendBuilding('autocoder'); + assert.equal(s.G.code, 90); +}); + +// ===================== canAffordProject() / spendProject() tests ===================== + +test('canAffordProject: checks project cost', () => { + const s = createSandbox(); + s.G.code = 100; + assert.equal(s.canAffordProject({ cost: { code: 50 } }), true); + assert.equal(s.canAffordProject({ cost: { code: 200 } }), false); +}); + +test('spendProject: deducts project cost', () => { + const s = createSandbox(); + s.G.code = 100; + s.spendProject({ cost: { code: 30 } }); + assert.equal(s.G.code, 70); +}); + +// ===================== getClickPower() tests ===================== + +test('getClickPower: returns 1 at base state (phase 1, no autocoder)', () => { + const s = createSandbox(); + s.G.phase = 1; + s.G.buildings.autocoder = 0; + s.G.codeBoost = 1; + // (1 + floor(0 * 0.5) + max(0, 0) * 2) * 1 = 1 + assert.equal(s.getClickPower(), 1); +}); + +test('getClickPower: scales with autocoder buildings', () => { + const s = createSandbox(); + s.G.phase = 1; + s.G.buildings.autocoder = 4; + s.G.codeBoost = 1; + // (1 + floor(4 * 0.5) + 0) * 1 = 3 + assert.equal(s.getClickPower(), 3); +}); + +test('getClickPower: scales with phase', () => { + const s = createSandbox(); + s.G.phase = 3; + s.G.buildings.autocoder = 0; + s.G.codeBoost = 1; + // (1 + 0 + max(0, 2) * 2) * 1 = 5 + assert.equal(s.getClickPower(), 5); +}); + +test('getClickPower: scales with codeBoost', () => { + const s = createSandbox(); + s.G.phase = 1; + s.G.buildings.autocoder = 0; + s.G.codeBoost = 3; + // (1 + 0 + 0) * 3 = 3 + assert.equal(s.getClickPower(), 3); +}); + +test('getClickPower: combines all multipliers', () => { + const s = createSandbox(); + s.G.phase = 2; + s.G.buildings.autocoder = 6; + s.G.codeBoost = 2; + // (1 + floor(6 * 0.5) + max(0, 1) * 2) * 2 = (1 + 3 + 2) * 2 = 12 + assert.equal(s.getClickPower(), 12); +});