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