Files
jp-editor/test/m6.test.mjs
T
2026-09-22 16:33:35 +08:00

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");
});