350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
/**
|
|
* 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, Image: 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);
|