/** * Music voice test (dev tool, run with Node — no browser needed): * * node dev/music.test.mjs * * Exercises the shared music voice (js/utils/Music.js) with a stub scene: * the guard rails (master switch off, missing sound manager, asset not in * the audio cache, ONE loop per track), the loop + volume config, the * spec → cache-key rule (music_frames_3…), the SurfaceScene preload + * setSurfaceMusic seam, and the data/music.json contract (menu + every * planets.png frame entry points at a file that exists), and the * GameScene SHUFFLE playlist (gameTrackKey, start/stop, advancing when a * track ends, no repeats, guards). */ import { pathToFileURL, fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; // --- Stub just enough of Phaser for the module-level class declarations ---- // (js/vendor/phaser.js throws unless window.Phaser exists — same shim the // other Node tests use; the surface scene only needs the base classes.) const ClassStub = class {}; globalThis.window = { Phaser: { Scene: ClassStub, GameObjects: { Sprite: ClassStub, Container: ClassStub }, Physics: { Arcade: { Sprite: ClassStub } }, Geom: { Rectangle: class {} }, }, }; const __dirname = dirname(fileURLToPath(import.meta.url)); const MUSIC = JSON.parse(fs.readFileSync(join(__dirname, '../data/music.json'), 'utf8')); const LANDING = JSON.parse(fs.readFileSync(join(__dirname, '../data/landing.json'), 'utf8')); const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); const { playMusicOn, stopMusicOn, musicPlayingOn, musicKey, gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } = await import( pathToFileURL(join(__dirname, '../js/utils/Music.js')).href ); const { SurfaceScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/SurfaceScene.js')).href); const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; /** A stub scene: sound manager + audio cache, recording play/stop calls. */ const makeScene = (o = {}) => { const plays = []; const stops = []; let playing = false; return { plays, stops, sound: o.sound === false ? null : { play: (key, opts) => { plays.push({ key, opts }); playing = true; }, stopByKey: (key) => { stops.push(key); playing = false; }, isPlaying: (key) => playing && key === plays[plays.length - 1]?.key, }, cache: o.noCache ? undefined : { audio: o.noAudio ? undefined : { has: (k) => (o.inCache === false ? false : true) } }, }; }; // 1) spec → cache key: the spec flattened, music_ prefixed. { check("musicKey('music.menu') → music_menu", musicKey('music.menu') === 'music_menu'); check("musicKey('music.frames.3') → music_frames_3", musicKey('music.frames.3') === 'music_frames_3'); } // 2) Happy path: loops the music_ key at music.volume. { const scene = makeScene(); playMusicOn(scene, 'music.menu'); check('plays music_menu, looping, at music.volume', scene.plays.length === 1 && scene.plays[0].key === 'music_menu' && scene.plays[0].opts.loop === true && scene.plays[0].opts.volume === MUSIC.volume); } // 3) One loop per track: a scene restart must not stack a second copy. { const scene = makeScene(); playMusicOn(scene, 'music.menu'); playMusicOn(scene, 'music.menu'); check('a second start (scene restart) does not double the loop', scene.plays.length === 1); } // 4) Master switch off → silent, no throw. { const scene = makeScene(); config.init({ music: { ...MUSIC, enabled: false }, landing: { ...LANDING } }); playMusicOn(scene, 'music.menu'); check('music.enabled=false → nothing plays', scene.plays.length === 0); config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); } // 5) No sound manager (headless) → silent, no throw. { const scene = makeScene({ sound: false }); playMusicOn(scene, 'music.menu'); check('no sound manager → nothing plays', scene.plays.length === 0); } // 6) Asset not in the audio cache → silent, no throw (v4 play() would throw). { const scene = makeScene({ inCache: false }); playMusicOn(scene, 'music.frames.3'); check('asset missing from cache → nothing plays', scene.plays.length === 0); } // 7) stopMusicOn stops the music_ key (v4 has no sound.stop()); no-throw without a manager. { const scene = makeScene(); playMusicOn(scene, 'music.frames.1'); stopMusicOn(scene, 'music.frames.1'); check('stopMusicOn stops the music_ key', scene.stops.length === 1 && scene.stops[0] === 'music_frames_1'); const noMgr = makeScene({ sound: false }); let threw = false; try { stopMusicOn(noMgr, 'music.menu'); } catch { threw = true; } check('stopMusicOn with no sound manager → no throw', !threw); } // 8) musicPlayingOn mirrors the manager's isPlaying (false when unsure). { const scene = makeScene(); check('musicPlayingOn is false before anything plays', musicPlayingOn(scene, 'music.menu') === false); playMusicOn(scene, 'music.menu'); check('musicPlayingOn is true while it plays', musicPlayingOn(scene, 'music.menu') === true); stopMusicOn(scene, 'music.menu'); check('musicPlayingOn is false after stop', musicPlayingOn(scene, 'music.menu') === false); } // 9) data/music.json contract: the menu + every planets.png frame (0..5) // point at files that exist. { const ok = typeof MUSIC.menu === 'string' && fs.existsSync(join(__dirname, '..', MUSIC.menu)) && [0, 1, 2, 3, 4, 5].every((f) => { const p = MUSIC.frames?.[String(f)]; return typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)); }); check('music.json: menu + frames 0..5 point at files that exist', ok); } // 10) SurfaceScene.preload: the world's track, queued under the derived key. { const s = Object.create(SurfaceScene.prototype); s.planetFrame = 1; s.musicSpec = 'music.frames.1'; s.queued = []; s.load = { audio: (k, u) => s.queued.push([k, u]), video: () => {} }; SurfaceScene.prototype.preload.call(s); check('preload queues the frame\'s track under the derived key', s.queued.some(([k, u]) => k === 'music_frames_1' && u === MUSIC.frames['1'])); // A frame with no track is silent (nothing queued, no crash). const s2 = Object.create(SurfaceScene.prototype); s2.planetFrame = 9; // no such frame s2.musicSpec = 'music.frames.9'; s2.queued = []; s2.load = { audio: (k, u) => s2.queued.push([k, u]), video: () => {} }; SurfaceScene.prototype.preload.call(s2); check('a frame with no track queues no music', s2.queued.length === 0); } // 11) SurfaceScene.setSurfaceMusic: one loop across re-fires, stops on demand. { let playing = false; const plays = []; const stops = []; const scene = { musicSpec: 'music.frames.3', plays, stops, sound: { play: (key, opts) => { plays.push({ key, opts }); playing = true; }, isPlaying: (key) => playing && key === 'music_frames_3', stopByKey: (key) => { stops.push(key); playing = false; }, }, cache: { audio: { has: () => true } }, }; SurfaceScene.prototype.setSurfaceMusic.call(scene, true); check('setSurfaceMusic(true) starts the world\'s loop (looping, at music.volume)', plays.length === 1 && plays[0].key === 'music_frames_3' && plays[0].opts.loop === true && plays[0].opts.volume === MUSIC.volume); SurfaceScene.prototype.setSurfaceMusic.call(scene, true); check('... a re-fire (scene restart) keeps ONE loop', plays.length === 1); SurfaceScene.prototype.setSurfaceMusic.call(scene, false); check('setSurfaceMusic(false) stops the loop', stops.length === 1 && stops[0] === 'music_frames_3' && !playing); } // 12) gameTrackKey: the playlist FILENAME → the cache key it queues under. { check("gameTrackKey('assets/music/deepspace-01.mp3') → music_deepspace_01", gameTrackKey('assets/music/deepspace-01.mp3') === 'music_deepspace_01'); check("gameTrackKey('terran-02.mp3') → music_terran_02", gameTrackKey('terran-02.mp3') === 'music_terran_02'); } // 13) data/music.json contract: `game` is a list, pointing at real files. { const ok = Array.isArray(MUSIC.game) && MUSIC.game.length >= 1 && MUSIC.game.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p))); check('music.json: game is a non-empty list of files that exist', ok); } /** A shuffle-capable stub scene: sound manager + cache + a time.addEvent * that records the tick so the test can fire it by hand. */ const makeShuffleScene = (o = {}) => { let playingKey = null; const plays = []; const stops = []; const events = []; const scene = { plays, stops, sound: o.sound === false ? null : { play: (key, opts) => { plays.push({ key, opts }); playingKey = key; }, stopByKey: (key) => { stops.push(key); if (playingKey === key) playingKey = null; }, isPlaying: (key) => playingKey === key, }, cache: { audio: { has: (k) => (o.missing ? !o.missing.includes(k) : true) } }, time: { addEvent: (cfg) => { const ev = { cfg, remove() { const i = events.indexOf(ev); if (i > -1) events.splice(i, 1); } }; events.push(ev); return ev; } }, }; scene.tick = () => events.slice().forEach((ev) => ev.cfg.callback()); return scene; }; // 14) The shuffle itself: one track at a time, advancing when it ends, // never the same track twice in a row (2 tracks → deterministic). { const [fa, fb] = MUSIC.game; const ka = gameTrackKey(fa); const kb = gameTrackKey(fb); const scene = makeShuffleScene(); startMusicShuffleOn(scene, MUSIC.game); check('starts exactly ONE track', scene.plays.length === 1); check('... and it plays loop:false at music.volume', (scene.plays[0].key === ka || scene.plays[0].key === kb) && scene.plays[0].opts.loop === false && scene.plays[0].opts.volume === MUSIC.volume); const first = scene.plays[0].key; const firstFile = first === ka ? fa : fb; const second = first === ka ? kb : ka; const secondFile = first === ka ? fb : fa; // The track plays out (natural end) → the tick advances to the OTHER one. scene.sound.stopByKey(first); scene.tick(); check('when a track ends, the other track starts (no repeat)', scene.plays.length === 2 && scene.plays[1].key === second); // ...and the alternation keeps going. scene.sound.stopByKey(second); scene.tick(); check('... and the first track comes back on the next pass', scene.plays.length === 3 && scene.plays[2].key === first); // While a track is still playing, the tick does nothing. scene.tick(); check('a tick while a track is still playing plays nothing new', scene.plays.length === 3); // Stopping the shuffle kills the current track AND the timer. stopMusicShuffleOn(scene); const playsAtStop = scene.plays.length; check('stopMusicShuffleOn stops the current track', scene.stops[scene.stops.length - 1] === first); scene.sound.stopByKey(first); // simulate the next natural end scene.tick(); check('... and its progression timer is gone (no more track picks)', scene.plays.length === playsAtStop); } // 15) Re-firing the shuffle (a scene restart) must not stack tracks. { const scene = makeShuffleScene(); startMusicShuffleOn(scene, MUSIC.game); const first = scene.plays[0].key; startMusicShuffleOn(scene, MUSIC.game); // restart check('a restart stops the old track and starts ONE new one', scene.stops.includes(first) && scene.plays.length === 2); check('... and only one track is playing after the restart', [gameTrackKey(MUSIC.game[0]), gameTrackKey(MUSIC.game[1])] .filter((k) => scene.sound.isPlaying(k)).length === 1); } // 16) Shuffle guard rails: master switch, missing cache, no sound manager. { config.init({ music: { ...MUSIC, enabled: false }, landing: { ...LANDING } }); const off = makeShuffleScene(); startMusicShuffleOn(off, MUSIC.game); check('music.enabled=false → the shuffle stays silent', off.plays.length === 0); config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); const [fa, fb] = MUSIC.game; const missing = makeShuffleScene({ missing: [gameTrackKey(fa), gameTrackKey(fb)] }); startMusicShuffleOn(missing, MUSIC.game); check('no usable track in the cache → silent, no throw', missing.plays.length === 0); const noMgr = makeShuffleScene({ sound: false }); let threw = false; try { startMusicShuffleOn(noMgr, MUSIC.game); } catch { threw = true; } check('no sound manager → silent, no throw', !threw && noMgr.plays.length === 0); } // 17) GameScene.preload queues every game-track file under its derived key. { const s = Object.create(GameScene.prototype); s.queued = []; s.load = { audio: (k, u) => s.queued.push([k, u]), spritesheet: () => {} }; GameScene.prototype.preload.call(s); const ok = MUSIC.game.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f)); check('preload queues each music.game file under its derived key', ok); } console.log(failures === 0 ? '\nAll music voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);