add Scene Music GM module: mood-based CC0-1.0 background tracks, /api/music/manifest, /music static, MoodMusic player in GMKit

This commit is contained in:
2026-07-17 08:03:44 +02:00
parent ad7ce9dc22
commit 111f4b3d0d
13 changed files with 706 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const musicDir = path.join(__dirname, '..', '..', 'media', 'music');
// GET /api/music/manifest — returns the music manifest array
router.get('/manifest', (req, res) => {
const manifestPath = path.join(musicDir, 'manifest.json');
try {
if (!fs.existsSync(manifestPath)) {
return res.status(404).json({ error: 'Music manifest not found' });
}
const data = fs.readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(data);
return res.json(manifest);
} catch (err) {
console.error('Failed to read music manifest:', err);
return res.status(500).json({ error: 'Failed to read music manifest' });
}
});
module.exports = router;

View File

@@ -14,6 +14,7 @@ const rulesStagingRoutes = require('./routes/rulesStagingRoutes');
const missionRoutes = require('./routes/missionRoutes');
const simulationRoutes = require('./routes/simulationRoutes');
const errorRoutes = require('./routes/errorRoutes');
const musicRoutes = require('./routes/musicRoutes');
// const rulesRoutes = require('./routes/rulesRoutes-simple');
const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit');
@@ -121,6 +122,14 @@ try {
console.error('Error mounting /api/errors:', e && e.stack ? e.stack : e);
}
try {
console.log('Registering /api/music');
app.use('/api/music', musicRoutes);
console.log('Music routes registered');
} catch (e) {
console.error('Error mounting /api/music:', e && e.stack ? e.stack : e);
}
// Expose gamemaster kit files and a simple listing API for GM-only resources
try {
console.log('Registering /api/gmkit and /gmkit static');
@@ -234,6 +243,13 @@ if (!fs.existsSync(avatarsDir)) {
}
app.use('/avatars', express.static(avatarsDir));
// Serve mood music files from media/music
const musicDir = path.join(__dirname, '..', 'media', 'music');
if (!fs.existsSync(musicDir)) {
fs.mkdirSync(musicDir, { recursive: true });
}
app.use('/music', express.static(musicDir));
// Catch-all handler for React Router (must be after API routes)
// app.get('*', (req, res) => {
// res.sendFile(path.join(buildDir, 'index.html'));
@@ -259,7 +275,7 @@ if (fs.existsSync(indexHtml)) {
// Middleware fallback for client-side routing: serve index.html for non-API and non-avatar paths.
// Use a plain middleware (no path) to avoid route string parsing by path-to-regexp.
app.use((req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/avatars/')) return next();
if (req.path.startsWith('/api/') || req.path.startsWith('/avatars/') || req.path.startsWith('/music/') || req.path.startsWith('/gmkit/')) return next();
return res.sendFile(indexHtml);
});
} else {

52
media/music/README.md Normal file
View File

@@ -0,0 +1,52 @@
# Mood Music — Scene Background Audio
These files are **real CC0-1.0 music tracks** from [freepd.com](https://freepd.com/),
redistributed via [github.com/SoundSafari/CC0-1.0-Music](https://github.com/SoundSafari/CC0-1.0-Music).
CC0-1.0 = public domain, no attribution required (but crediting the source is polite).
## Moods
| Mood | File | Title | Source |
|------|------|-------|--------|
| combat | `combat.mp3` | Battle Ready | freepd.com (via SoundSafari/CC0-1.0-Music) |
| investigation | `investigation.mp3` | Dark Hallway | freepd.com (via SoundSafari/CC0-1.0-Music) |
| tension | `tension.mp3` | Abstract Anxiety | freepd.com (via SoundSafari/CC0-1.0-Music) |
| victory | `victory.mp3` | Heroic Adventure | freepd.com (via SoundSafari/CC0-1.0-Music) |
| eerie | `eerie.mp3` | Ancient Rite | freepd.com (via SoundSafari/CC0-1.0-Music) |
## How to Swap in Your Own Tracks
1. **Find DRM-free music** from sources like:
- [Kevin MacLeod / incompetech.com](https://incompetech.com/) — CC-BY (requires attribution)
- [Pixabay Music](https://pixabay.com/music/) — mostly CC0
- [Free Music Archive](https://freemusicarchive.org/) — various licenses
- [Musopen](https://musopen.org/) — public domain classical
2. **Drop your file in** this directory with the same name (e.g. `combat.mp3`).
The file can be any audio format the browser supports (WAV, MP3, OGG).
3. **Update `manifest.json`** if you change filenames or add new moods:
```json
{
"mood": "combat",
"file": "combat.mp3",
"title": "Your Track Title",
"license": "CC-BY Kevin MacLeod"
}
```
4. **No code changes needed** — the frontend reads the manifest dynamically.
## License Notes
- **CC0** — no attribution required, free for any use.
- **CC-BY** — attribution required. Add the artist name in the `license` field
of `manifest.json` and display it in your app if desired.
- Never use copyrighted music without explicit permission.
## Technical Details
- Audio is served statically at `/music/<filename>` by Express.
- The manifest is available at `GET /api/music/manifest`.
- The GM plays music from the **GM Kit** tab → **Scene Music** section.
- Tracks loop automatically; switching moods fades out the current track.

BIN
media/music/combat.mp3 Normal file

Binary file not shown.

BIN
media/music/eerie.mp3 Normal file

Binary file not shown.

Binary file not shown.

37
media/music/manifest.json Normal file
View File

@@ -0,0 +1,37 @@
[
{
"mood": "combat",
"file": "combat.mp3",
"title": "Battle Ready",
"license": "CC0-1.0",
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
},
{
"mood": "investigation",
"file": "investigation.mp3",
"title": "Dark Hallway",
"license": "CC0-1.0",
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
},
{
"mood": "tension",
"file": "tension.mp3",
"title": "Abstract Anxiety",
"license": "CC0-1.0",
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
},
{
"mood": "victory",
"file": "victory.mp3",
"title": "Heroic Adventure",
"license": "CC0-1.0",
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
},
{
"mood": "eerie",
"file": "eerie.mp3",
"title": "Ancient Rite",
"license": "CC0-1.0",
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
}
]

BIN
media/music/tension.mp3 Normal file

Binary file not shown.

BIN
media/music/victory.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,152 @@
// gen-music-placeholders.js
// Synthesizes short loopable ambient WAV files (one per mood) using only Node stdlib.
// Output: media/music/<mood>.wav
// Each file: mono, 16-bit, 22050 Hz, ~8 seconds, low-amplitude, non-silent.
// License: CC0 (synthesized placeholder — replace with real DRM-free tracks).
const fs = require('fs');
const path = require('path');
const SAMPLE_RATE = 22050;
const DURATION_SEC = 8;
const NUM_SAMPLES = SAMPLE_RATE * DURATION_SEC;
const BITS_PER_SAMPLE = 16;
const BYTES_PER_SAMPLE = BITS_PER_SAMPLE / 8;
const CHANNELS = 1;
const MOODS = {
combat: {
title: 'Combat — Driving Tension',
// Fast pulsing low sine + harsh high noise burst
generate(i) {
const t = i / SAMPLE_RATE;
const pulse = Math.sin(2 * Math.PI * 80 * t) * 0.15;
const drive = Math.sin(2 * Math.PI * 160 * t) * 0.08;
const noise = (Math.random() * 2 - 1) * 0.04;
const accent = Math.sin(2 * Math.PI * 40 * t) * Math.sin(2 * Math.PI * 2 * t) * 0.1;
return pulse + drive + noise + accent;
},
},
investigation: {
title: 'Investigation — Low Ambient Drone',
generate(i) {
const t = i / SAMPLE_RATE;
const drone1 = Math.sin(2 * Math.PI * 55 * t) * 0.12;
const drone2 = Math.sin(2 * Math.PI * 82.5 * t) * 0.08;
const sub = Math.sin(2 * Math.PI * 30 * t) * 0.1;
const shimmer = Math.sin(2 * Math.PI * 220 * t) * 0.02 * Math.sin(2 * Math.PI * 0.25 * t);
return drone1 + drone2 + sub + shimmer;
},
},
tension: {
title: 'Tension — Ominous Unease',
generate(i) {
const t = i / SAMPLE_RATE;
const low = Math.sin(2 * Math.PI * 45 * t) * 0.12;
const dissonance = Math.sin(2 * Math.PI * 67 * t) * 0.08;
const trill = Math.sin(2 * Math.PI * 330 * t) * 0.03 * (1 + 0.5 * Math.sin(2 * Math.PI * 3 * t));
const scrape = (Math.random() * 2 - 1) * 0.02 * Math.abs(Math.sin(2 * Math.PI * 1.5 * t));
return low + dissonance + trill + scrape;
},
},
victory: {
title: 'Victory — Warm Resolution',
generate(i) {
const t = i / SAMPLE_RATE;
const root = Math.sin(2 * Math.PI * 130.8 * t) * 0.12;
const third = Math.sin(2 * Math.PI * 164.8 * t) * 0.09;
const fifth = Math.sin(2 * Math.PI * 196 * t) * 0.07;
const octave = Math.sin(2 * Math.PI * 261.6 * t) * 0.05;
const warmth = Math.sin(2 * Math.PI * 65.4 * t) * 0.08;
return root + third + fifth + octave + warmth;
},
},
eerie: {
title: 'Eerie — Dissonant Sparse',
generate(i) {
const t = i / SAMPLE_RATE;
const sparse = Math.sin(2 * Math.PI * 110 * t) * 0.06 * (Math.sin(2 * Math.PI * 0.15 * t) > 0.5 ? 1 : 0.05);
const dissonant = Math.sin(2 * Math.PI * 117 * t) * 0.06 * (Math.sin(2 * Math.PI * 0.15 * t) > 0.5 ? 1 : 0.05);
const whisper = (Math.random() * 2 - 1) * 0.015 * (Math.sin(2 * Math.PI * 0.5 * t) > 0 ? 1 : 0);
const sub = Math.sin(2 * Math.PI * 25 * t) * 0.08;
return sparse + dissonant + whisper + sub;
},
},
};
function writeWav(filePath, samples) {
const numBytes = samples.length * BYTES_PER_SAMPLE;
const buf = Buffer.alloc(44 + numBytes);
// RIFF header
buf.write('RIFF', 0);
buf.writeUInt32LE(36 + numBytes, 4);
buf.write('WAVE', 8);
// fmt chunk
buf.write('fmt ', 12);
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(CHANNELS, 22);
buf.writeUInt32LE(SAMPLE_RATE, 24);
buf.writeUInt32LE(SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS, 28);
buf.writeUInt16LE(BYTES_PER_SAMPLE * CHANNELS, 32);
buf.writeUInt16LE(BITS_PER_SAMPLE, 34);
// data chunk
buf.write('data', 36);
buf.writeUInt32LE(numBytes, 40);
for (let i = 0; i < samples.length; i++) {
let val = samples[i];
// Clamp to [-1, 1]
if (val > 1) val = 1;
if (val < -1) val = -1;
// Convert to signed 16-bit
const int16 = val >= 0 ? Math.floor(val * 32767) : Math.ceil(val * 32768);
buf.writeInt16LE(int16, 44 + i * BYTES_PER_SAMPLE);
}
fs.writeFileSync(filePath, buf);
}
function generateMood(mood, config) {
const samples = [];
for (let i = 0; i < NUM_SAMPLES; i++) {
samples.push(config.generate(i));
}
return samples;
}
// Main
const outDir = path.join(__dirname, '..', 'media', 'music');
fs.mkdirSync(outDir, { recursive: true });
const manifest = [];
for (const [mood, config] of Object.entries(MOODS)) {
const samples = generateMood(mood, config);
const filePath = path.join(outDir, `${mood}.wav`);
writeWav(filePath, samples);
// Verify non-silent
let maxAmp = 0;
for (const s of samples) {
if (Math.abs(s) > maxAmp) maxAmp = Math.abs(s);
}
console.log(`Generated: ${filePath} (${samples.length} samples, max amplitude: ${maxAmp.toFixed(4)})`);
manifest.push({
mood,
file: `${mood}.wav`,
title: config.title,
license: 'CC0 (synthesized placeholder)',
});
}
// Write manifest
const manifestPath = path.join(outDir, 'manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`\nManifest written: ${manifestPath}`);
console.log('Done. All placeholder tracks are CC0 synthesized audio.');

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import MoodMusic from './MoodMusic';
export default function GMKit({ authedPlayer }) {
const [activeTable, setActiveTable] = useState('difficulty');
@@ -442,6 +443,9 @@ export default function GMKit({ authedPlayer }) {
</table>
</div>
</div>
{/* Scene Music */}
<MoodMusic />
</div>
);
}

View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
const MOOD_COLORS = {
combat: 'bg-red-900 border-red-600 hover:bg-red-800',
investigation: 'bg-blue-900 border-blue-600 hover:bg-blue-800',
tension: 'bg-purple-900 border-purple-600 hover:bg-purple-800',
victory: 'bg-amber-900 border-amber-600 hover:bg-amber-800',
eerie: 'bg-green-900 border-green-600 hover:bg-green-800',
};
const MOOD_ICONS = {
combat: '⚔️',
investigation: '🔍',
tension: '⚡',
victory: '🏆',
eerie: '👁️',
};
export default function MoodMusic() {
const [manifest, setManifest] = useState([]);
const [activeMood, setActiveMood] = useState(null);
const [volume, setVolume] = useState(0.3);
const [nowPlaying, setNowPlaying] = useState('');
const [loading, setLoading] = useState(true);
const audioRef = useRef(null);
const fadeTimeoutRef = useRef(null);
// Fetch manifest on mount
useEffect(() => {
fetch('/api/music/manifest')
.then((r) => r.json())
.then((data) => {
setManifest(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch((err) => {
console.error('Failed to load music manifest:', err);
setManifest([]);
setLoading(false);
});
}, []);
// Create audio element on mount
useEffect(() => {
const audio = new Audio();
audio.preload = 'auto';
audio.loop = true;
audio.volume = volume;
audioRef.current = audio;
return () => {
if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
audio.pause();
audio.src = '';
};
}, []);
// Update volume when it changes
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume;
}
}, [volume]);
const playMood = useCallback((track) => {
if (!track || !audioRef.current) return;
const audio = audioRef.current;
const url = `/music/${track.file}`;
// If switching to same track, stop
if (activeMood === track.mood && audio.src === url) {
stopPlayback();
return;
}
// Fade out current, then switch
if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
// Quick fade out
const fadeOut = () => {
audio.volume = Math.max(0, audio.volume - 0.1);
if (audio.volume > 0) {
fadeTimeoutRef.current = setTimeout(fadeOut, 30);
} else {
// Switch source and fade in
audio.src = url;
audio.load();
audio.play().catch((e) => console.warn('Audio play failed:', e));
setActiveMood(track.mood);
setNowPlaying(track.title);
// Fade in
const fadeIn = () => {
audio.volume = Math.min(volume, audio.volume + 0.05);
if (audio.volume < volume) {
fadeTimeoutRef.current = setTimeout(fadeIn, 30);
}
};
if (volume > 0) {
fadeTimeoutRef.current = setTimeout(fadeIn, 30);
}
}
};
if (audio.src && audio.src !== url) {
fadeOut();
} else {
audio.src = url;
audio.load();
audio.play().catch((e) => console.warn('Audio play failed:', e));
setActiveMood(track.mood);
setNowPlaying(track.title);
}
}, [activeMood, volume]);
const stopPlayback = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
setActiveMood(null);
setNowPlaying('');
}, []);
if (loading) {
return (
<div className="bg-slate-800 rounded-xl p-4 border border-slate-600">
<h3 className="text-lg font-semibold text-slate-100 mb-2">Scene Music</h3>
<p className="text-slate-400 text-sm">Loading music tracks...</p>
</div>
);
}
if (manifest.length === 0) {
return (
<div className="bg-slate-800 rounded-xl p-4 border border-slate-600">
<h3 className="text-lg font-semibold text-slate-100 mb-2">Scene Music</h3>
<p className="text-slate-400 text-sm">No music tracks available.</p>
</div>
);
}
return (
<div className="bg-slate-800 rounded-xl p-4 border border-slate-600">
<h3 className="text-lg font-semibold text-slate-100 mb-3">Scene Music</h3>
{/* Mood buttons */}
<div className="grid grid-cols-5 gap-2 mb-4">
{manifest.map((track) => {
const isActive = activeMood === track.mood;
const colorClass = MOOD_COLORS[track.mood] || 'bg-slate-700 border-slate-500 hover:bg-slate-600';
return (
<button
key={track.mood}
onClick={() => playMood(track)}
className={`p-3 rounded-lg border-2 text-center transition-all ${colorClass} ${
isActive ? 'ring-2 ring-white text-white font-bold scale-105' : 'text-slate-200'
}`}
title={track.title}
>
<div className="text-xl mb-1">{MOOD_ICONS[track.mood] || '🎵'}</div>
<div className="text-xs uppercase tracking-wide">{track.mood}</div>
</button>
);
})}
</div>
{/* Stop button */}
{activeMood && (
<button
onClick={stopPlayback}
className="w-full mb-4 px-4 py-2 bg-red-800 hover:bg-red-700 text-white rounded-lg border border-red-600 transition-colors text-sm font-medium"
>
Stop Music
</button>
)}
{/* Volume control */}
<div className="mb-3">
<label className="text-xs text-slate-400 mb-1 block">Volume</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="text-xs text-slate-500 mt-1 text-right">{Math.round(volume * 100)}%</div>
</div>
{/* Now playing */}
{nowPlaying && (
<div className="text-xs text-slate-300 bg-slate-900 rounded px-3 py-2">
<span className="text-slate-500">Now playing:</span> {nowPlaying}
</div>
)}
</div>
);
}

219
src/tests/moodMusic.test.js Normal file
View File

@@ -0,0 +1,219 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import MoodMusic from '../components/MoodMusic';
import '@testing-library/jest-dom';
// Mock fetch for manifest endpoint
const mockManifest = [
{ mood: 'combat', file: 'combat.mp3', title: 'Battle Ready', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
{ mood: 'investigation', file: 'investigation.mp3', title: 'Dark Hallway', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
{ mood: 'tension', file: 'tension.mp3', title: 'Abstract Anxiety', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
{ mood: 'victory', file: 'victory.mp3', title: 'Heroic Adventure', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
{ mood: 'eerie', file: 'eerie.mp3', title: 'Ancient Rite', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
];
// Mock HTMLMediaElement (Audio constructor)
const mockAudio = {
preload: 'auto',
loop: true,
volume: 0.3,
src: '',
pause: jest.fn(),
play: jest.fn(() => Promise.resolve()),
load: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Mock global fetch for manifest
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockManifest),
})
);
// Mock Audio constructor
const OriginalAudio = global.Audio;
global.Audio = jest.fn(() => mockAudio);
});
afterEach(() => {
// Restore
delete global.Audio;
});
describe('MoodMusic', () => {
jest.setTimeout(10000);
test('renders with scene music heading', async () => {
await act(async () => {
render(<MoodMusic />);
});
await waitFor(() => {
expect(screen.getByText('Scene Music')).toBeInTheDocument();
}, { timeout: 3000 });
});
test('fetches manifest and renders mood buttons', async () => {
await act(async () => {
render(<MoodMusic />);
});
// Should have fetched the manifest
expect(fetch).toHaveBeenCalledWith('/api/music/manifest');
// Wait for buttons to appear
await waitFor(() => {
expect(screen.getByText('combat')).toBeInTheDocument();
}, { timeout: 3000 });
// All moods should be present
expect(screen.getByText('investigation')).toBeInTheDocument();
expect(screen.getByText('tension')).toBeInTheDocument();
expect(screen.getByText('victory')).toBeInTheDocument();
expect(screen.getByText('eerie')).toBeInTheDocument();
});
test('clicking a mood button sets it as active and plays', async () => {
await act(async () => {
render(<MoodMusic />);
});
await waitFor(() => {
expect(screen.getByText('combat')).toBeInTheDocument();
}, { timeout: 3000 });
// Click combat button
await act(async () => {
fireEvent.click(screen.getByText('combat'));
});
// Audio play should have been called
await waitFor(() => {
expect(mockAudio.play).toHaveBeenCalled();
}, { timeout: 3000 });
// Now playing label should show
await waitFor(() => {
expect(screen.getByText(/Battle Ready/)).toBeInTheDocument();
}, { timeout: 3000 });
// Stop button should appear
expect(screen.getByText('■ Stop Music')).toBeInTheDocument();
});
test('clicking active mood again stops playback', async () => {
await act(async () => {
render(<MoodMusic />);
});
await waitFor(() => {
expect(screen.getByText('combat')).toBeInTheDocument();
}, { timeout: 3000 });
// Click combat to start
await act(async () => {
fireEvent.click(screen.getByText('combat'));
});
await waitFor(() => {
expect(mockAudio.play).toHaveBeenCalled();
}, { timeout: 3000 });
// Click combat again to stop
await act(async () => {
fireEvent.click(screen.getByText('combat'));
});
// Pause should have been called
await waitFor(() => {
expect(mockAudio.pause).toHaveBeenCalled();
}, { timeout: 3000 });
});
test('stop button stops playback', async () => {
await act(async () => {
render(<MoodMusic />);
});
await waitFor(() => {
expect(screen.getByText('combat')).toBeInTheDocument();
}, { timeout: 3000 });
// Start combat
await act(async () => {
fireEvent.click(screen.getByText('combat'));
});
await waitFor(() => {
expect(screen.getByText('■ Stop Music')).toBeInTheDocument();
}, { timeout: 3000 });
// Click stop
await act(async () => {
fireEvent.click(screen.getByText('■ Stop Music'));
});
await waitFor(() => {
expect(mockAudio.pause).toHaveBeenCalled();
}, { timeout: 3000 });
});
test('volume slider is present and adjustable', async () => {
await act(async () => {
render(<MoodMusic />);
});
await waitFor(() => {
expect(screen.getByText('combat')).toBeInTheDocument();
}, { timeout: 3000 });
const volumeSlider = document.querySelector('input[type="range"]');
expect(volumeSlider).toBeInTheDocument();
expect(volumeSlider).toHaveAttribute('min', '0');
expect(volumeSlider).toHaveAttribute('max', '1');
});
test('shows loading state initially', async () => {
// Delay the fetch response to test loading state
global.fetch = jest.fn(() => new Promise((resolve) => {
setTimeout(() => {
resolve({
ok: true,
json: () => Promise.resolve(mockManifest),
});
}, 100);
}));
const { getByText, queryByText } = render(<MoodMusic />);
// Initially should show loading
expect(getByText('Loading music tracks...')).toBeInTheDocument();
expect(queryByText('combat')).not.toBeInTheDocument();
// After delay, should show buttons
await waitFor(() => {
expect(screen.getByText('combat')).toBeInTheDocument();
}, { timeout: 3000 });
});
test('shows empty state when manifest is empty', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
await act(async () => {
render(<MoodMusic />);
});
await waitFor(() => {
expect(screen.getByText('No music tracks available.')).toBeInTheDocument();
}, { timeout: 3000 });
});
});