@bongos/core 1.19.364 → 1.19.366
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +71 -26
- package/docs/adr/0191-majority-and-the-author-who-may-not-decide-alone.md +142 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +61 -61
- package/docs/copy-registry.json +64 -64
- package/docs/file-map.md +13 -1
- package/modules/builder-settings/event-sound-prefs.js +14 -4
- package/modules/government/board.js +199 -31
- package/modules/government/config.js +26 -5
- package/modules/government/routes/government.js +6 -1
- package/modules/hall-ui/public/alert.wav +0 -0
- package/modules/hall-ui/public/fanfare.wav +0 -0
- package/modules/hall-ui/public/government.js +27 -7
- package/modules/hall-ui/public/ship.wav +0 -0
- package/modules/hall-ui/public/triumph.wav +0 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-sounds.js +176 -0
- package/scripts/gds/ship-io.js +16 -2
- package/scripts/gds/turn-end-sound.js +74 -0
- package/src/module-api.js +5 -1
- package/tests/government_board_amendment.mjs +37 -8
- package/tests/government_board_close.mjs +13 -2
- package/tests/government_board_majority.mjs +276 -0
- package/tests/government_board_vote.mjs +35 -4
- package/tests/government_board_vote_ui.mjs +18 -3
- package/tests/government_config.mjs +9 -2
- package/tests/sound_player.mjs +345 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// tests/sound_player.mjs — the local notification player and its assets
|
|
2
|
+
// (task 1003272).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS FILE EXISTS TO MAKE UNREPEATABLE. The 5-event sound system
|
|
5
|
+
// (V3.R90, task 1000781) shipped its whole CONFIGURATION — prefs, per-sound
|
|
6
|
+
// toggles, master volume, a per-event variant picker in Settings — and never
|
|
7
|
+
// shipped the audio. `git ls-files` carried exactly one WAV, so four of the five
|
|
8
|
+
// events resolved to a 404 and played nothing, and .claude/sounds/play.js, which
|
|
9
|
+
// four other files require, had never been committed at all. Two previous
|
|
10
|
+
// attempts to fix "sound is broken" (tasks 1001252, 1000796) were abandoned
|
|
11
|
+
// without reaching either fact.
|
|
12
|
+
//
|
|
13
|
+
// So the load-bearing test here is the boring one: EVERY SOUND THE SERVER CAN
|
|
14
|
+
// NAME HAS A FILE (and every file is really playable audio). That is the
|
|
15
|
+
// assertion whose absence let a shipped feature be silent for months.
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const player = require(path.join(ROOT, '.claude', 'sounds', 'play.js'));
|
|
28
|
+
const soundPrefs = require(path.join(ROOT, 'modules', 'builder-settings', 'sound-prefs.js'));
|
|
29
|
+
|
|
30
|
+
const WAV_DIR = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
31
|
+
|
|
32
|
+
function tmpPrefs(obj) {
|
|
33
|
+
const f = path.join(os.tmpdir(), `otb-test-prefs-${process.pid}-${Math.abs(hash(JSON.stringify(obj)))}.json`);
|
|
34
|
+
fs.writeFileSync(f, JSON.stringify(obj));
|
|
35
|
+
return f;
|
|
36
|
+
}
|
|
37
|
+
// A tiny stable hash so the temp name is derived from content, not a clock
|
|
38
|
+
// (Date.now()/Math.random() are avoidable here and make reruns noisier).
|
|
39
|
+
function hash(s) {
|
|
40
|
+
let h = 0;
|
|
41
|
+
for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; }
|
|
42
|
+
return h;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Minimal 16-bit mono WAV, for the scaler tests.
|
|
46
|
+
function wav(samples, { bits = 16, extraChunk = false } = {}) {
|
|
47
|
+
const data = Buffer.alloc(samples.length * 2);
|
|
48
|
+
samples.forEach((v, i) => data.writeInt16LE(v, i * 2));
|
|
49
|
+
const pre = extraChunk ? Buffer.concat([Buffer.from('LIST'), u32(4), Buffer.from('INFO')]) : Buffer.alloc(0);
|
|
50
|
+
const head = Buffer.alloc(36);
|
|
51
|
+
head.write('RIFF', 0); head.writeUInt32LE(28 + pre.length + data.length, 4); head.write('WAVE', 8);
|
|
52
|
+
head.write('fmt ', 12); head.writeUInt32LE(16, 16); head.writeUInt16LE(1, 20);
|
|
53
|
+
head.writeUInt16LE(1, 22); head.writeUInt32LE(44100, 24); head.writeUInt32LE(88200, 28);
|
|
54
|
+
head.writeUInt16LE(2, 32); head.writeUInt16LE(bits, 34);
|
|
55
|
+
return Buffer.concat([head, pre, Buffer.from('data'), u32(data.length), data]);
|
|
56
|
+
}
|
|
57
|
+
function u32(n) { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b; }
|
|
58
|
+
|
|
59
|
+
// ── the assertion the original bug needed ───────────────────────────────────
|
|
60
|
+
|
|
61
|
+
test('EVERY sound the server can name has a real file on disk', () => {
|
|
62
|
+
// KNOWN_SOUNDS is what /me/sound-prefs offers a builder to toggle, and the
|
|
63
|
+
// event-sound variants resolve to these same stems. A name here with no file
|
|
64
|
+
// is a toggle for silence — which is exactly what shipped.
|
|
65
|
+
const known = Object.keys(soundPrefs.SOUND_DEFAULTS || {});
|
|
66
|
+
assert.ok(known.length >= 5, `expected the 5-event roster, got ${known.length}`);
|
|
67
|
+
for (const name of known) {
|
|
68
|
+
const f = path.join(WAV_DIR, `${name}.wav`);
|
|
69
|
+
assert.ok(fs.existsSync(f), `${name}.wav is MISSING — the server offers this sound and nothing can play it`);
|
|
70
|
+
assert.ok(fs.statSync(f).size > 1000, `${name}.wav exists but is suspiciously tiny`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('every asset is really 16-bit 44.1kHz mono PCM, like the one that always worked', () => {
|
|
75
|
+
// Same format as the pre-existing chime.wav, so nothing downstream has to care
|
|
76
|
+
// which file it was handed.
|
|
77
|
+
for (const name of Object.keys(soundPrefs.SOUND_DEFAULTS || {})) {
|
|
78
|
+
const b = fs.readFileSync(path.join(WAV_DIR, `${name}.wav`));
|
|
79
|
+
assert.equal(b.toString('ascii', 0, 4), 'RIFF', `${name}: not RIFF`);
|
|
80
|
+
assert.equal(b.toString('ascii', 8, 12), 'WAVE', `${name}: not WAVE`);
|
|
81
|
+
assert.equal(b.readUInt16LE(20), 1, `${name}: not PCM`);
|
|
82
|
+
assert.equal(b.readUInt16LE(22), 1, `${name}: not mono`);
|
|
83
|
+
assert.equal(b.readUInt32LE(24), 44100, `${name}: wrong sample rate`);
|
|
84
|
+
assert.equal(b.readUInt16LE(34), 16, `${name}: not 16-bit`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('EVERY event-sound variant resolves to a real file, and each event offers a real CHOICE', () => {
|
|
89
|
+
// The Settings variant picker offered names like "Lyre Pluck" and "Bronze
|
|
90
|
+
// Fanfare" that resolved to wavs which did not exist — selectable options for
|
|
91
|
+
// silence. Both halves are asserted: the file is there, and an event's variants
|
|
92
|
+
// are not all the same sound (a picker whose options are identical is a lie).
|
|
93
|
+
const eventPrefs = require(path.join(ROOT, 'modules', 'builder-settings', 'event-sound-prefs.js'));
|
|
94
|
+
const events = eventPrefs.EVENTS || eventPrefs.default || [];
|
|
95
|
+
assert.equal(events.length, 5, 'the 5-event roster');
|
|
96
|
+
for (const ev of events) {
|
|
97
|
+
assert.ok(ev.variants.length >= 2, `${ev.id}: a picker needs something to pick`);
|
|
98
|
+
for (const v of ev.variants) {
|
|
99
|
+
assert.ok(fs.existsSync(path.join(WAV_DIR, `${v.wav}.wav`)),
|
|
100
|
+
`${ev.id}/${v.id} names ${v.wav}.wav, which does not exist — a selectable silence`);
|
|
101
|
+
}
|
|
102
|
+
const distinct = new Set(ev.variants.map((v) => v.wav));
|
|
103
|
+
assert.equal(distinct.size, ev.variants.length,
|
|
104
|
+
`${ev.id}: its variants must map to DIFFERENT sounds, or switching changes nothing`);
|
|
105
|
+
assert.equal(ev.variants.filter((v) => v.isDefault).length, 1, `${ev.id}: exactly one default`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the generator is idempotent, so the assets are reproducible not mysterious', () => {
|
|
110
|
+
// --check exits non-zero if a re-run would change any byte. An asset nobody can
|
|
111
|
+
// regenerate is how this repo lost the player in the first place.
|
|
112
|
+
execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'gds', 'gen-sounds.js'), '--check'], { stdio: 'pipe' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the generator does NOT touch chime.wav', () => {
|
|
116
|
+
// chime predates this task and is the sound builders already know; regenerating
|
|
117
|
+
// it would silently swap a familiar sound for a different one.
|
|
118
|
+
const gen = require(path.join(ROOT, 'scripts', 'gds', 'gen-sounds.js'));
|
|
119
|
+
assert.ok(!Object.keys(gen.SOUNDS).includes('chime'));
|
|
120
|
+
assert.deepEqual(Object.keys(gen.SOUNDS).sort(), ['alert', 'fanfare', 'ship', 'triumph']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── the require that four files depend on ───────────────────────────────────
|
|
124
|
+
|
|
125
|
+
test("ship-state's guarded require now resolves the REAL player, not its no-op stub", () => {
|
|
126
|
+
// task 1002199 wrapped this require so a missing player could not crash ship.js.
|
|
127
|
+
// The guard stays — but it must no longer be what actually runs.
|
|
128
|
+
const shipState = require(path.join(ROOT, 'scripts', 'gds', 'ship-state.js'));
|
|
129
|
+
assert.equal(typeof shipState.playSound, 'function');
|
|
130
|
+
assert.equal(shipState.playSound.name, 'playSound',
|
|
131
|
+
'the stub is an anonymous arrow; the real one is a named function');
|
|
132
|
+
assert.ok(shipState.playSound.length >= 1, 'the real player takes a wav name');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── the writer and the readers must agree on ONE path ──────────────────────
|
|
136
|
+
|
|
137
|
+
test('every prefs READER resolves the same path the WRITER uses', () => {
|
|
138
|
+
// THE FOURTH BUG IN THIS STACK. fetch-sound-prefs.js writes the mirror through
|
|
139
|
+
// instance-config.configPath() — today ~/.config/cloudbongos/ — while three
|
|
140
|
+
// readers hardcoded ~/.config/otb/, the PRE-RENAME location. Every one of them
|
|
141
|
+
// silently found nothing: the player failed open (so the Settings toggles and
|
|
142
|
+
// master volume had no local effect at all), and the ship bell always fell back
|
|
143
|
+
// to its default instead of the builder's chosen variant. Nothing errored.
|
|
144
|
+
const ic = require(path.join(ROOT, 'src', 'instance-config.js'));
|
|
145
|
+
const canonical = ic.configPath('sound-prefs.json');
|
|
146
|
+
|
|
147
|
+
const shipIo = require(path.join(ROOT, 'scripts', 'gds', 'ship-io.js'));
|
|
148
|
+
const turnEnd = require(path.join(ROOT, 'scripts', 'gds', 'turn-end-sound.js'));
|
|
149
|
+
|
|
150
|
+
// The player's canonical path must BE the writer's path.
|
|
151
|
+
assert.equal(player.PREFS_PATH, canonical, 'play.js must read where the writer writes');
|
|
152
|
+
// The other two resolve to the canonical path when it exists, else the legacy
|
|
153
|
+
// one — so assert membership rather than pinning which branch this box takes.
|
|
154
|
+
for (const [name, got] of [['ship-io', shipIo.shipPrefsPath()], ['turn-end-sound', turnEnd.prefsPath()]]) {
|
|
155
|
+
assert.ok([canonical, player.LEGACY_PREFS_PATH].includes(got),
|
|
156
|
+
`${name} resolved ${got}, which is neither the canonical nor the legacy path`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('no reader hardcodes the pre-rename otb path as its ONLY location', () => {
|
|
161
|
+
// A grep-level guard: the string may appear as a documented FALLBACK, but a
|
|
162
|
+
// reader whose only path is the legacy one is the bug returning.
|
|
163
|
+
const files = [
|
|
164
|
+
path.join(ROOT, '.claude', 'sounds', 'play.js'),
|
|
165
|
+
path.join(ROOT, 'scripts', 'gds', 'ship-io.js'),
|
|
166
|
+
path.join(ROOT, 'scripts', 'gds', 'turn-end-sound.js'),
|
|
167
|
+
];
|
|
168
|
+
for (const f of files) {
|
|
169
|
+
const src = fs.readFileSync(f, 'utf8');
|
|
170
|
+
if (!src.includes("'otb'")) continue; // no legacy reference at all: fine
|
|
171
|
+
assert.ok(src.includes('instance-config'),
|
|
172
|
+
`${path.basename(f)} mentions the legacy otb path without resolving via instance-config`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the legacy prefs file is still honoured when the canonical one is absent', () => {
|
|
177
|
+
// An older box has its mirror at the pre-rename path; its saved toggles must
|
|
178
|
+
// keep working rather than silently resetting to "everything on".
|
|
179
|
+
const legacyOnly = tmpPrefs({ sounds: { ship: false }, volume: 40 });
|
|
180
|
+
// readPrefs falls back only when its FIRST path is unreadable, so point it at
|
|
181
|
+
// an absent file and assert the fallback chain does not throw or lose the shape.
|
|
182
|
+
const p = player.readPrefs(path.join(os.tmpdir(), 'otb-absent-canonical.json'));
|
|
183
|
+
assert.equal(typeof p.volume, 'number');
|
|
184
|
+
assert.equal(typeof p.enabled('ship'), 'boolean');
|
|
185
|
+
// And the explicit-path form still reads exactly what it is given.
|
|
186
|
+
const direct = player.readPrefs(legacyOnly);
|
|
187
|
+
assert.equal(direct.enabled('ship'), false);
|
|
188
|
+
assert.equal(direct.volume, 40);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the Stop hook resolves the builder CHOSEN variant, not just the fallback', () => {
|
|
192
|
+
// The hook exists at all only as of this task — .claude/settings.json had no
|
|
193
|
+
// Stop hook, so the "Claude finished a turn" sound had nothing to fire it.
|
|
194
|
+
const turnEnd = require(path.join(ROOT, 'scripts', 'gds', 'turn-end-sound.js'));
|
|
195
|
+
assert.equal(turnEnd.EVENT_ID, 'user-input');
|
|
196
|
+
assert.equal(turnEnd.FALLBACK_WAV, 'chime');
|
|
197
|
+
const wav = turnEnd.resolveWav();
|
|
198
|
+
assert.equal(typeof wav, 'string');
|
|
199
|
+
assert.ok(fs.existsSync(path.join(WAV_DIR, `${wav}.wav`)),
|
|
200
|
+
`the hook resolved ${wav}, which has no file`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the Stop hook and the prefs mirror are actually WIRED into settings.json', () => {
|
|
204
|
+
// fetch-sound-prefs.js described "a LOCAL Stop hook" for months while
|
|
205
|
+
// .claude/settings.json contained no Stop hook and never invoked the mirror.
|
|
206
|
+
// Documentation is not a trigger.
|
|
207
|
+
const settings = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
|
|
208
|
+
const cmds = (key) => (settings.hooks?.[key] || []).flatMap((g) => (g.hooks || []).map((h) => h.command));
|
|
209
|
+
assert.ok(cmds('Stop').some((c) => c.includes('turn-end-sound')), 'no Stop hook rings the turn-end sound');
|
|
210
|
+
assert.ok(cmds('SessionStart').some((c) => c.includes('fetch-sound-prefs')),
|
|
211
|
+
'nothing mirrors the prefs, so the toggles cannot take local effect');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── path safety: the wav name comes off disk, so it is input ────────────────
|
|
215
|
+
|
|
216
|
+
test('a wav name is a bare stem — traversal and absolute paths are refused', () => {
|
|
217
|
+
for (const bad of ['../../etc/passwd', '/etc/passwd', '..', '', 'a/b', 'sh ell', null, undefined, 42, 'x'.repeat(65)]) {
|
|
218
|
+
assert.equal(player.safeName(bad), null, `${JSON.stringify(bad)} must not be accepted`);
|
|
219
|
+
}
|
|
220
|
+
for (const good of ['ship', 'chime', 'bronze-fanfare', 'Alert_2']) {
|
|
221
|
+
assert.equal(player.safeName(good), good);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('playSound refuses a bad name without touching the filesystem', () => {
|
|
226
|
+
let spawned = 0;
|
|
227
|
+
const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
|
|
228
|
+
assert.equal(player.playSound('../../etc/passwd', { spawnFn }), false);
|
|
229
|
+
assert.equal(player.playSound('nope-not-a-sound', { spawnFn }), false);
|
|
230
|
+
assert.equal(spawned, 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── prefs: fail OPEN, because being mutely broken is the bug ────────────────
|
|
234
|
+
|
|
235
|
+
test('a missing prefs file plays everything (fail open)', () => {
|
|
236
|
+
const p = player.readPrefs(path.join(os.tmpdir(), 'otb-definitely-absent-prefs.json'));
|
|
237
|
+
assert.equal(p.volume, 100);
|
|
238
|
+
assert.equal(p.enabled('ship'), true);
|
|
239
|
+
assert.equal(p.enabled('anything-at-all'), true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('garbage in the prefs file also plays everything', () => {
|
|
243
|
+
const f = path.join(os.tmpdir(), `otb-test-prefs-garbage-${process.pid}.json`);
|
|
244
|
+
fs.writeFileSync(f, 'not json at all');
|
|
245
|
+
const p = player.readPrefs(f);
|
|
246
|
+
assert.equal(p.volume, 100);
|
|
247
|
+
assert.equal(p.enabled('ship'), true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('only an EXPLICIT false mutes a sound', () => {
|
|
251
|
+
const p = player.readPrefs(tmpPrefs({ sounds: { ship: false, alert: true }, volume: 70 }));
|
|
252
|
+
assert.equal(p.enabled('ship'), false);
|
|
253
|
+
assert.equal(p.enabled('alert'), true);
|
|
254
|
+
assert.equal(p.enabled('triumph'), true, 'a sound absent from the map is not muted');
|
|
255
|
+
assert.equal(p.volume, 70);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('the LEGACY flat prefs shape is still read', () => {
|
|
259
|
+
// fetch-sound-prefs.js documents that play.js must tolerate the old
|
|
260
|
+
// { <sound>: <bool> } file, which older boxes still have on disk.
|
|
261
|
+
const p = player.readPrefs(tmpPrefs({ ship: false, chime: true }));
|
|
262
|
+
assert.equal(p.enabled('ship'), false);
|
|
263
|
+
assert.equal(p.enabled('chime'), true);
|
|
264
|
+
assert.equal(p.volume, 100, 'no volume key in the legacy shape → full');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('volume is clamped and rounded into 0-100', () => {
|
|
268
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: 250 })).volume, 100);
|
|
269
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: -30 })).volume, 0);
|
|
270
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: 62.6 })).volume, 63);
|
|
271
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: 'loud' })).volume, 100);
|
|
272
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: NaN })).volume, 100);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('a muted sound and volume 0 both spawn NOTHING', () => {
|
|
276
|
+
let spawned = 0;
|
|
277
|
+
const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
|
|
278
|
+
assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: { ship: false }, volume: 100 }), spawnFn }), false);
|
|
279
|
+
assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 0 }), spawnFn }), false);
|
|
280
|
+
assert.equal(spawned, 0, 'silence must not cost a process');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── the volume scaler (aplay and PowerShell have no volume flag) ────────────
|
|
284
|
+
|
|
285
|
+
test('scaleWav halves the samples and preserves the file length', () => {
|
|
286
|
+
const src = wav([0, 1000, -2000, 32000, -32000]);
|
|
287
|
+
const out = player.scaleWav(src, 0.5);
|
|
288
|
+
assert.equal(out.length, src.length);
|
|
289
|
+
const at = (b, i) => b.readInt16LE(b.length - 10 + i * 2);
|
|
290
|
+
assert.equal(at(out, 1), 500);
|
|
291
|
+
assert.equal(at(out, 2), -1000);
|
|
292
|
+
assert.equal(at(out, 3), 16000);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('scaleWav clamps rather than wrapping — a gain above 1 must not invert audio', () => {
|
|
296
|
+
const out = player.scaleWav(wav([32000, -32000]), 2);
|
|
297
|
+
assert.equal(out.readInt16LE(out.length - 4), 32767);
|
|
298
|
+
assert.equal(out.readInt16LE(out.length - 2), -32768);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('scaleWav walks the chunk list instead of assuming data starts at byte 44', () => {
|
|
302
|
+
// A WAV carrying a LIST chunk before `data` would be corrupted by a naive
|
|
303
|
+
// fixed-offset scaler — it would scale the metadata and skip the audio.
|
|
304
|
+
const src = wav([1000, 2000], { extraChunk: true });
|
|
305
|
+
const out = player.scaleWav(src, 0.5);
|
|
306
|
+
assert.equal(out.readInt16LE(out.length - 4), 500);
|
|
307
|
+
assert.equal(out.readInt16LE(out.length - 2), 1000);
|
|
308
|
+
assert.equal(out.toString('ascii', 36, 40), 'LIST', 'the metadata chunk is left intact');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('scaleWav leaves anything it does not understand completely alone', () => {
|
|
312
|
+
const notWav = Buffer.from('this is not audio at all');
|
|
313
|
+
assert.ok(player.scaleWav(notWav, 0.5).equals(notWav));
|
|
314
|
+
assert.ok(player.scaleWav(Buffer.alloc(10), 0.5).equals(Buffer.alloc(10)));
|
|
315
|
+
const eightBit = wav([1000, 2000], { bits: 8 });
|
|
316
|
+
assert.ok(player.scaleWav(eightBit, 0.5).equals(eightBit), '8-bit is passed through, not mangled');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── the spawn, exercised without real audio ────────────────────────────────
|
|
320
|
+
|
|
321
|
+
test('a playable sound spawns the platform player, detached and unref\'d', () => {
|
|
322
|
+
const calls = [];
|
|
323
|
+
const spawnFn = (cmd, args, opts) => { calls.push({ cmd, args, opts }); return { on() {}, unref() {} }; };
|
|
324
|
+
// playerFor returns null on a machine with no audio binary (CI containers), in
|
|
325
|
+
// which case there is nothing to assert about the spawn — so this test asserts
|
|
326
|
+
// the CONTRACT that holds either way: it never throws, and it only ever spawns
|
|
327
|
+
// when a player exists.
|
|
328
|
+
const res = player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 100 }), spawnFn });
|
|
329
|
+
const available = player.playerFor(path.join(WAV_DIR, 'ship.wav')) !== null;
|
|
330
|
+
assert.equal(res, available, 'plays exactly when this machine has a player');
|
|
331
|
+
assert.equal(calls.length, available ? 1 : 0);
|
|
332
|
+
if (available) {
|
|
333
|
+
assert.equal(calls[0].opts.detached, true, 'audio must not hold the caller open');
|
|
334
|
+
assert.equal(calls[0].opts.stdio, 'ignore');
|
|
335
|
+
assert.ok(calls[0].args.some((a) => String(a).includes('ship')), 'the chosen wav reaches the player');
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('playerFor never throws and returns null or a [cmd, args] pair', () => {
|
|
340
|
+
const got = player.playerFor('/tmp/whatever.wav');
|
|
341
|
+
if (got !== null) {
|
|
342
|
+
assert.equal(typeof got[0], 'string');
|
|
343
|
+
assert.ok(Array.isArray(got[1]));
|
|
344
|
+
}
|
|
345
|
+
});
|