← Natura·Docs/Modules/Source

verbs/phenology-test.js

111 lines · in natura-src-1.0.0.zip as natura/verbs/phenology-test.js · a test

// phenology-test.js -- assertions for the phenology verb (grow + stageOf). Pure functions, so tested directly.
const { grow, stageOf } = require('./phenology.js');

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('phenology assertions:');

// --- grow: advances toward target, never past it ---
{
  let g = 0; for (let i = 0; i < 100; i++) g = grow(g, 0.5, { rate: 0.05 });   // 50s at rate .05 -> would be 2.5, capped
  ok(g === 1, 'grow saturates AT the target and never overshoots (' + g + ')');
  const one = grow(0.2, 1, { rate: 0.1 });
  ok(near(one, 0.3), 'grow advances by rate*dt at full vigour (0.2 -> ' + one.toFixed(3) + ')');
  ok(grow(0.5, 1, { rate: 0.1, target: 0.6 }) === 0.6, 'grow respects a custom target (caps at 0.6)');
  ok(grow(0, 0, { rate: 1 }) === 0, 'zero dt -> no change');
}

// --- grow: gates multiply the rate, and 0 stalls growth ---
{
  const base = grow(0, 1, { rate: 0.2 });                             // gates default 1
  ok(near(base, 0.2), 'ungated growth = rate*dt');
  ok(grow(0, 1, { rate: 0.2, resource: 0 }) === 0, 'no resource -> no growth (starved)');
  ok(grow(0, 1, { rate: 0.2, light: 0 }) === 0, 'no light -> no growth (a phototroph in the dark)');
  ok(near(grow(0, 1, { rate: 0.2, resource: 0.5, light: 0.5 }), 0.05), 'gates MULTIPLY (half resource x half light = quarter rate)');
  ok(near(grow(0, 1, { rate: 0.2, resource: 1.5 }), 0.3), 'a gate above 1 BOOSTS growth (rich supply, e.g. very wet)');
  ok(grow(0, 1, { rate: 0.2, resource: -3 }) === 0, 'a negative gate is clamped to 0, never reverses growth');
}

// --- grow: taper halves the rate past the taper fraction (ripening slows near full) ---
{
  const early = grow(0.5, 0.1, { rate: 0.2, taper: 0.8 });            // 0.5 < 0.8 -> full rate  -> +0.02
  const late = grow(0.9, 0.1, { rate: 0.2, taper: 0.8 });             // 0.9 > 0.8 -> half rate  -> +0.01
  ok(near(early, 0.52), 'below the taper point growth is full rate (+0.02)');
  ok(near(late, 0.91), 'past the taper point growth is HALVED (+0.01, not +0.02)');
  ok(grow(0.5, 1, { rate: 0.2, taper: 1 }) > grow(0.5, 1, { rate: 0.2, taper: 0.4 }), 'taper:1 (default) applies no slowdown');
}

// --- grow: pure -- same inputs, same output; does not mutate opts ---
{
  const opts = { rate: 0.1, resource: 0.7 };
  const a = grow(0.3, 1, opts), b = grow(0.3, 1, opts);
  ok(a === b, 'grow is deterministic (same inputs -> same output)');
  ok(opts.rate === 0.1 && opts.resource === 0.7 && Object.keys(opts).length === 2, 'grow does not mutate its opts');
}

// --- stageOf: reads the passed stage at and between thresholds ---
{
  const S = { seed: 0, sprout: 0.08, bud: 0.62, bloom: 0.82 };
  ok(stageOf(0, S) === 'seed', 'g=0 -> seed');
  ok(stageOf(0.05, S) === 'seed', 'below sprout -> still seed');
  ok(stageOf(0.08, S) === 'sprout', 'AT a threshold -> that stage (sprout)');
  ok(stageOf(0.7, S) === 'bud', 'between bud and bloom -> bud');
  ok(stageOf(0.82, S) === 'bloom', 'at bloom -> bloom');
  ok(stageOf(1, S) === 'bloom', 'past the last threshold -> the last stage');
  ok(stageOf(-0.5, S) === 'seed', 'below every threshold -> the earliest stage');
}

// --- stageOf: array form, unordered input, single stage ---
{
  ok(stageOf(0.5, [['a', 0], ['b', 0.3], ['c', 0.9]]) === 'b', 'array form works');
  ok(stageOf(0.5, { late: 0.9, early: 0, mid: 0.3 }) === 'mid', 'unordered stages still resolve to the right one');
  ok(stageOf(0.9, { only: 0 }) === 'only', 'a single stage always returns');
  ok(stageOf(0.5, []) === null, 'no stages -> null');
}

