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
84 changes: 84 additions & 0 deletions frontend/__tests__/components/modals/CustomTextModal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ vi.mock("../../../src/ts/test/test-logic", () => ({
restartTest: vi.fn(),
}));

vi.mock("../../../src/ts/utils/lyrics-provider", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../src/ts/utils/lyrics-provider")
>()),
searchTracks: vi.fn(async () => [
{
id: "1",
title: "Song",
artist: "Artist",
album: "Album",
lyrics: "[Chorus]\nhello world\nfoo bar",
},
]),
fetchTrackInfo: vi.fn(async () => null),
}));

// The test state module fetches the input/keymap layout on module load and
// the word filter modal fetches the language data, so stub the static files.
vi.stubGlobal(
Expand Down Expand Up @@ -91,6 +107,16 @@ describe("components/modals/CustomTextModal.tsx", () => {
fireEvent.click(el);
}

/** searches for the mocked song in the lyrics modal and opens it */
async function openMockedSong(): Promise<void> {
const searchInput = await screen.findByPlaceholderText(
"search by song, artist or album and press enter",
);
fireEvent.input(searchInput, { target: { value: "song" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
fireEvent.click(await screen.findByText("Song"));
}

function submitSaveDialog(): void {
const saveDialog = document.getElementById("SaveCustomTextModal");
if (saveDialog === null) {
Expand Down Expand Up @@ -206,4 +232,62 @@ describe("components/modals/CustomTextModal.tsx", () => {
]);
expect(CustomText.getCustomTextIsPipeDelimiter("book", true)).toBe(true);
});

it("fills the text with lyrics chosen from the lyrics search instead of starting a test", async () => {
const { restartTest } = await import("../../../src/ts/test/test-logic");
vi.mocked(restartTest).mockClear();

showModal("CustomText");
render(() => <CustomTextModal />);

fireEvent.input(screen.getByPlaceholderText(textareaPlaceholder), {
target: { value: "old text" },
});

fireEvent.click(screen.getByText("load lyrics"));
await openMockedSong();
fireEvent.click(await screen.findByText("use lyrics"));

// back in the custom text modal, with the cleaned up lyrics in the textarea
await waitFor(() => {
expect(screen.getByPlaceholderText(textareaPlaceholder)).toHaveValue(
"hello world foo bar",
);
});
expect(restartTest).not.toHaveBeenCalled();

fireEvent.click(screen.getByText("ok"));

await waitFor(() => {
expect(CustomText.getText()).toStrictEqual([
"hello",
"world",
"foo",
"bar",
]);
});
expect(CustomText.getPipeDelimiter()).toBe(false);
expect(CustomText.getLimitValue()).toBe(4);
});

it("starts the test right away when the lyrics search is opened on its own", async () => {
const { restartTest } = await import("../../../src/ts/test/test-logic");
vi.mocked(restartTest).mockClear();

showModal("LyricsSearch");
render(() => <CustomTextModal />);

await openMockedSong();
fireEvent.click(await screen.findByText("start typing"));

await waitFor(() => {
expect(CustomText.getText()).toStrictEqual([
"hello",
"world",
"foo",
"bar",
]);
});
expect(restartTest).toHaveBeenCalled();
});
});
254 changes: 254 additions & 0 deletions frontend/__tests__/utils/lyrics-filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { describe, it, expect } from "vitest";

import {
defaultLyricsFilterOptions,
filterLyrics,
findFoldableChars,
getFoldReplacement,
lyricsToWords,
type LyricsFilterOptions,
} from "../../src/ts/utils/lyrics-filter";

// all filters off, so each test enables only what it checks
const off: LyricsFilterOptions = {
removeSectionHeaders: false,
brackets: "keep",
removeSpecialChars: false,
removePunctuation: false,
normalizeTypography: false,
foldDiacritics: false,
keepChars: "",
lowercase: false,
removeRepeatedLines: false,
lineBreaks: "space",
};

describe("lyrics-filter", () => {
it("joins lines with spaces and drops empty lines", () => {
expect(filterLyrics("one two\n\nthree\r\nfour", off)).toBe(
"one two three four",
);
});

it("uses the custom text newline convention for enter mode", () => {
const text = filterLyrics("one two\nthree", {
...off,
lineBreaks: "enter",
});
expect(text).toBe("one two\n three");
expect(text.split(" ")).toEqual(["one", "two\n", "three"]);
});

describe("section headers", () => {
it("removes tag-only lines", () => {
expect(
filterLyrics("[Chorus]\nhello\n [Verse 2] \nworld", {
...off,
removeSectionHeaders: true,
}),
).toBe("hello world");
});

it("keeps brackets that are part of a lyric line", () => {
expect(
filterLyrics("hello [yeah] world", {
...off,
removeSectionHeaders: true,
}),
).toBe("hello [yeah] world");
});
});

describe("brackets", () => {
it("removes content, including nested and multi-line", () => {
expect(
filterLyrics("hello (ooh (yeah)) world {x} [y]\nfoo (bar\nbaz) end", {
...off,
brackets: "removeContent",
}),
).toBe("hello world foo end");
});

it("strips only the bracket characters", () => {
expect(
filterLyrics("hello (ooh) [yeah] {no}", {
...off,
brackets: "stripChars",
}),
).toBe("hello ooh yeah no");
});

it("a line that is only a parenthetical disappears", () => {
expect(
filterLyrics("(ooh)\nhello", { ...off, brackets: "removeContent" }),
).toBe("hello");
});
});

describe("special characters", () => {
it("removes symbols but keeps letters, digits and basic punctuation", () => {
expect(
filterLyrics("it's #1 * ~ hit, yeah! <3 100% ok?", {
...off,
removeSpecialChars: true,
}),
).toBe("it's 1 hit, yeah! 3 100 ok?");
});

it("replaces & and slashes with readable text", () => {
expect(
filterLyrics("rock & roll, black/white", {
...off,
removeSpecialChars: true,
}),
).toBe("rock and roll, black white");
});

it("keeps non-latin letters", () => {
expect(
filterLyrics("привет, мир ★ 愛してる", {
...off,
removeSpecialChars: true,
}),
).toBe("привет, мир 愛してる");
});
});

describe("punctuation", () => {
it("removes punctuation, keeps apostrophes and in-word hyphens", () => {
expect(
filterLyrics(`"Don't stop", she said - well-known: yes!`, {
...off,
removePunctuation: true,
}),
).toBe("Don't stop she said well-known yes");
});
});

it("normalizes typography", () => {
expect(
filterLyrics("“Hello” – it’s me…", { ...off, normalizeTypography: true }),
).toBe(`"Hello" - it's me...`);
});

it("folds diacritics", () => {
expect(
filterLyrics("Café Ünïcode Straße Søren", {
...off,
foldDiacritics: true,
}),
).toBe("Cafe Unicode Strasse Soren");
});

describe("keepChars", () => {
it("folds cedillas in both cases", () => {
expect(
filterLyrics("Ça va, garçon", { ...off, foldDiacritics: true }),
).toBe("Ca va, garcon");
});

it("leaves chosen characters alone and folds the rest", () => {
expect(
filterLyrics("Straße über Café Ärger", {
...off,
foldDiacritics: true,
keepChars: "ßü",
}),
).toBe("Straße über Cafe Arger");
});

it("keeps both cases of a chosen character", () => {
expect(
filterLyrics("Über über ÜBER", {
...off,
foldDiacritics: true,
keepChars: "ü",
}),
).toBe("Über über ÜBER");
});

it("keepChars does nothing while folding is off", () => {
expect(filterLyrics("é", { ...off, keepChars: "" })).toBe("é");
});
});

describe("findFoldableChars", () => {
it("lists distinct lowercase characters that would be folded", () => {
expect(findFoldableChars("Straße Über café Ç plain ASCII")).toEqual([
"ç",
"é",
"ß",
"ü",
]);
});

it("finds decomposed accents too", () => {
expect(findFoldableChars("cafe\u0301")).toEqual(["é"]);
});

it("returns nothing for plain text", () => {
expect(findFoldableChars("just words")).toEqual([]);
});
});

it("getFoldReplacement", () => {
expect(getFoldReplacement("Ç")).toBe("C");
expect(getFoldReplacement("ç")).toBe("c");
expect(getFoldReplacement("ß")).toBe("ss");
expect(getFoldReplacement("a")).toBe("a");
});

it("does not destroy non-latin combining marks when folding", () => {
expect(filterLyrics("हिन्दी", { ...off, foldDiacritics: true })).toBe(
"हिन्दी",
);
});

it("lowercases", () => {
expect(filterLyrics("Hello WORLD", { ...off, lowercase: true })).toBe(
"hello world",
);
});

describe("repeated lines", () => {
it("collapses only consecutive duplicates, ignoring case", () => {
expect(
filterLyrics("la la\nLa La\nla la\nother\nla la", {
...off,
removeRepeatedLines: true,
}),
).toBe("la la other la la");
});

it("compares after cleaning", () => {
expect(
filterLyrics("oh yeah (x2)\noh yeah", {
...off,
brackets: "removeContent",
removeRepeatedLines: true,
}),
).toBe("oh yeah");
});
});

describe("lyricsToWords", () => {
it("returns no empty words", () => {
expect(lyricsToWords(" a b \n c ", off)).toEqual(["a", "b", "c"]);
});

it("returns [] for lyrics that are only filtered content", () => {
expect(
lyricsToWords("[Intro]\n(instrumental)", defaultLyricsFilterOptions),
).toEqual([]);
});

it("defaults produce clean typeable text", () => {
expect(
lyricsToWords(
"[Verse 1]\n“I’m here” (yeah)\n★ you & me ★",
defaultLyricsFilterOptions,
),
).toEqual(["\"I'm", 'here"', "you", "and", "me"]);
});
});
});
Loading
Loading