126 lines
7.8 KiB
JavaScript
126 lines
7.8 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { parseJpwabc } from "../dist/domain/jpwabc.js";
|
|
import { modelFromParse, createEmptyDocument, createNoteEvent, rational } from "../dist/domain/score-model.js";
|
|
import { encodeProject, decodeProject } from "../dist/domain/project-codec.js";
|
|
import { applyEditCommand, CommandStore } from "../dist/domain/command-store.js";
|
|
import { DEFAULT_PAPER_CONFIG, layoutDocument } from "../dist/layout/layout-engine.js";
|
|
import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js";
|
|
import { renderSnapshotSvg } from "../dist/layout/svg-renderer.js";
|
|
|
|
function parseFixture() {
|
|
return modelFromParse(parseJpwabc(new TextEncoder().encode(`JPW-ABC File Ver 7.30\n.Voice\n1 {(3} [13] {DunYin} 2 3 |: 4 Hide 5 :|\n.Words\nW1@1,1(True,Lyric,1.00,$FF000000,False):\n{(女)[跑]}马溜/溜的山/上\nW2@1,1(True,Lyric,1.00,$FF000000,False):\n第二段歌词\n.Attachments\nText@2,1 = hello\nArc@1,1 = curve\n.Page\nMemo@1,20,30 = hello\n`)));
|
|
}
|
|
|
|
test("M6 parses tuplets, chords, decorations, hidden events and complex lyrics", () => {
|
|
const document = parseFixture();
|
|
const events = document.voices[0].measures.flatMap((measure) => measure.events);
|
|
assert.equal(events.filter((event) => event.tuplet?.ratio === 3).length, 3);
|
|
assert.deepEqual(events[1].chord?.map((pitch) => pitch.degree), [1, 3]);
|
|
assert.equal(events[1].decorations?.[0].kind, "DunYin");
|
|
assert.equal(events.find((event) => event.hidden)?.text, "Hide");
|
|
const lyrics = document.attachments.filter((attachment) => attachment.kind === "Lyric");
|
|
assert.equal(lyrics.length, 2);
|
|
assert.equal(lyrics[0].status, "attached");
|
|
assert.equal(lyrics[0].lyric.prefix, "{(女)[跑]}");
|
|
assert.deepEqual(lyrics[0].lyric.syllables, ["马", "溜", "溜", "的", "山", "上"]);
|
|
assert.equal(lyrics[0].lyric.syllableEventIds?.length, 6);
|
|
assert.equal(new Set(lyrics[0].lyric.syllableEventIds?.filter(Boolean)).size, 6);
|
|
assert.equal(document.attachments.find((attachment) => attachment.kind === "Text").status, "attached");
|
|
assert.deepEqual(document.pageObjects[0].position, { x: 20, y: 30 });
|
|
assert.equal(document.voices[0].measures[0].barlineAfter, "|:");
|
|
assert.equal(document.voices[0].measures[1].barlineAfter, ":|");
|
|
});
|
|
|
|
test("M6 editor commands update chords, decorations, tuplets and barline/repeat fields", () => {
|
|
const document = parseFixture();
|
|
const eventId = document.voices[0].measures[0].events[1].id;
|
|
const withEventFields = applyEditCommand(document, {
|
|
type: "update-event",
|
|
eventId,
|
|
patch: {
|
|
chord: [{ degree: 1, accidental: 0, octave: null }, { degree: 5, accidental: 0, octave: null }],
|
|
decorations: [{ id: "m6-decoration", kind: "staccato", raw: "staccato", playback: "visual", sourceRange: null }],
|
|
tuplet: { id: "m6-tuplet", ratio: 3, ordinal: 1, raw: "3:1" }
|
|
}
|
|
});
|
|
const withMeasureFields = applyEditCommand(withEventFields, {
|
|
type: "update-measure",
|
|
measureId: withEventFields.voices[0].measures[0].id,
|
|
patch: { barlineAfter: ":|", repeatStart: true, repeatEnd: true, ending: 1 }
|
|
});
|
|
const event = withMeasureFields.voices[0].measures[0].events[1];
|
|
assert.deepEqual(event.chord?.map((pitch) => pitch.degree), [1, 5]);
|
|
assert.equal(event.decorations?.[0].kind, "staccato");
|
|
assert.equal(event.tuplet?.ratio, 3);
|
|
assert.equal(withMeasureFields.voices[0].measures[0].barlineAfter, ":|");
|
|
assert.equal(withMeasureFields.voices[0].measures[0].repeatStart, true);
|
|
assert.equal(withMeasureFields.voices[0].measures[0].ending, 1);
|
|
});
|
|
|
|
test("M6 project codec preserves complex semantic fields and source attachments", () => {
|
|
const document = parseFixture();
|
|
const reopened = decodeProject(encodeProject(document));
|
|
const event = reopened.voices[0].measures[0].events[1];
|
|
assert.equal(event.chord?.length, 2);
|
|
assert.equal(event.decorations?.[0].raw, "{DunYin}");
|
|
assert.equal(event.tuplet?.ratio, 3);
|
|
assert.equal(reopened.attachments.filter((attachment) => attachment.lyric).length, 2);
|
|
assert.deepEqual(reopened.pageObjects[0].position, { x: 20, y: 30 });
|
|
});
|
|
|
|
test("M6 lyric editing preserves one-to-many syllables, punctuation, undo/redo and anchors", () => {
|
|
const store = new CommandStore(parseFixture());
|
|
const lyric = store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric");
|
|
assert.ok(lyric);
|
|
const raw = `${lyric.raw.split("\n", 1)[0]}\n一音多字-A,B。C\n`;
|
|
store.execute({ type: "set-attachment-raw", attachmentId: lyric.id, raw });
|
|
const edited = store.snapshot().attachments.find((attachment) => attachment.id === lyric.id);
|
|
assert.deepEqual(edited?.lyric?.syllables, ["一", "音", "多", "字", "A", "B", "C"]);
|
|
assert.equal(edited?.lyric?.syllableEventIds?.filter(Boolean).length, 7);
|
|
store.undo();
|
|
assert.notEqual(store.snapshot().attachments.find((attachment) => attachment.id === lyric.id)?.raw, raw);
|
|
store.redo();
|
|
const reopened = decodeProject(encodeProject(store.snapshot()));
|
|
assert.equal(reopened.attachments.find((attachment) => attachment.id === lyric.id)?.raw, raw);
|
|
assert.deepEqual(reopened.attachments.find((attachment) => attachment.id === lyric.id)?.lyric?.syllables, edited?.lyric?.syllables);
|
|
});
|
|
|
|
test("M6 visual versus real meter/key edits remain distinct in the saved timeline", () => {
|
|
const document = parseFixture();
|
|
const withMusicControls = applyEditCommand(document, { type: "set-title", field: "meter", value: "3/4" });
|
|
const withVisualText = applyEditCommand(withMusicControls, { type: "insert-event", voiceId: withMusicControls.voices[0].id, measureId: withMusicControls.voices[0].measures[0].id, event: { id: "visual-meter", kind: "text", pitch: null, duration: null, text: "拍号文字 4/4", source: null } });
|
|
const reopened = decodeProject(encodeProject(withVisualText));
|
|
assert.equal(reopened.title.meter, "3/4");
|
|
assert.equal(reopened.voices[0].measures[0].events.find((event) => event.id === "visual-meter")?.duration, null);
|
|
});
|
|
|
|
test("M6 layout renders decoration, chord, complex lyrics, page object and complex barlines", async () => {
|
|
const document = parseFixture();
|
|
const snapshot = await layoutDocument(document, DEFAULT_PAPER_CONFIG, createDefaultFontMeasurementProvider());
|
|
const svg = renderSnapshotSvg(snapshot)[0];
|
|
assert.match(svg, /data-layer="decoration"/);
|
|
assert.match(svg, /data-layer="chord"/);
|
|
assert.match(svg, /data-kind="attachment"/);
|
|
assert.match(svg, /data-layer="lyric-syllable"/);
|
|
assert.match(svg, /data-layer="attachment-curve"/);
|
|
assert.match(svg, /data-layer="barline"/);
|
|
assert.match(svg, /data-layer="page-objects"/);
|
|
assert.ok(Object.values(snapshot.attachments).some((attachment) => attachment.visible));
|
|
});
|
|
|
|
test("M6 layout segments an anchored attachment across pages without dropping stable ID", async () => {
|
|
const document = createEmptyDocument("m6-cross-page");
|
|
const events = Array.from({ length: 80 }, (_, index) => createNoteEvent(`m6-event-${index}`, (index % 7) + 1, rational(1)));
|
|
document.voices.push({ id: "m6-voice", name: "主旋律", sectionId: null, visible: true, measures: events.map((event, index) => ({ id: `m6-measure-${index}`, ordinal: index, beats: rational(1), events: [event] })) });
|
|
const start = events[0];
|
|
const end = events.at(-1);
|
|
document.attachments.push({ id: "m6-range", kind: "Arc", raw: "Arc@range", anchor: { raw: "Arc@range", startId: start.id, endId: end.id }, status: "attached", sourceRange: null, visible: true });
|
|
const snapshot = await layoutDocument(document, { ...DEFAULT_PAPER_CONFIG, lineHeightMm: 30 }, createDefaultFontMeasurementProvider());
|
|
const segments = snapshot.pages.flatMap((page) => page.attachments).filter((attachment) => attachment.id === "m6-range");
|
|
assert.ok(snapshot.pages.length > 1);
|
|
assert.ok(segments.length > 1);
|
|
assert.ok(segments.every((segment) => segment.continued));
|
|
assert.equal(snapshot.attachments["m6-range"].id, "m6-range");
|
|
});
|