// --- integration: a worked under-glass-style run. A WATERED, lit plant reaches bloom faster than a parched one,
//     and its stage sequence never regresses (monotone life). ---
{
  const S = { seed: 0, sprout: 0.08, bud: 0.62, bloom: 0.82 };
  let wet = 0, dry = 0; const seen = [];
  for (let i = 0; i < 400; i++) {
    wet = grow(wet, 0.1, { rate: 0.06, resource: 1, light: 1, taper: 0.85 });
    dry = grow(dry, 0.1, { rate: 0.06, resource: 0.2, light: 1, taper: 0.85 });
    const st = stageOf(wet, S); if (seen[seen.length - 1] !== st) seen.push(st);
  }
  ok(wet > dry, 'the watered plant outgrows the parched one over the same time (' + wet.toFixed(2) + ' vs ' + dry.toFixed(2) + ')');
  ok(wet > 0.82 && stageOf(wet, S) === 'bloom', 'the watered plant reaches bloom');
  ok(seen.join('>') === 'seed>sprout>bud>bloom', 'stages advance in order and never regress (' + seen.join('>') + ')');
  const idx = k => seen.indexOf(k);
  ok(idx('seed') < idx('sprout') && idx('sprout') < idx('bud') && idx('bud') < idx('bloom'), 'the life-stage sequence is monotone');
}

// --- provenance parity: grow reproduces the exact rainforest hand-rolls it replaces (behaviour-preserving capture) ---
{
  const active = 0.7, m = 0.6, dt = 0.5;
  // mould: min(1, grow + active*dt*0.03)
  ok(near(grow(0.4, dt, { rate: 0.03, light: active }), Math.min(1, 0.4 + active * dt * 0.03)), 'grow == the mould hand-roll (light-gated spread)');
  // fruit: min(1, ripe + dt*0.02*active*(0.4+0.9*m))
  ok(near(grow(0.3, dt, { rate: 0.02, light: active, resource: 0.4 + 0.9 * m }), Math.min(1, 0.3 + dt * 0.02 * active * (0.4 + 0.9 * m))), 'grow == the fruit hand-roll (light+moisture ripening)');
  // flower nectar: min(1, nectar + dt*0.03*active*(0.4+0.9*m))
  ok(near(grow(0.5, dt, { rate: 0.03, light: active, resource: 0.4 + 0.9 * m }), Math.min(1, 0.5 + dt * 0.03 * active * (0.4 + 0.9 * m))), 'grow == the flower-nectar hand-roll');
}

// --- growth CURVES: linear (default, unchanged) vs logistic S-curve vs accelerating ---
{
  const dgAt = (g0, curve) => grow(g0, 0.01, { rate: 1, curve: curve }) - g0;   // per-step increment at progress g0
  // back-compat: no curve == 'linear' == the old behaviour
  ok(grow(0.3, 1, { rate: 0.1 }) === grow(0.3, 1, { rate: 0.1, curve: 'linear' }), 'default curve is linear (back-compatible)');
  ok(Math.abs(dgAt(0.2, 'linear') - dgAt(0.6, 'linear')) < 1e-9, 'LINEAR: a steady rate at every stage');
  // logistic S-curve: growth is fastest in the MIDDLE, slow at seed and at ripening
  ok(dgAt(0.5, 'logistic') > dgAt(0.1, 'logistic') && dgAt(0.5, 'logistic') > dgAt(0.9, 'logistic'), 'LOGISTIC: fastest mid-life, slow at both ends (the real S-curve)');
  ok(dgAt(0.02, 'logistic') > 0, 'LOGISTIC: still leaves the seed (a floored start, not stuck at zero)');
  // accelerating: growth speeds up as it grows
  ok(dgAt(0.8, 'accelerating') > dgAt(0.2, 'accelerating'), 'ACCELERATING: compounds -- faster the bigger it is');
  // every curve still saturates AT the target and never past
  for (const c of ['linear', 'logistic', 'accelerating']) { let g = 0.05; for (let i = 0; i < 4000; i++) g = grow(g, 0.5, { rate: 0.05, curve: c }); ok(Math.abs(g - 1) < 1e-6, c + ' still saturates exactly at the target'); }
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);