import assert from 'node:assert/strict'; import {test} from 'node:test'; import {loadTypescript} from './support/load-typescript.mjs'; class El extends EventTarget {open=false;inert=false;hidden=false;textContent='';focus(){}show(){this.open=true;}close(){this.open=false;this.dispatchEvent(new Event('close'));}setAttribute(){}removeAttribute(){}getBoundingClientRect(){return {width:100,height:44};}closest(){return this.hidden?this:null;}fire(t){this.dispatchEvent(new Event(t,{cancelable:true}));}} async function harness(mobile=false,saved={},throws=false){ const {initFirstGuide}=await loadTypescript(new URL('../src/ui/FirstGuide.ts',import.meta.url)); const ids=['first-guide','experience','guide-scene-target','door-toggle','gyro','gyro-recenter'];const els=Object.fromEntries(ids.map(id=>[id,new El()]));let eligible=true;const values=new Map(Object.entries(saved)),writes=[],tours=[]; const view=new El();Object.defineProperty(view,'localStorage',{get(){if(throws)throw Error('blocked');return {getItem:k=>values.get(k)??null,setItem:(k,v)=>{writes.push(k);values.set(k,v);}};}});view.matchMedia=()=>({matches:true});view.visualViewport=new El(); const root={querySelector:s=>els[s.slice(1)],defaultView:view}; const factory=config=>{let index=0,active=false;const tour={config,drive(){active=true;},isActive:()=>active,isLastStep:()=>index===config.steps.length-1,moveNext(){index++;},getActiveIndex:()=>index,refresh(){tour.refreshed=true;},destroy(){if(!active)return;active=false;config.onDestroyed?.();}};tours.push(tour);return tour;}; const abort=new AbortController();const guide=initFirstGuide(root,abort.signal,mobile,()=>eligible,()=>{},factory); return {els,guide,abort,writes,tours,view,values,setEligible:v=>eligible=v}; } test('mobile driver tour highlights actual controls, blocks actions, and finishes once',async()=>{ const h=await harness(true);h.guide.start();const t=h.tours[0];assert.equal(h.els['first-guide'].open,true);assert.equal(h.els.experience.inert,true); assert.deepEqual(t.config.steps.map(s=>s.element()),[h.els['guide-scene-target'],h.els['door-toggle'],h.els.gyro]); assert.equal(t.config.disableActiveInteraction,true);assert.equal(t.config.overlayClickBehavior,'none');assert.equal(t.config.animate,false); t.config.onNextClick();t.config.onNextClick();assert.equal(h.writes.length,0);t.config.onNextClick();assert.equal(h.writes.length,1);assert.equal(h.els.experience.inert,false);h.guide.start();assert.equal(h.tours.length,1); }); test('desktop has two steps; previous completion suppresses auto tour but replay works',async()=>{ const h=await harness(false,{'behind-glass.first-guide.v1':'1'});h.guide.start();assert.equal(h.tours.length,0);h.guide.start(true);assert.equal(h.tours[0].config.steps.length,2);h.tours[0].config.onCloseClick();assert.equal(h.els['first-guide'].open,false); }); test('external close and abort do not save completion; explicit Escape does',async()=>{ const h=await harness();h.setEligible(false);h.guide.start();assert.equal(h.tours.length,0);h.setEligible(true);h.guide.start();h.els['first-guide'].close();assert.equal(h.writes.length,0);assert.equal(h.els.experience.inert,false); h.guide.start();h.tours[1].config.onDestroyStarted();assert.equal(h.writes.length,1); const a=await harness();a.guide.start();a.abort.abort();assert.equal(a.els['first-guide'].open,false);assert.equal(a.writes.length,0);a.guide.start();assert.equal(a.tours.length,1); }); test('recenter hint is contextual, mobile-only and independently once-per-browser',async()=>{ const h=await harness(true,{'behind-glass.first-guide.v1':'1'});h.els['gyro-recenter'].hidden=true;h.guide.recenter();assert.equal(h.tours.length,0);h.els['gyro-recenter'].hidden=false;h.guide.recenter();assert.equal(h.tours[0].config.steps.length,1);h.tours[0].config.onNextClick();assert.ok(h.values.has('behind-glass.recenter-guide.v1'));h.guide.recenter();assert.equal(h.tours.length,1); const pc=await harness(false,{'behind-glass.first-guide.v1':'1'});pc.guide.recenter();assert.equal(pc.tours.length,0); }); test('storage denial is session-only, missing targets resolve undefined, viewport changes refresh active tour',async()=>{ const h=await harness(true,{},true);h.guide.start();h.els['door-toggle'].hidden=true;assert.equal(h.tours[0].config.steps[1].element(),undefined);h.view.visualViewport.fire('resize');assert.equal(h.tours[0].refreshed,true);h.tours[0].config.onCloseClick();h.guide.start();assert.equal(h.tours.length,1); }); test('cleanup still runs when Driver destroys before its first RAF without onDestroyed',async()=>{ const h=await harness();h.guide.start();h.tours[0].destroy=()=>{};h.abort.abort();assert.equal(h.els.experience.inert,false);assert.equal(h.els['first-guide'].open,false);assert.equal(h.writes.length,0); }); test('queued close from prior run cannot cancel a newer replay',async()=>{ const h=await harness();h.guide.start();h.els['first-guide'].close();h.guide.start(true);h.els['first-guide'].fire('close');assert.equal(h.els['first-guide'].open,true);assert.equal(h.els.experience.inert,true); }); test('spotlight popover keeps arrows outside scroll clipping and colors the correct edge',async()=>{ const {readFile}=await import('node:fs/promises');const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8'); assert.match(css,/\.behind-glass-tour\s*\{[^}]*overflow:visible/); assert.match(css,/arrow-side-top\s*\{\s*border-top-color:var\(--cream\)/); assert.match(css,/arrow-side-bottom\s*\{\s*border-bottom-color:var\(--cream\)/); });