diff --git a/package.json b/package.json index 1140aeb31..f793b9d9e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", "eslint": "8", "eslint-plugin-import": "^2.31.0", "eslint-plugin-vue": "^9.32.0", diff --git a/src/config.ts b/src/config.ts index bf4a97810..15e2480b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,11 @@ export type Config = { * The default speed to use for tunes that don't specify a separate default speed, in beats per minute. */ defaultSpeed: number; + + /** + * Whether to display a whistle-in block to start the example song on a song's main page. + */ + startSongWithWhistleIn: boolean; }; const config: Config = { @@ -304,7 +309,9 @@ const config: Config = { tuneOfTheYear: "The Roof Is on Fire", - defaultSpeed: 100 + defaultSpeed: 100, + + startSongWithWhistleIn: true, }; // Check some requirements for export so that we don't forget them at some point in the future diff --git a/src/defaultTunes.ts b/src/defaultTunes.ts index ff1f96061..cc1544917 100644 --- a/src/defaultTunes.ts +++ b/src/defaultTunes.ts @@ -3428,6 +3428,16 @@ Object.defineProperty(defaultTunes, "firstInSorting", { value: [ "General Breaks", "Special Breaks", "Shouting Breaks" ] }); +if (config.startSongWithWhistleIn) { + const whistleInBreak = defaultTunes["General Breaks"]?.patterns["Whistle in"]; + if (!whistleInBreak) { + throw new Error("config.startSongWithWhistleIn is true but pattern 'General Breaks' / 'Whistle in' does not exist."); + } + if (whistleInBreak.length !== 4) { + throw new Error("config.startSongWithWhistleIn is true but pattern 'General Breaks' / 'Whistle in' must be 4 beats long (got " + whistleInBreak.length + ")."); + } +} + interface DefaultTunesMethods { getPattern(tuneName: string, patternName?: string): Pattern | undefined; getPattern(patternReference: PatternReference): Pattern | undefined; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 000000000..cc21a9684 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,12 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + type VueComponentWithExports = DefineComponent<{}, {}, any> & Record; + const component: VueComponentWithExports; + export default component; +} + +declare module "@vue/test-utils" { + export * from "@vue/test-utils/dist/index"; +} diff --git a/src/ui/listen/__tests__/example-song-player.test.ts b/src/ui/listen/__tests__/example-song-player.test.ts new file mode 100644 index 000000000..213471ed0 --- /dev/null +++ b/src/ui/listen/__tests__/example-song-player.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { defineComponent, h, nextTick, ref } from "vue"; +import { mount } from "@vue/test-utils"; +import type { State } from "../../../state/state"; +import type { ExampleSong } from "../../../state/tune"; +import config, { Instrument } from "../../../config"; +import { provideState } from "../../../services/state"; +import ExampleSongPlayer from "../example-song-player.vue"; +import * as playerModule from "../../../services/player"; +import { normalizePattern } from "../../../state/pattern"; + +vi.mock("../../../services/i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), + getLocalizedDisplayName: (value: string) => value +})); + +// Necessary to mock beatbox.js to avoid AudioContext errors +vi.mock("beatbox.js", () => { + class MockBeatbox { + static registerInstrument = vi.fn(); + + playing = false; + _position = 0; + _events: Record void>> = {}; + + constructor() {} + + on(event: string, handler: (...args: unknown[]) => void) { + (this._events[event] ||= []).push(handler); + } + + emit(event: string, ...args: unknown[]) { + for (const handler of this._events[event] || []) + handler(...args); + } + + play() { + this.playing = true; + this.emit("play"); + } + + stop() { + this.playing = false; + this.emit("stop"); + } + + setPosition(position: number) { + this._position = position; + this.emit("setPosition"); + } + } + + return { default: MockBeatbox }; +}); + +const createVolumes = (): Record => Object.fromEntries( + config.instrumentKeys.map((instrument) => [instrument, 1]) +) as Record; + +function createMockState(): State { + const introPattern = normalizePattern(); + introPattern.displayName = "Intro DisplayName"; + introPattern.ls = ["X", " ", " ", " "]; + + const whistlePattern = normalizePattern(); + whistlePattern.displayName = "Whistle-in DisplayName"; + whistlePattern.ls = ["X", "X", " ", " "]; + + return { + tunes: { + "My Tune": { + displayName: "My Tune DisplayName", + categories: [], + patterns: { + "Intro": introPattern + } + }, + "General Breaks": { + displayName: "General Breaks DisplayName", + categories: [], + patterns: { + "Whistle in": whistlePattern + } + }, + }, + songs: [], + songIdx: 0, + playbackSettings: { + speed: 100, + headphones: [], + mute: {}, + volume: 1, + volumes: createVolumes(), + loop: false, + whistle: false + } + }; +} + +async function mountExampleSongPlayer(state: State, props: { tuneName: string; song: ExampleSong }) { + const wrapper = defineComponent({ + setup() { + provideState(ref(state)); + return () => h(ExampleSongPlayer, props); + } + }); + + const mountedWrapper = mount(wrapper); + await nextTick(); + + return { + container: mountedWrapper.element as HTMLElement, + unmount() { + mountedWrapper.unmount(); + } + }; +} + +describe("example-song-player", () => { + const originalStartSongWithWhistleIn = config.startSongWithWhistleIn; + const songToBeatboxSpy = vi.spyOn(playerModule, "songToBeatbox"); + + beforeEach(() => { + songToBeatboxSpy.mockClear(); + songToBeatboxSpy.mockReturnValue({} as any); + }); + + const expectCardContents = (card: Element, tuneName: string, patternName: string) => { + const tuneNameElement = card.querySelector(".tune-name"); + const patternNameElement = card.querySelector(".pattern-name"); + expect(tuneNameElement?.textContent?.trim()).toBe(tuneName); + expect(patternNameElement?.textContent?.trim()).toBe(patternName); + }; + + // We don't really test the AbstractPlayer here, but we test the construction of its rawPattern argument. + const expectRawPatternBuiltWithArguments = (pieces: string[][]) => { + expect(songToBeatboxSpy).toHaveBeenCalledTimes(1); + const songPartsArg = songToBeatboxSpy.mock.calls[0][0] as Record>; + expect(Object.keys(songPartsArg)).toHaveLength(pieces.length); + for (let index = 0; index < pieces.length; index++) { + expect(songPartsArg[index].ls).toEqual(pieces[index]); + } + }; + + describe("when the song has no whistle-in", () => { + beforeEach(() => { + config.startSongWithWhistleIn = false; + }); + + afterEach(() => { + config.startSongWithWhistleIn = originalStartSongWithWhistleIn; + }); + + it("renders tune and pattern names from props", async () => { + const mockState = createMockState(); + const props = { + tuneName: "My Tune", + song: ["Intro"] as ExampleSong + }; + const { container, unmount } = await mountExampleSongPlayer(mockState, props); + + const cards = container.querySelectorAll(".song .card"); + expect(cards.length).toBe(1); + expectCardContents(cards[0], "My Tune DisplayName", "Intro DisplayName"); + expectRawPatternBuiltWithArguments([["My Tune", "Intro"]]); + + unmount(); + }); + }); + + describe("when the song has a whistle-in", () => { + beforeEach(() => { + config.startSongWithWhistleIn = true; + }); + + afterEach(() => { + config.startSongWithWhistleIn = originalStartSongWithWhistleIn; + }); + + it("renders the whistle-in and the song", async () => { + const mockState = createMockState(); + const props = { + tuneName: "My Tune", + song: ["Intro"] as ExampleSong + }; + const { container, unmount } = await mountExampleSongPlayer(mockState, props); + + const cards = container.querySelectorAll(".song .card"); + expect(cards.length).toBe(2); + const whistleInCard = cards[0]; + expectCardContents(whistleInCard, "General Breaks DisplayName", "Whistle-in DisplayName"); + expectCardContents(cards[1], "My Tune DisplayName", "Intro DisplayName"); + expectRawPatternBuiltWithArguments([["General Breaks", "Whistle in"], ["My Tune", "Intro"]]); + + unmount(); + }); + }); +}); + diff --git a/src/ui/listen/example-song-player.vue b/src/ui/listen/example-song-player.vue index 42289b212..6550b5d38 100644 --- a/src/ui/listen/example-song-player.vue +++ b/src/ui/listen/example-song-player.vue @@ -35,28 +35,42 @@ const songRef = ref(null); const abstractPlayerRef = ref>(); - const normalizedSong = computed((): Array>> => props.song.flatMap((part) => { - const result = { - tuneName: props.tuneName, - ...(typeof part === "string" ? { patternName: part } : part) - }; - const pattern = getPatternFromState(state.value, result.tuneName, result.patternName); - if(!pattern) - return []; - else { - return [{ - length: pattern.length, - instruments: config.instrumentKeys, - ...result - }]; + const normalizedSong = computed((): Array>> => { + const normalizedSongParts = props.song.flatMap((part) => { + const result = { + tuneName: props.tuneName, + ...(typeof part === "string" ? { patternName: part } : part) + }; + const pattern = getPatternFromState(state.value, result.tuneName, result.patternName); + if(!pattern) + return []; + else { + return [{ + length: pattern.length, + instruments: config.instrumentKeys, + ...result + }]; + } + }); + + if (config.startSongWithWhistleIn) { + const whistleInPattern = getPatternFromState(state.value, "General Breaks", "Whistle in"); + if (whistleInPattern) { + return [{ + tuneName: "General Breaks", + patternName: "Whistle in", + instruments: config.instrumentKeys, + length: whistleInPattern.length, + }, ...normalizedSongParts]; + } } - })); + + return normalizedSongParts; + }); const songParts = computed((): SongParts => { - let i = 1; - const result = { - 0: allInstruments([ "General Breaks", "Whistle in" ]) - } as SongParts; + const result = {} as SongParts; + let i = 0; for(const part of normalizedSong.value) { result[i] = allInstruments([ part.tuneName, part.patternName ], part.instruments); i += part.length / 4; @@ -99,10 +113,6 @@