|
| 1 | +import { expect, test } from "@playwright/test"; |
| 2 | +import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; |
| 3 | +import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; |
| 4 | + |
| 5 | +const validManifest = { |
| 6 | + schema: "html-js-gaming.game-manifest", |
| 7 | + version: 1, |
| 8 | + game: { id: "MidiFixture", name: "MIDI Fixture", folder: "MidiFixture" }, |
| 9 | + launch: { directPath: "games/MidiFixture/index.html" }, |
| 10 | + tools: { |
| 11 | + "asset-manager-v2": { version: 1, assets: {} }, |
| 12 | + "midi-studio-v2": { |
| 13 | + activeSongId: "theme-main", |
| 14 | + directorMode: { enabled: true, defaultIntensity: "medium" } |
| 15 | + } |
| 16 | + }, |
| 17 | + music: { |
| 18 | + version: 1, |
| 19 | + runtimePreference: "rendered", |
| 20 | + songs: [ |
| 21 | + { |
| 22 | + id: "theme-main", |
| 23 | + name: "Main Theme", |
| 24 | + sourceMidi: "assets/music/midi/theme-main.mid", |
| 25 | + instrumentSet: "General MIDI", |
| 26 | + rendered: { |
| 27 | + wav: "assets/music/rendered/theme-main.wav", |
| 28 | + mp3: "assets/music/rendered/theme-main.mp3", |
| 29 | + ogg: "assets/music/rendered/theme-main.ogg" |
| 30 | + }, |
| 31 | + defaultRuntimeFormat: "ogg", |
| 32 | + loop: { enabled: true, startSeconds: 1, endSeconds: 64 }, |
| 33 | + director: { |
| 34 | + mood: "heroic", |
| 35 | + intensity: "medium", |
| 36 | + usage: ["title", "menu"], |
| 37 | + notes: "Opening cue." |
| 38 | + }, |
| 39 | + tags: ["theme", "menu"] |
| 40 | + }, |
| 41 | + { |
| 42 | + id: "combat-light", |
| 43 | + name: "Light Combat", |
| 44 | + sourceMidi: "", |
| 45 | + instrumentSet: { id: "gm-combat", name: "Combat GM" }, |
| 46 | + rendered: { |
| 47 | + wav: "assets/music/rendered/combat-light.wav", |
| 48 | + mp3: "assets/music/rendered/combat-light.mp3", |
| 49 | + ogg: "assets/music/rendered/combat-light.ogg" |
| 50 | + }, |
| 51 | + defaultRuntimeFormat: "ogg", |
| 52 | + loop: { enabled: true }, |
| 53 | + director: { |
| 54 | + mood: "tense", |
| 55 | + intensity: "high", |
| 56 | + usage: ["combat"], |
| 57 | + notes: "Short encounter loop." |
| 58 | + }, |
| 59 | + tags: ["combat"] |
| 60 | + } |
| 61 | + ] |
| 62 | + } |
| 63 | +}; |
| 64 | + |
| 65 | +function installMockAudio(page) { |
| 66 | + return page.addInitScript(() => { |
| 67 | + window.__midiStudioAudioEvents = []; |
| 68 | + window.Audio = class MockAudio { |
| 69 | + constructor(src) { |
| 70 | + this.currentTime = 0; |
| 71 | + this.duration = 12; |
| 72 | + this.loop = false; |
| 73 | + this.paused = true; |
| 74 | + this.preload = ""; |
| 75 | + this.src = src; |
| 76 | + this.volume = 1; |
| 77 | + } |
| 78 | + |
| 79 | + play() { |
| 80 | + this.paused = false; |
| 81 | + window.__midiStudioAudioEvents.push({ action: "play", loop: this.loop, src: this.src }); |
| 82 | + return Promise.resolve(); |
| 83 | + } |
| 84 | + |
| 85 | + pause() { |
| 86 | + this.paused = true; |
| 87 | + window.__midiStudioAudioEvents.push({ action: "pause", src: this.src }); |
| 88 | + } |
| 89 | + }; |
| 90 | + }); |
| 91 | +} |
| 92 | + |
| 93 | +async function openMidiStudio(page, routePayload = validManifest) { |
| 94 | + const server = await startRepoServer(); |
| 95 | + await installMockAudio(page); |
| 96 | + await page.route("**/midi-fixture.game.manifest.json", async (route) => { |
| 97 | + await route.fulfill({ |
| 98 | + contentType: "application/json", |
| 99 | + body: JSON.stringify(routePayload) |
| 100 | + }); |
| 101 | + }); |
| 102 | + await workspaceV2CoverageReporter.start(page); |
| 103 | + await page.goto(`${server.baseUrl}/tools/midi-studio-v2/index.html?manifestPath=/midi-fixture.game.manifest.json`, { waitUntil: "networkidle" }); |
| 104 | + return server; |
| 105 | +} |
| 106 | + |
| 107 | +test.describe("MIDI Studio V2", () => { |
| 108 | + test.afterAll(async () => { |
| 109 | + await workspaceV2CoverageReporter.writeReport(); |
| 110 | + }); |
| 111 | + |
| 112 | + test("launches and renders a valid multi-song manifest payload", async ({ page }) => { |
| 113 | + const server = await openMidiStudio(page); |
| 114 | + try { |
| 115 | + await expect(page.locator("body[data-tool-id='midi-studio-v2']")).toBeVisible(); |
| 116 | + await expect(page.locator("[data-midi-studio-header]")).toContainText("MIDI Studio V2"); |
| 117 | + await expect(page.locator("#songList [data-song-id]")).toHaveCount(2); |
| 118 | + await expect(page.locator('[data-song-id="theme-main"]')).toHaveAttribute("aria-pressed", "true"); |
| 119 | + await expect(page.locator("#songSourceField")).toHaveValue("assets/music/midi/theme-main.mid"); |
| 120 | + await expect(page.locator("#instrumentSetField")).toHaveValue("General MIDI"); |
| 121 | + await expect(page.locator("#renderedTargets")).toContainText("theme-main.ogg"); |
| 122 | + await expect(page.locator("#directorPanel")).toContainText("heroic"); |
| 123 | + await expect(page.locator("#statusLog")).toHaveValue(/OK Loaded 2 MIDI songs/); |
| 124 | + } finally { |
| 125 | + await workspaceV2CoverageReporter.stop(page); |
| 126 | + await server.close(); |
| 127 | + } |
| 128 | + }); |
| 129 | + |
| 130 | + test("selects multiple songs and updates source, director, and rendered targets", async ({ page }) => { |
| 131 | + const server = await openMidiStudio(page); |
| 132 | + try { |
| 133 | + await page.locator('[data-song-id="combat-light"]').click(); |
| 134 | + await expect(page.locator('[data-song-id="combat-light"]')).toHaveAttribute("aria-pressed", "true"); |
| 135 | + await expect(page.locator("#songSourceField")).toHaveValue("missing sourceMidi"); |
| 136 | + await expect(page.locator("#instrumentSetField")).toHaveValue("Combat GM"); |
| 137 | + await expect(page.locator("#renderedTargets")).toContainText("combat-light.mp3"); |
| 138 | + await expect(page.locator("#directorPanel")).toContainText("Short encounter loop."); |
| 139 | + await page.locator('[data-song-id="theme-main"]').click(); |
| 140 | + await expect(page.locator("#songSourceField")).toHaveValue("assets/music/midi/theme-main.mid"); |
| 141 | + } finally { |
| 142 | + await workspaceV2CoverageReporter.stop(page); |
| 143 | + await server.close(); |
| 144 | + } |
| 145 | + }); |
| 146 | + |
| 147 | + test("shows actionable failure for a selected song with missing MIDI source", async ({ page }) => { |
| 148 | + const server = await openMidiStudio(page); |
| 149 | + try { |
| 150 | + await page.locator('[data-song-id="combat-light"]').click(); |
| 151 | + await page.locator("#playButton").click(); |
| 152 | + await expect(page.locator("#statusLog")).toHaveValue(/FAIL Missing MIDI source for Light Combat\. Add music\.songs\[\]\.sourceMidi in game\.manifest\.json\./); |
| 153 | + await expect(page.locator("#stopButton")).toBeDisabled(); |
| 154 | + await expect(page.locator("#playButton")).toBeEnabled(); |
| 155 | + } finally { |
| 156 | + await workspaceV2CoverageReporter.stop(page); |
| 157 | + await server.close(); |
| 158 | + } |
| 159 | + }); |
| 160 | + |
| 161 | + test("updates play and stop control state without requiring real audio output", async ({ page }) => { |
| 162 | + const server = await openMidiStudio(page); |
| 163 | + try { |
| 164 | + await page.locator("#loopToggle").check(); |
| 165 | + await page.locator("#playButton").click(); |
| 166 | + await expect(page.locator("#playButton")).toBeDisabled(); |
| 167 | + await expect(page.locator("#stopButton")).toBeEnabled(); |
| 168 | + await expect(page.locator("#playbackState")).toContainText("Playing preview: Main Theme"); |
| 169 | + await expect(page.locator("#statusLog")).toHaveValue(/OK Preview play started for Main Theme/); |
| 170 | + expect(await page.evaluate(() => window.__midiStudioAudioEvents)).toEqual([ |
| 171 | + { action: "play", loop: true, src: "assets/music/midi/theme-main.mid" } |
| 172 | + ]); |
| 173 | + await page.locator("#stopButton").click(); |
| 174 | + await expect(page.locator("#stopButton")).toBeDisabled(); |
| 175 | + await expect(page.locator("#playButton")).toBeEnabled(); |
| 176 | + await expect(page.locator("#playbackState")).toContainText("Stopped: Main Theme"); |
| 177 | + } finally { |
| 178 | + await workspaceV2CoverageReporter.stop(page); |
| 179 | + await server.close(); |
| 180 | + } |
| 181 | + }); |
| 182 | + |
| 183 | + test("rejects invalid payloads before render", async ({ page }) => { |
| 184 | + const server = await openMidiStudio(page, { |
| 185 | + music: { |
| 186 | + version: 1, |
| 187 | + songs: [{ name: "Broken Song" }] |
| 188 | + } |
| 189 | + }); |
| 190 | + try { |
| 191 | + await expect(page.locator("#songList")).toContainText("No MIDI songs loaded."); |
| 192 | + await expect(page.locator("#playButton")).toBeDisabled(); |
| 193 | + await expect(page.locator("#statusLog")).toHaveValue(/FAIL MIDI Studio V2 payload rejected before render .* music\.songs\[0\]\.id is required\./); |
| 194 | + } finally { |
| 195 | + await workspaceV2CoverageReporter.stop(page); |
| 196 | + await server.close(); |
| 197 | + } |
| 198 | + }); |
| 199 | +}); |
0 commit comments