← Natura·Docs/Modules/Source

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);