Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/defaultTunes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />

declare module "*.vue" {
import type { DefineComponent } from "vue";
type VueComponentWithExports = DefineComponent<{}, {}, any> & Record<string, unknown>;
const component: VueComponentWithExports;
export default component;
}

declare module "@vue/test-utils" {
export * from "@vue/test-utils/dist/index";
}
199 changes: 199 additions & 0 deletions src/ui/listen/__tests__/example-song-player.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Array<(...args: unknown[]) => 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<Instrument, number> => Object.fromEntries(
config.instrumentKeys.map((instrument) => [instrument, 1])
) as Record<Instrument, number>;

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<number, Record<string, string[]>>;
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();
});
});
});

56 changes: 33 additions & 23 deletions src/ui/listen/example-song-player.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,42 @@
const songRef = ref<HTMLElement | null>(null);
const abstractPlayerRef = ref<InstanceType<typeof AbstractPlayer>>();

const normalizedSong = computed((): Array<Required<Exclude<ExampleSong[0], string>>> => 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<Required<Exclude<ExampleSong[0], string>>> => {
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;
Expand Down Expand Up @@ -99,10 +113,6 @@
<template>
<div class="bb-example-song">
<div class="song" @click="setPosition($event)" ref="songRef">
<div class="card" style="width: 10em;">
<span class="tune-name">{{getLocalizedDisplayName("General Breaks")}}</span>
<span class="pattern-name">{{getLocalizedDisplayName("Whistle in")}}</span>
</div>
<div v-for="(part, i) in normalizedSong" :key="i" class="card" :style="{ width: `${2.5 * part.length }em` }">
<span class="tune-name">{{getLocalizedDisplayName(state.tunes[part.tuneName].displayName || part.tuneName)}}</span>
<span class="pattern-name">{{getLocalizedDisplayName(state.tunes[part.tuneName].patterns[part.patternName].displayName || part.patternName)}}</span>
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"moduleResolution": "node",
"module": "esnext",
"isolatedModules": true,
"skipLibCheck": true
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"vueCompilerOptions": {
Expand Down
Loading