Covers fmt(), spellf(), getScaleName(), getBuildingCost(), getMaxBuyable(), getBulkCost(), canAffordBuilding(), spendBuilding(), canAffordProject(), spendProject(), and getClickPower(). Uses VM sandbox with DOM mocks to test browser-dependent code in Node.js test runner.
441 lines
14 KiB
JavaScript
441 lines
14 KiB
JavaScript
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);
|
|
});
|