Index: test/ward-sky.test.js
===================================================================
--- test/ward-sky.test.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ test/ward-sky.test.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
@@ -0,0 +1,69 @@
+// De avatarhemel: het MODEL, niet de tekening.
+//
+// Deze wiskunde bestaat nu drie keer: Swift, Kotlin en hier. Dat is drie keer
+// kans op drift, en drift zie je niet aan een screenshot — je ziet het als
+// gezichten op een andere plek hangen dan in de app. Daarom staan hier
+// dezelfde eigenschappen bewaakt als in AvatarSkyTest.kt: vers hangt dichterbij
+// dan oud, dezelfde invoer geeft dezelfde uitkomst, en niemand overlapt.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { orbitPlacement, nearestFace } = await import('../src/assets/js/ward-sky.js');
+
+const r = (s) => Math.hypot(s.x, s.y);
+
+test('vers hangt dichter bij het midden dan oud', () => {
+  // De afstand tot het midden IS de leeftijd van het laatste bericht. Zou dat
+  // omdraaien, dan leest de hele hemel verkeerd.
+  const spots = orbitPlacement([0, 3600000, 86400000, 30 * 86400000]);
+  assert.ok(r(spots[0]) < r(spots[3]), 'het verste gezicht was niet het oudste');
+});
+
+test('dezelfde invoer geeft dezelfde uitkomst', () => {
+  // Zonder dit springt de hemel bij elke verversing door elkaar en herken je
+  // niemand meer aan zijn plek.
+  const ages = Array.from({ length: 40 }, (_, i) => i * 7200000);
+  assert.deepEqual(orbitPlacement(ages), orbitPlacement(ages));
+});
+
+test('niemand overlapt, ook niet bij gelijke leeftijd', () => {
+  // Twintig gesprekken van exact even oud: zonder de push-apart vallen die op
+  // één punt samen en zie je één gezicht in plaats van twintig.
+  const spots = orbitPlacement(Array(20).fill(3600000));
+  for (let i = 0; i < spots.length; i++) {
+    for (let j = i + 1; j < spots.length; j++) {
+      const d = Math.hypot(spots[j].x - spots[i].x, spots[j].y - spots[i].y);
+      assert.ok(d > 1.0, `gezicht ${i} en ${j} liggen op elkaar (${d.toFixed(3)})`);
+    }
+  }
+});
+
+test('leeg blijft leeg, en één gezicht staat niet in het midden', () => {
+  assert.deepEqual(orbitPlacement([]), []);
+  // Het midden is de deur naar iemand nieuw; daar hoort geen gezicht te staan.
+  const [solo] = orbitPlacement([0]);
+  assert.ok(r(solo) >= 1.4, 'het enige gezicht stond bovenop de (+)');
+});
+
+test('mikken kiest het dichtstbijzijnde gezicht, of niets', () => {
+  const spots = orbitPlacement([0, 86400000, 30 * 86400000]);
+  const view = { cx: 200, cy: 200, cell: 92, scale: 1, panX: 0, panY: 0 };
+  // Precies op het eerste gezicht mikken.
+  const p = { x: view.cx + spots[0].x * 92, y: view.cy + spots[0].y * 92 };
+  assert.equal(nearestFace(p.x, p.y, spots, view), 0);
+  // Ver ernaast is GEEN treffer: liever niets openen dan de verkeerde persoon.
+  assert.equal(nearestFace(-500, -500, spots, view), null);
+});
+
+test('de treffer schaalt mee met de zoom', () => {
+  // Uitgezoomd liggen de gezichten dichter bij elkaar; het mikpunt moet dan
+  // meebewegen, anders open je bij het uitzoomen steeds de verkeerde.
+  const spots = orbitPlacement([0, 3600000, 7200000]);
+  const at = (scale) => {
+    const v = { cx: 300, cy: 300, cell: 92, scale, panX: 0, panY: 0 };
+    return nearestFace(v.cx + spots[1].x * 92 * scale, v.cy + spots[1].y * 92 * scale, spots, v);
+  };
+  assert.equal(at(1), 1);
+  assert.equal(at(0.6), 1);
+  assert.equal(at(1.5), 1);
+});
