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