Back to Safe Sand · Material studies
SOURCE / PINNED RELEASE

Made of little things.

Safe Sand · Material studies

Release
9101a9cd9fb4…
Author-recorded commit
75d98e1b8edb…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-7682e55a791

Archive hash verified: 6c8ddabeb31100e1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/simulation.mjs
import assert from 'node:assert/strict';
import { readFile, mkdir } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
const require = createRequire(import.meta.url);
// Reuse the project's pinned conformance browser dependency.
const browserRequire = createRequire(require.resolve('@napplet/conformance-cli/package.json'));
const { chromium } = browserRequire('playwright');
const browser = await chromium.launch({ headless: true, ...(process.env.SAND_CHROMIUM ? { executablePath: process.env.SAND_CHROMIUM } : {}) });
const html = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8');
const errors = [];
let passed = 0;
const check = (condition, label) => { assert.ok(condition, label); passed++; console.log(`PASS ${label}`); };
try {
  const page = await browser.newPage({ viewport: { width: 1000, height: 760 }, reducedMotion: 'reduce' });
  page.on('pageerror', e => errors.push(e.message));
  async function load(injection = '') {
    await page.setContent('<style>body{margin:0}iframe{width:900px;height:600px;border:0}</style><iframe sandbox="allow-scripts"></iframe>');
    await page.locator('iframe').evaluate((el, source) => { el.srcdoc = source; }, injection + html);
    const frame = page.frames().find(f => f.parentFrame());
    await frame.waitForSelector('#app[data-ready="true"]');
    return frame;
  }
  let f = await load();
  const info = () => f.evaluate(() => window.sand.info());
  const sample = () => f.evaluate(() => window.sand.sample());
  const choose = async key => {
    await f.locator('#materialsButton').click(); await f.locator(`button[data-material="${key}"]`).click(); await f.locator('#returnButton').click();
  };
  check((await info()).paused, 'reduced motion starts with a settled, paused tray');
  check((await sample()).count > 100000, 'full grain budget after host initial layout');
  for (const key of ['river', 'beach', 'desert', 'volcanic', 'coral', 'snow', 'glass', 'iron', 'glitter', 'salt', 'kinetic']) {
    await choose(key);
    const state = await sample();
    check(state.finite && state.count > 0 && (await info()).material === key, `${key} renders finite, live GPU particles`);
  }
  const start = await sample();
  const oldWorld = (await info()).world;
  await page.locator('iframe').evaluate(el => { el.style.width='320px'; el.style.height='560px'; });
  await f.waitForFunction(() => innerWidth === 320 && window.sand.info().world[0] < 100);
  const resized = await sample();
  const newWorld = (await info()).world;
  check(resized.count === start.count && Math.abs(resized.x / newWorld[0] - start.x / oldWorld[0]) < .001, 'live resize preserves grains and relative position');
  await page.locator('iframe').evaluate(el => { el.style.width='900px'; el.style.height='600px'; });
  await f.waitForFunction(() => innerWidth === 900 && window.sand.info().world[0] > 200);
  await choose('glass');
  const beforeTilt = await sample();
  await f.locator('#pauseButton').click();
  await f.locator('#sand').focus();
  await page.keyboard.down('ArrowRight');
  await page.waitForTimeout(900); await page.keyboard.up('ArrowRight');
  await f.locator('#pauseButton').click();
  const afterTilt = await sample();
  check(afterTilt.x > beforeTilt.x + 4 && afterTilt.finite, 'tilt rolls glass beads across the tray');
  const steps = (await info()).steps;
  await page.waitForTimeout(150);
  check((await info()).steps === steps, 'pause stops simulation steps');
  await choose('iron');
  await f.locator('#toolButton').click();
  await f.locator('button[data-tool="magnet"]').click();
  await f.locator('#pauseButton').click();
  const box = await f.locator('#sand').boundingBox();
  const beforeMagnet = await sample();
  await page.mouse.move(box.x + box.width * .68, box.y + box.height * .5); await page.mouse.down();
  await page.waitForTimeout(1000); await page.mouse.up();
  await f.locator('#pauseButton').click();
  const afterMagnet = await sample();
  check(afterMagnet.x > beforeMagnet.x + .15 && afterMagnet.finite, 'magnet attracts iron filings toward the pointer');
  await f.locator('#materialsButton').click();
  check(await f.locator('#saveButton').isDisabled(), 'missing storage disables only save and restore');
  await f.locator('#clearButton').click();
  check((await sample()).count === 0, 'empty tray removes live grains');
  await f.locator('#pourButton').click();
  check((await sample()).count > 0, 'fresh pour recovers an empty tray');
  await f.locator('#toolButton').click(); await f.locator('button[data-tool="repel"]').click();
  const beforeRepel = await sample();
  await f.locator('#pauseButton').click();
  await page.mouse.move(box.x + box.width * .68, box.y + box.height * .5); await page.mouse.down();
  await page.waitForTimeout(900); await page.mouse.up(); await f.locator('#pauseButton').click();
  check((await sample()).x < beforeRepel.x - .03, 'repulsion moves iron away from the magnetic pointer');
  const beforeCamera = await sample();
  await f.locator('#cameraButton').click(); await f.locator('#angledButton').click();
  check((await info()).camera.pitch === 28 && (await info()).camera.yaw === -18, 'angled view rotates the height-aware camera');
  const angled = await sample();
  check(angled.count === beforeCamera.count && angled.x === beforeCamera.x && angled.y === beforeCamera.y, 'camera movement does not move the grains');
  await f.locator('#orbitButton').click();
  await page.mouse.move(box.x + 350, box.y + 220); await page.mouse.down();
  await page.mouse.move(box.x + 800, box.y + 550, {steps:12}); await page.mouse.up();
  check((await info()).camera.pitch === 35 && (await info()).camera.yaw === 35, 'orbit drag works while paused and stays within bounds');
  await f.locator('#cameraButton').click(); await f.locator('#overheadButton').click(); await f.locator('#closeCamera').click();
  check((await info()).camera.pitch === 0 && (await info()).camera.yaw === 0, 'overhead reset restores the original camera');
  await f.locator('#materialsButton').click(); await f.locator('#clearButton').click();
  await f.locator('#toolButton').click(); await f.locator('button[data-tool="pour"]').click();
  await f.locator('#pauseButton').click();
  await page.mouse.move(box.x+box.width*.5,box.y+box.height*.5); await page.mouse.down();
  await page.waitForTimeout(650); await page.mouse.up();
  await f.locator('#pauseButton').click();
  const poured = await sample();
  check(poured.count > 500 && poured.count < 30000 && poured.finite, 'continuous pouring adds a bounded stream to an empty tray');
  await f.locator('#toolButton').click(); await f.locator('button[data-tool="pour"]').click();
  await f.locator('#pauseButton').click();
  await page.mouse.move(box.x+box.width*.72,box.y+box.height*.5); await page.mouse.down();
  await page.waitForTimeout(650); await page.mouse.up();
  await f.locator('#pauseButton').click();
  const twoPiles = await sample();
  check(twoPiles.count > poured.count && twoPiles.x > poured.x, 'pouring a second pile preserves existing grains');
  await page.waitForTimeout(200);
  await f.locator('#toolButton').click(); await f.locator('button[data-tool="erase"]').click();
  await f.locator('#pauseButton').click();
  await page.mouse.move(box.x+box.width*.5,box.y+box.height*.5); await page.mouse.down();
  await page.waitForTimeout(350); await page.mouse.up();
  await f.locator('#pauseButton').click();
  const erased = await sample();
  check(erased.count < twoPiles.count && erased.count > 0, 'eraser removes local grains and preserves the other pile');
  await f.locator('#toolButton').click(); await f.locator('#undoButton').click();
  check((await sample()).count === twoPiles.count, 'undo restores the particles from before the stroke');
  await f.locator('#speed').selectOption('0.25');
  check((await info()).speed === .25, 'quarter-speed control changes simulation time');
  const beforeStep = (await info()).steps;
  await f.locator('#stepButton').click();
  check((await info()).steps === beforeStep + 1 && (await info()).paused, 'single-step advances one frame and stays paused');
  await f.locator('#closeTools').click();
  await f.locator('#sand').focus(); await page.keyboard.press('3');
  check((await info()).tool === 'rake', 'keyboard tool selection chooses the five-tine rake');
  await f.locator('#materialsButton').click(); await f.locator('#clearButton').click();
  await f.locator('#sand').focus(); await page.keyboard.press('2'); await page.keyboard.press('v');
  await f.locator('#pauseButton').click();
  await page.mouse.click(box.x+450,box.y+300); await page.waitForTimeout(150);
  await f.locator('#pauseButton').click();
  check((await sample()).count > 0, 'a quick pour tap survives pointer capture release in an angled view');
  const beforeKeyPour = (await sample()).count;
  await f.locator('#pauseButton').click(); await f.locator('#sand').focus();
  await page.keyboard.down('f'); await page.waitForTimeout(500); await page.keyboard.up('f');
  await f.locator('#pauseButton').click();
  check((await sample()).count > beforeKeyPour, 'holding F pours at the pointer');
  await f.locator('#sand').focus(); await page.keyboard.press('u');
  check((await sample()).count === beforeKeyPour, 'undo restores the snapshot before keyboard pouring');
  check(await f.locator('#soundButton').isDisabled(), 'missing media keeps the tray silent and usable');
  // Inert domain objects reproduce the pinned reference harness; errors must be caught.
  f = await load('<script>window.napplet={storage:{}};<\/script>');
  await f.locator('#materialsButton').click(); await f.locator('#saveButton').click();
  await f.waitForFunction(() => document.getElementById('storageStatus').textContent.includes('not saved'));
  check(await f.locator('#saveButton').isEnabled(), 'advertised but unusable storage reports failure and recovers');
  f = await load(`<script>
    window.audioTest = { states: [], destroyed: 0 };
    window.napplet = {media: {
      createSession: async () => ({owner:'napplet',sessionId:'test-sand'}),
      onCommand: (id, fn) => {window.audioTest.command=fn;return {close(){}}},
      reportState: (id,state) => window.audioTest.states.push(state),
      destroySession: () => window.audioTest.destroyed++
    }};
  <\/script>`);
  await f.locator('#soundButton').click();
  await f.waitForFunction(() => window.sand.info().sound);
  check(await f.evaluate(() => window.audioTest.states.some(s=>s.status==='playing')), 'sound registers a napplet-owned host session');
  await f.evaluate(() => window.audioTest.command('volume', .25));
  check(await f.evaluate(() => window.audioTest.states.at(-1).volume===.25), 'host volume controls the master sound output');
  await f.evaluate(() => window.audioTest.command('pause'));
  await f.waitForFunction(() => !window.sand.info().sound);
  await f.locator('#pauseButton').click(); await page.waitForTimeout(100);
  check(!(await info()).sound, 'ordinary simulation activity does not override host audio pause');
  await f.evaluate(() => window.audioTest.command('play'));
  await f.waitForFunction(() => window.sand.info().sound);
  await f.evaluate(() => window.audioTest.command('stop'));
  await f.waitForFunction(() => !window.sand.info().sound);
  check(await f.evaluate(() => window.audioTest.states.at(-1).status==='stopped'), 'host stop silences and reports stopped audio');
  f = await load('<script>window.napplet={media:{createSession:async()=>({error:"denied"})}};<\/script>');
  await f.locator('#soundButton').click(); await page.waitForTimeout(100);
  check(!(await info()).sound, 'denied media never falls back to unregistered sound');
  check(errors.length === 0, `no uncaught app errors: ${errors.join('; ')}`);
  await mkdir(resolve('.napplet-space/tests'), { recursive: true });
  await page.screenshot({path:resolve('.napplet-space/tests/optional-storage.png')});
  console.log(`${passed} GPU/interaction checks passed`);
} finally { await browser.close(); }