import test from 'node:test'; import assert from 'node:assert/strict'; import { mountStudy } from '../lib/playground/v2/lifecycle.mjs'; import { isTimelineSwipe, SceneGestures } from '../lib/playground/v2/gestures.mjs'; import { progressForYaw, wrappedYaw, formatYaw } from '../lib/playground/v2/head-projection.mjs'; test('a silent non-seeking DOM study mounts and tears down without score or pace',()=>{ const host={textContent:'',dataset:{}};let disposals=0; const definition={title:'Quiet contours',eyebrow:'A FORM STUDY',help:['Move around the form.'],capabilities:{},create(element){element.textContent='Quiet contours';return{render(theme){element.dataset.theme=theme;},dispose(){element.textContent='';disposals++;}};}}; const instance=mountStudy(definition,host);assert.equal(instance.timeline,undefined);assert.equal(host.textContent,'Quiet contours'); instance.render('dark');assert.equal(host.dataset.theme,'dark');instance.pause();instance.dispose();instance.dispose();assert.equal(disposals,1);assert.equal(host.textContent,''); }); test('touch taps, small jitter and horizontal brushing preserve playback; a vertical swipe takes over',()=>{ const start={x:100,y:100};assert.equal(isTimelineSwipe(start,100,100),false);assert.equal(isTimelineSwipe(start,101,109),false);assert.equal(isTimelineSwipe(start,150,105),false);assert.equal(isTimelineSwipe(start,104,130),true); }); test('degree ruler uses wrapped yaw and preserves the nearest score orbit',()=>{ assert.equal(wrappedYaw(391.5),31.5);assert.equal(wrappedYaw(751.5),31.5); assert.ok(Math.abs(progressForYaw(45,391.5,.5)-(.5+13.5/720))<1e-10); assert.ok(Math.abs(progressForYaw(-175,175,.6)-(.6+10/720))<1e-10); assert.equal(progressForYaw(-90,31.5,0),0); }); import { surfaceDepth } from '../lib/playground/v2/head-projection.mjs'; test('hidden line depth selects the nearest surface, including overlapping rear geometry',()=>{ const depth=surfaceDepth([[0,0,1],[10,0,1],[0,10,1],[0,0,-1],[10,0,-1],[0,10,-1]],[[0,1,2],[3,4,5]],10,10); assert.equal(depth.at(2,2),1);assert.equal(depth.at(9,9),-Infinity); }); test('degree ruler clamps tiny endpoint key steps before selecting a distant score orbit',()=>{ assert.equal(progressForYaw(31,31.5,0,720),0); // initial ArrowLeft assert.equal(progressForYaw(32,751.5,1,720),1); // final ArrowRight assert.ok(Math.abs(progressForYaw(32,31.5,0,720)-.5/720)<1e-10); assert.ok(Math.abs(progressForYaw(31,751.5,1,720)-(1-.5/720))<1e-10); assert.ok(Math.abs(progressForYaw(-179,179,.5,720)-(.5+2/720))<1e-10); assert.ok(Math.abs(progressForYaw(179,-179,.5,720)-(.5-2/720))<1e-10); }); test('small negative yaw uses sign before padded magnitude and no negative zero',()=>{ assert.equal(formatYaw(-5),'-005°');assert.equal(formatYaw(5),'005°');assert.equal(formatYaw(-.1),'000°'); }); import { LichenField } from '../lib/playground/v2/lichen-field.mjs'; // Touch intent is unknown on down: a swipe must never sound the preliminary tap. test('scene touch waits for tap intent, suppresses vertical seek, and preserves horizontal brushing',()=>{ const hits=[];const f=Object.create(LichenField.prototype); Object.assign(f,{host:{setPointerCapture(){}},pointer:{tx:10,ty:20,targetPower:0},w:100,h:100, sound:{interact:e=>hits.push(e),endInteraction(){}},canvas:{getBoundingClientRect:()=>({left:0,top:0})},pluck(){},contact(){return 1;}}); f.gestures=new SceneGestures(e=>f.interaction(e)); f.pointerDown({pointerType:'touch',pointerId:1,clientX:10,clientY:20});assert.equal(hits.length,0); f.pointerMove({pointerType:'touch',pointerId:1,clientX:11,clientY:50});f.pointerUp({pointerType:'touch',pointerId:1});assert.equal(hits.length,0); f.pointerDown({pointerType:'touch',pointerId:1,clientX:10,clientY:20});f.pointerMove({pointerType:'touch',pointerId:1,clientX:50,clientY:21});f.pointerUp({pointerType:'touch',pointerId:1});assert.equal(hits.length,1);assert.equal(hits[0].mode,'hold'); }); // The final viewport previously lost its veil in its top third before the score ended. test('the contour veil continues through the closing score viewport',()=>{ const gradients=[],gradient={addColorStop(){}};const ctx={clearRect(){},createRadialGradient(){return gradient;},fillRect(){},createLinearGradient(x0,y0,x1,y1){gradients.push([y0,y1]);return gradient;},beginPath(){},moveTo(){},lineTo(){},stroke(){}}; const f=Object.create(LichenField.prototype);Object.assign(f,{ctx,w:100,h:100,scroll:260,time:0,reduced:true,theme:0,music:{energy:0,bass:0,air:0},pulses:[]});f.draw(); assert.ok(gradients.slice(0,76).every(([from,to])=>to>85)); }); import { projectPoint, projectSource, sourceDirection } from '../lib/playground/v2/head-projection.mjs'; import { LichenSound } from '../lib/playground/v2/lichen-sound.mjs'; test('main music coordinates match the anatomical face, listener right and shared camera at every yaw',()=>{ const vectors=[{x:0,y:0,z:-2.6},{x:2.6,y:0,z:0},{x:0,y:0,z:2.6},{x:-2.6,y:0,z:0}]; assert.deepEqual(vectors.map(sourceDirection),['Front','Right','Back','Left']); const points=vectors.map(v=>projectSource(v,0,300,180)); assert.ok(points[0][2]>0);assert.ok(points[1][0]<150);assert.ok(points[2][2]<0);assert.ok(points[3][0]>150); for(const yaw of[0,45,90,180,270,720])for(const v of vectors){const p=projectSource(v,yaw,300,180),q=projectPoint([-v.x/2.6*1.02,0,-v.z/2.6*1.02],yaw,300,180);assert.deepEqual(p,q);} }); test('spatial readout uses actual panner parameters, main bus energy and immediate silence gating',()=>{ const s=Object.create(LichenSound.prototype);Object.assign(s,{enabled:true,volume:.6,ctx:{state:'running'},master:{gain:{value:.6}},orbitTarget:20,musicPanner:{positionX:{value:1.2},positionY:{value:.15},positionZ:{value:-2}},musicTimeData:new Float32Array(4),musicAnalyser:{getFloatTimeDomainData(a){a.fill(.1);}}}); const read=s.spatialSnapshot();assert.equal(read.x,1.2);assert.equal(read.z,-2);assert.ok(read.energy>.3); for(const [key,value]of[['muted',true],['enabled',false],['volume',0]]){const old=s[key];s[key]=value;assert.equal(s.spatialSnapshot().energy,0);s[key]=old;} s.ctx.state='suspended';assert.equal(s.spatialSnapshot().energy,0); }); test('head and scene use the same relative pixel traversal; no wrapped degree seeks or absolute pointer jumps',async()=>{ const {readFile}=await import('node:fs/promises');const head=await readFile(new URL('../components/playground/v2/SpatialHead.tsx',import.meta.url),'utf8'),shell=await readFile(new URL('../components/playground/v2/StudyShell.tsx',import.meta.url),'utf8'); assert.match(head,/onMove\(e.clientX-drag.current\)/);assert.doesNotMatch(head,/progressForYaw|onSeek/);assert.match(shell,/onMove=\{pixels=>\{if\(!blockedRef.current\)instance.current\?\.timeline\?\.scroll\(pixels\);\}\}/);assert.match(shell,/t\?\.scroll\(start.lastY-event.clientY\)/); }); import { createHeadProjection, projectHead } from '../lib/playground/v2/head-projection.mjs'; test('shared camera projection matches the original transform across points and unwrapped angles',()=>{ for(const angle of[-721,-180,0,31.5,90,359,1080])for(const p of[[0,0,0],[.4,.7,.8],[-.8,-.6,.3],[.2,.1,-.9]]){ const a=angle*Math.PI/180,c=Math.cos(a),s=Math.sin(a),scale=Math.min(300/2.05,180/2.65),cp=Math.cos(.14),sp=Math.sin(.14),x=p[0]*c-p[2]*s,z=p[2]*c+p[0]*s,y=p[1]*cp+z*sp,depth=z*cp-p[1]*sp; const expected=[150+x*scale*6/(6-depth),180*.49-y*scale*6/(6-depth),depth],actual=createHeadProjection(angle,300,180).point(p); actual.forEach((v,i)=>assert.ok(Math.abs(v-expected[i])<1e-10));assert.deepEqual(projectPoint(p,angle,300,180),actual); } }); test('mesh projection computes camera trigonometry once, independent of vertex count',()=>{ const sin=Math.sin,cos=Math.cos;let calls=0;Math.sin=x=>{calls++;return sin(x);};Math.cos=x=>{calls++;return cos(x);}; try{const points=Array.from({length:1000},(_,i)=>[i/1000,.4,.2]);const projected=projectHead({curves:[],vertices:points,normals:points,edges:[]},35,300,180);assert.equal(projected.vertices.length,1000);assert.equal(calls,2);}finally{Math.sin=sin;Math.cos=cos;} });