natura-test.js
126 lines · in natura-src-1.0.0.zip as natura/natura-test.js · a test
// natura-test.js -- assertions for the Natura core. node natura-test.js (non-zero on failure).
const N = require('./natura.js');
const { World, makeClock, defineSystem, getSystem, clearRegistry, UNITS } = N;
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log(' FAIL: ' + m); } };
const near = (a, b, e) => Math.abs(a - b) <= (e || 1e-9);
console.log('natura core assertions:');
// ---- the CLOCK: one time, cadences from day to eon ----
{
const c = makeClock(0);
ok(near(c.dayPhase(), 0), 'day phase starts at 0 (midnight)');
c.advance(UNITS.DAY / 4);
ok(near(c.dayPhase(), 0.25), 'quarter day -> dayPhase 0.25');
c.advance(UNITS.DAY / 4); // now noon
ok(near(c.dayPhase(), 0.5) && c.daylight() > 0.99, 'noon -> dayPhase 0.5, daylight ~1');
c.advance(UNITS.DAY / 2); // back to midnight
ok(c.daylight() < 0.01, 'midnight -> daylight ~0');
const c2 = makeClock(0); c2.advance(UNITS.YEAR * 0.6);
ok(c2.season() === 'autumn', 'year-phase 0.6 -> autumn, got ' + c2.season());
ok(near(c2.years(), 0.6, 1e-6), 'absolute years read');
const big = makeClock(0); big.advance(UNITS.EON * 4.6);
ok(near(big.eons(), 4.6, 1e-6), 'deep time: 4.6 eons read (a sun\'s age)');
// phase determinism
const p1 = makeClock(12345).phase(UNITS.WEEK), p2 = makeClock(12345).phase(UNITS.WEEK);
ok(p1 === p2, 'phase is deterministic');
}
// ---- the REGISTRY ----
{
clearRegistry();
defineSystem({ name: 'demo', init: () => ({ x: 1 }) });
ok(getSystem('demo') && getSystem('demo').name === 'demo', 'defineSystem / getSystem round-trips');
ok(getSystem('nope') === undefined, 'unknown system -> undefined');
}
// ---- demo systems: a clock-reader, a coupled reader, a ticker with events ----
const seasons = {
name: 'seasons',
init: () => ({ daylight: 0, season: 'spring', temp: 0 }),
step: (s, ctx) => {
s.daylight = ctx.clock.daylight();
s.season = ctx.clock.season();
s.temp = 10 + 15 * ctx.clock.daylight() + 5 * Math.sin(ctx.clock.yearPhase() * 2 * Math.PI);
return s;
},
fields: (s) => ({ daylight: s.daylight, season: s.season, temp: s.temp })
};
const thermometer = { // COUPLED: reads the seasons system's exposed temp field
name: 'thermometer',
init: () => ({ reading: null }),
step: (s, ctx) => { const f = ctx.fields.seasons; s.reading = f ? f.temp : null; return s; },
fields: (s) => ({ reading: s.reading })
};
const ticker = {
name: 'ticker',
init: () => ({ n: 0 }),
step: (s) => { s.n++; return s; },
events: (s) => (s.n % 5 === 0 ? [{ kind: 'tick5', n: s.n }] : [])
};
// ---- the WORLD: steps its stack, advances the clock, evolves state ----
{
clearRegistry();
const w = World({ seed: 7, systems: [seasons, thermometer, ticker] });
ok(w.systems.length === 3, 'world holds the chosen stack');
ok(w.get('ticker').n === 0, 'ticker starts at 0');
for (let i = 0; i < 8; i++) w.step(UNITS.DAY / 8); // one full day in 8 steps
ok(w.get('ticker').n === 8, 'ticker advanced one per step');
ok(typeof w.get('seasons').temp === 'number' && isFinite(w.get('seasons').temp), 'seasons produced a finite temp');
}
// ---- COUPLING: the thermometer tracks the seasons temp (one-frame delayed snapshot) ----
{
clearRegistry();
const w = World({ seed: 3, systems: [seasons, thermometer] });
w.step(UNITS.HOUR * 6); // step 1: seasons computes temp T1; thermometer read the pre-step (init) temp
const t1 = w.get('seasons').temp;
w.step(UNITS.HOUR * 6); // step 2: thermometer reads the snapshot = T1
ok(w.get('thermometer').reading === t1, 'thermometer read the seasons field (coupling works, 1-frame delay)');
ok(w.get('thermometer').reading !== null, 'coupled reading is populated');
}
// ---- daylight actually swings between night and noon over a day ----
{
clearRegistry();
const w = World({ seed: 1, systems: [seasons] });
let dayLo = Infinity, dayHi = -Infinity;
for (let i = 0; i < 24; i++) { w.step(UNITS.HOUR); const d = w.get('seasons').daylight; dayLo = Math.min(dayLo, d); dayHi = Math.max(dayHi, d); }
ok(dayLo < 0.05 && dayHi > 0.95, 'daylight swings from ~0 (night) to ~1 (noon) across a day: ' + dayLo.toFixed(2) + '..' + dayHi.toFixed(2));
}
// ---- EVENTS are collected from systems ----
{
clearRegistry();
const w = World({ seed: 1, systems: [ticker] });
const all = w.run(12, UNITS.HOUR);
const ticks = all.filter(e => e.kind === 'tick5');
ok(ticks.length === 2, 'ticker emitted events at n=5 and n=10, got ' + ticks.length);
ok(ticks[0].system === 'ticker' && typeof ticks[0].t === 'number', 'events carry system name + time');
}
// ---- COMPOSABILITY: run a subset; unknown systems throw ----
{
clearRegistry();
const w = World({ seed: 1, systems: [ticker] });
ok(w.has('ticker') && !w.has('seasons'), 'a world runs exactly the chosen subset');
let threw = false;
try { World({ seed: 1, systems: ['does-not-exist'] }); } catch (e) { threw = true; }
ok(threw, 'an unknown system name throws (fail loud, not silent)');
}
// ---- DETERMINISM: same seed + same dt sequence -> identical state ----
{
clearRegistry();
const mk = () => World({ seed: 42, systems: [seasons, thermometer, ticker] });
const a = mk(), b = mk();
for (let i = 0; i < 30; i++) { a.step(UNITS.HOUR * 5); b.step(UNITS.HOUR * 5); }
ok(a.get('seasons').temp === b.get('seasons').temp && a.get('ticker').n === b.get('ticker').n
&& a.get('thermometer').reading === b.get('thermometer').reading, 'same seed + dt sequence -> identical state (reproducible)');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);