diff --git a/database/routes/musicRoutes.js b/database/routes/musicRoutes.js new file mode 100644 index 0000000..45e2aab --- /dev/null +++ b/database/routes/musicRoutes.js @@ -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; \ No newline at end of file diff --git a/database/server.js b/database/server.js index 53418e3..b3b2cc0 100755 --- a/database/server.js +++ b/database/server.js @@ -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 { diff --git a/media/music/README.md b/media/music/README.md new file mode 100644 index 0000000..bc96e68 --- /dev/null +++ b/media/music/README.md @@ -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/` 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. \ No newline at end of file diff --git a/media/music/combat.mp3 b/media/music/combat.mp3 new file mode 100644 index 0000000..301afe6 Binary files /dev/null and b/media/music/combat.mp3 differ diff --git a/media/music/eerie.mp3 b/media/music/eerie.mp3 new file mode 100644 index 0000000..63d25e4 Binary files /dev/null and b/media/music/eerie.mp3 differ diff --git a/media/music/investigation.mp3 b/media/music/investigation.mp3 new file mode 100644 index 0000000..8c15089 Binary files /dev/null and b/media/music/investigation.mp3 differ diff --git a/media/music/manifest.json b/media/music/manifest.json new file mode 100644 index 0000000..46e9096 --- /dev/null +++ b/media/music/manifest.json @@ -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)" + } +] \ No newline at end of file diff --git a/media/music/tension.mp3 b/media/music/tension.mp3 new file mode 100644 index 0000000..0433064 Binary files /dev/null and b/media/music/tension.mp3 differ diff --git a/media/music/victory.mp3 b/media/music/victory.mp3 new file mode 100644 index 0000000..666e735 Binary files /dev/null and b/media/music/victory.mp3 differ diff --git a/scripts/gen-music-placeholders.js b/scripts/gen-music-placeholders.js new file mode 100644 index 0000000..d875a6a --- /dev/null +++ b/scripts/gen-music-placeholders.js @@ -0,0 +1,152 @@ +// gen-music-placeholders.js +// Synthesizes short loopable ambient WAV files (one per mood) using only Node stdlib. +// Output: media/music/.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.'); \ No newline at end of file diff --git a/src/components/GMKit.jsx b/src/components/GMKit.jsx index ce403b1..e78376d 100644 --- a/src/components/GMKit.jsx +++ b/src/components/GMKit.jsx @@ -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 }) { + + {/* Scene Music */} + ); } diff --git a/src/components/MoodMusic.jsx b/src/components/MoodMusic.jsx new file mode 100644 index 0000000..d917b06 --- /dev/null +++ b/src/components/MoodMusic.jsx @@ -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 ( +
+

Scene Music

+

Loading music tracks...

+
+ ); + } + + if (manifest.length === 0) { + return ( +
+

Scene Music

+

No music tracks available.

+
+ ); + } + + return ( +
+

Scene Music

+ + {/* Mood buttons */} +
+ {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 ( + + ); + })} +
+ + {/* Stop button */} + {activeMood && ( + + )} + + {/* Volume control */} +
+ + setVolume(parseFloat(e.target.value))} + className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
{Math.round(volume * 100)}%
+
+ + {/* Now playing */} + {nowPlaying && ( +
+ Now playing: {nowPlaying} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/tests/moodMusic.test.js b/src/tests/moodMusic.test.js new file mode 100644 index 0000000..2568b28 --- /dev/null +++ b/src/tests/moodMusic.test.js @@ -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(); + }); + + await waitFor(() => { + expect(screen.getByText('Scene Music')).toBeInTheDocument(); + }, { timeout: 3000 }); + }); + + test('fetches manifest and renders mood buttons', async () => { + await act(async () => { + render(); + }); + + // 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + + // 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(); + }); + + await waitFor(() => { + expect(screen.getByText('No music tracks available.')).toBeInTheDocument(); + }, { timeout: 3000 }); + }); +}); \ No newline at end of file