Files
QZMusic_PC/amll-local/packages/lyric/test/lrc.test.ts

202 lines
6.2 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from "vitest";
import { parseLrc, stringifyLrc } from "../src/formats/lrc";
import { MAX_LRC_TIMESTAMP } from "../src/utils";
import { timeStampsTestCases } from "./timestampcase.fixture";
describe("lrc", () => {
it("parses basic timestamped lines", () => {
const lines = parseLrc("[00:01.120]Hello\n[00:03.000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("handles CRLF line breaks correctly", () => {
const lines = parseLrc("[00:01.120]Hello\r\n[00:03.000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("parses multiple timestamps for the same line", () => {
const lines = parseLrc("[00:01.120][00:02.000]Hello");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Hello");
});
it("ignores lines without timestamps", () => {
const lines = parseLrc(
"This is a line without timestamp\n[ar: Artist]\n[00:01.120]Hello\n# This is a comment\n{ some: 'metadata' }\n\n[00:03.000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLrc(
"[00:01.120]Hello\n[invalid]Bad line\n[xx:yy.zzz]Bad line\n[-1:00.000]Bad line\n[NaN:NaN]Bad line\n[00:03.000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("sorts lines by timestamp and sets end times correctly", () => {
const lines = parseLrc("[00:03.000]World\n[00:01.120][00:05.000]Hello");
expect(lines).toHaveLength(3);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(5000);
expect(lines[1].words[0].word).toBe("World");
expect(lines[2].startTime).toBe(5000);
expect(lines[2].endTime).toBe(MAX_LRC_TIMESTAMP);
expect(lines[2].words[0].word).toBe("Hello");
});
it("parses all kinds of timestamps", () => {
const input = timeStampsTestCases
.map(([ts, ms]) => `[${ts}]Should be ${ts} = ${ms} ms`)
.join("\n");
const lines = parseLrc(input);
expect(lines).toHaveLength(timeStampsTestCases.length);
lines.forEach((line, i) => {
const [ts, ms] = timeStampsTestCases[i];
expect(line.words[0].word).toBe(`Should be ${ts} = ${ms} ms`);
expect(line.startTime).toBe(ms);
});
});
it("identifies background lines with parentheses", () => {
const lines = parseLrc(
"[00:01.120](Hello)\n[00:03.000]Hi\n[00:03.000]World",
);
expect(lines).toHaveLength(3);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].isBG).toBe(true);
expect(lines[1].words).toHaveLength(1);
expect(lines[1].words[0].word).toBe("Hi");
expect(lines[2].isBG).toBe(false);
expect(lines[2].words).toHaveLength(1);
expect(lines[2].words[0].word).toBe("World");
});
it("trims whitespace from lines and ignore empty lines, while preserving end times", () => {
const lines = parseLrc(
"[00:00.000]\n[00:01.000] \n[00:01.120] Hello \n[00:02.333]\n[00:03.000] World \n[00:05.000] \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].endTime).toBe(2333);
expect(lines[1].words[0].word).toBe("World");
expect(lines[1].endTime).toBe(5000);
});
it("stringifies background lines with parentheses and normal lines without", () => {
const lines = [
{
startTime: 1120,
endTime: 3000,
words: [
{ startTime: 1120, endTime: 3000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
{
startTime: 3000,
endTime: 3000,
words: [
{ startTime: 3000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
];
const result = stringifyLrc(lines);
expect(result).toBe("[00:01.120](Hello)\n[00:03.000]World");
});
it("stringifies lines to expected lrc text", () => {
const result = stringifyLrc([
{
startTime: 1120,
endTime: 3000,
words: [
{ startTime: 1120, endTime: 3000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1120, endTime: 3000, word: "world!", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:01.120]Hello world!");
});
it("normalizes invalid startTime when stringifying", () => {
const baseLine = {
endTime: 3000,
words: [{ startTime: 0, endTime: 0, word: "Hello", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
};
expect(stringifyLrc([{ ...baseLine, startTime: -1 }])).toBe(
"[00:00.000]Hello",
);
expect(stringifyLrc([{ ...baseLine, startTime: Number.NaN }])).toBe(
"[00:00.000]Hello",
);
expect(
stringifyLrc([{ ...baseLine, startTime: Number.POSITIVE_INFINITY }]),
).toBe("[00:00.000]Hello");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[00:01.120]Hello\n[00:03.000](World)";
const first = parseLrc(input);
const text = stringifyLrc(first);
const second = parseLrc(text);
expect(second).toEqual(first);
});
});