← Natura·Docs/Modules/Source

verbs/school-test.js

85 lines · in natura-src-1.0.0.zip as natura/verbs/school-test.js · a test

// school-test.js -- node tests for the shoaling/flocking verb. Run: node natura/verbs/school-test.js
'use strict';
const school = require('./school').school;
const Verbs = require('./index');

let pass = 0, fail = 0;
function ok(cond, msg) { if (cond) { pass++; } else { fail++; console.log('  FAIL: ' + msg); } }
function near(a, b, eps, msg) { ok(Math.abs(a - b) <= (eps == null ? 1e-6 : eps), msg + ' (got ' + a + ', want ~' + b + ')'); }

// --- SEPARATION: a neighbour right on top of me pushes me the opposite way -------------------------
{
  const self = { pos: [0.5, 0.5], vel: [1, 0] };
  const other = { pos: [0.53, 0.5], vel: [1, 0] };            // just to my +x, well inside sepRadius
  const r = school(self, [self, other], { perceive: 0.2, sep: 1, ali: 0, coh: 0 });
  ok(r.sep[0] < -0.9, 'separation points -x (away from the +x neighbour)');
  near(r.sep[1], 0, 1e-9, 'separation has no y-component for a purely +x neighbour');
  ok(r.near === 1, 'one neighbour counted inside sepRadius');
}

// --- COHESION: a cluster to my +x pulls me +x ------------------------------------------------------
{
  const self = { pos: [0.5, 0.5] };
  const cluster = [{ pos: [0.6, 0.5] }, { pos: [0.62, 0.52] }, { pos: [0.61, 0.48] }];  // all to my +x, outside sepRadius
  const r = school(self, [self].concat(cluster), { perceive: 0.3, sepRadius: 0.02, sep: 0, ali: 0, coh: 1 });
  ok(r.coh[0] > 0.9, 'cohesion points +x toward the cluster centroid');
  ok(r.n === 3, 'all three cluster members are neighbours');
}

// --- ALIGNMENT: neighbours all heading +y makes me steer +y ---------------------------------------
{
  const self = { pos: [0.5, 0.5], vel: [1, 0] };
  const others = [{ pos: [0.5, 0.52], vel: [0, 2] }, { pos: [0.52, 0.5], vel: [0, 3] }];
  const r = school(self, [self].concat(others), { perceive: 0.3, sep: 0, ali: 1, coh: 0 });
  near(r.ali[0], 0, 1e-9, 'alignment has no x-component when neighbours head +y');
  ok(r.ali[1] > 0.99, 'alignment points +y (matches neighbours)');
}

// --- TOPOLOGICAL k: only the k nearest count, regardless of radius --------------------------------
{
  const self = { pos: [0, 0] };
  const others = [];
  for (let i = 1; i <= 10; i++) others.push({ pos: [i * 0.05, 0] });   // 10 neighbours strung out along +x
  const r = school(self, [self].concat(others), { k: 3, sep: 0, ali: 0, coh: 1 });
  ok(r.n === 3, 'topological mode uses exactly k=3 neighbours (got ' + r.n + ')');
  const rMetric = school(self, [self].concat(others), { perceive: 1.0, sep: 0, ali: 0, coh: 1 });
  ok(rMetric.n === 10, 'metric mode with a wide radius sees all 10 (got ' + rMetric.n + ')');
}

// --- ROUTER: reachable as Verbs.school and identical -----------------------------------------------
{
  ok(typeof Verbs.school === 'function', 'Verbs.school is wired into the router');
  const self = { pos: [0.5, 0.5], vel: [1, 0] }, other = { pos: [0.55, 0.5], vel: [1, 0] };
  const a = school(self, [self, other], { perceive: 0.2 });
  const b = Verbs.school(self, [self, other], { perceive: 0.2 });
  ok(a.sx === b.sx && a.sy === b.sy, 'router call equals direct call');
}

// --- EMERGENT: alignment alone makes a random-heading crowd converge to one heading ---------------
// (the real proof the verb flocks -- order parameter |mean unit velocity| climbs toward 1)
{
  // deterministic pseudo-rng so the test is reproducible (no Math.random)
  let s = 12345; const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
  const N = 40, fish = [];
  for (let i = 0; i < N; i++) { const a = rng() * Math.PI * 2; fish.push({ pos: [rng(), rng()], vel: [Math.cos(a), Math.sin(a)] }); }
  const torus = (a, b) => { let dx = a.x - (b[0] != null ? b[0] : b.x), dy = a.y - (b[1] != null ? b[1] : b.y); if (dx > 0.5) dx -= 1; if (dx < -0.5) dx += 1; if (dy > 0.5) dy -= 1; if (dy < -0.5) dy += 1; return [dx, dy]; };
  const speed = 0.004, accel = 0.12;
  const order = () => { let ox = 0, oy = 0; for (const f of fish) { const m = Math.hypot(f.vel[0], f.vel[1]) || 1; ox += f.vel[0] / m; oy += f.vel[1] / m; } return Math.hypot(ox, oy) / N; };
  const o0 = order();
  for (let step = 0; step < 500; step++) {
    for (const f of fish) {
      const r = school(f, fish, { k: 7, sep: 1.2, ali: 1.4, coh: 0.8, sepRadius: 0.04, delta: torus });
      f.vel[0] += r.sx * accel; f.vel[1] += r.sy * accel;
      const m = Math.hypot(f.vel[0], f.vel[1]) || 1; f.vel[0] = f.vel[0] / m * speed; f.vel[1] = f.vel[1] / m * speed;   // fixed speed, steer only turns
    }
    for (const f of fish) { f.pos[0] = (f.pos[0] + f.vel[0] + 1) % 1; f.pos[1] = (f.pos[1] + f.vel[1] + 1) % 1; }
  }
  const o1 = order();
  console.log('  [emergent] order ' + o0.toFixed(3) + ' -> ' + o1.toFixed(3));
  ok(o0 < 0.5, 'starts disordered (random headings)');
  ok(o1 > 0.75, 'self-organises to a common heading (order climbs past 0.75)');
  ok(o1 > o0 + 0.3, 'order rises substantially through schooling');
}

console.log((fail ? 'FAIL ' : 'OK   ') + 'school.js: ' + pass + ' passed, ' + fail + ' failed');
process.exit(fail ? 1 : 0);