import test from "node:test"; import assert from "node:assert/strict"; import { CommandStore } from "../dist/domain/command-store.js"; import { decodeProject, encodeProject } from "../dist/domain/project-codec.js"; import { createEmptyDocument, createNoteEvent, rational, modelEquivalent, validateScore } from "../dist/domain/score-model.js"; import { DEFAULT_PAPER_CONFIG, layoutDocument } from "../dist/layout/layout-engine.js"; import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js"; import { preflightJpwabcExport } from "../dist/domain/jpwabc-writer.js"; function measure(id, ordinal, degree) { return { id, ordinal, beats: rational(1), events: [createNoteEvent(`${id}-event`, degree, rational(1))] }; } function baseDocument() { const document = createEmptyDocument("m7-document"); document.voices.push({ id: "voice-a", name: "主旋律", sectionId: null, visible: true, alignment: "aligned", measures: [measure("measure-a-1", 0, 1), measure("measure-a-2", 1, 2), measure("measure-a-3", 2, 3)] }); document.voices.push({ id: "voice-b", name: "和声", sectionId: "section-1", visible: true, alignment: "overlay", measures: [measure("measure-b-1", 0, 3), measure("measure-b-2", 1, 5)] }); return document; } test("M7 manual layout and voice commands are editable and survive project reopen", () => { const store = new CommandStore(baseDocument()); store.execute({ type: "set-manual-layout", patch: { lockedMeasureIds: ["measure-a-1"], forcedBreakAfterMeasureIds: ["measure-a-1"], measureSpacing: { "measure-a-1": 8 }, measurePositions: { "measure-a-1": { x: 3, y: 2 } }, objectPositions: { "measure-a-1-event": { x: 1.5, y: -0.5 } }, voiceOrder: ["voice-b", "voice-a"], voiceSpacing: { "voice-b": 5 }, voiceAlignment: { "voice-b": "overlay" }, lyricOffsets: { "lyric-a": { x: 1, y: -2 } }, fontOverrides: { "voice-b": { role: "special", scaleX: 1.1, scaleY: 0.9 } } }}); store.execute({ type: "set-voice-layout", voiceId: "voice-b", patch: { temporary: true, fontRole: "special", alignment: "overlay" } }); const temporary = { id: "voice-temp", name: "临时声部", sectionId: null, visible: true, temporary: true, alignment: "independent", measures: [measure("measure-t-1", 0, 6)] }; store.execute({ type: "add-temporary-voice", voice: temporary }); const reopened = decodeProject(encodeProject(store.snapshot())); assert.equal(modelEquivalent(store.snapshot(), reopened), true); assert.deepEqual(reopened.manualLayout.measurePositions["measure-a-1"], { x: 3, y: 2 }); assert.deepEqual(reopened.manualLayout.objectPositions["measure-a-1-event"], { x: 1.5, y: -0.5 }); assert.equal(reopened.voices.find((voice) => voice.id === "voice-b").temporary, true); assert.equal(reopened.voices.find((voice) => voice.id === "voice-temp").temporary, true); const report = preflightJpwabcExport(reopened); assert.equal(report.safe, false); assert.ok(report.reasons.some((reason) => reason.includes("手动排版") || reason.includes("临时"))); }); test("M7 layout consumes lock, forced break, measure movement, voice order and voice spacing", async () => { const document = baseDocument(); document.manualLayout.lockedMeasureIds = ["measure-a-1"]; document.manualLayout.forcedBreakAfterMeasureIds = ["measure-a-1"]; document.manualLayout.measureSpacing["measure-a-1"] = 12; document.manualLayout.measurePositions = { "measure-a-1": { x: 4, y: 2 } }; document.manualLayout.objectPositions = { "measure-a-1-event": { x: 1.5, y: -0.5 } }; document.manualLayout.voiceOrder = ["voice-b", "voice-a"]; document.manualLayout.voiceSpacing = { "voice-b": 6 }; document.voices[1].alignment = "overlay"; const snapshot = await layoutDocument(document, { ...DEFAULT_PAPER_CONFIG, lineHeightMm: 25 }, createDefaultFontMeasurementProvider()); const locked = snapshot.measures["measure-a-1"]; assert.equal(locked.locked, true); assert.equal(locked.voiceId, "voice-a"); assert.equal(locked.x, 128); assert.equal(locked.y, 37); assert.ok(snapshot.pages.some((page) => page.lines.some((line) => line.measureIds.includes("measure-a-1")))); assert.ok(snapshot.pages.flatMap((page) => page.lines).some((line) => line.measureIds.includes("measure-a-2"))); assert.equal(snapshot.symbols["measure-a-1-event"].voiceId, "voice-a"); assert.equal(snapshot.symbols["measure-b-1-event"].voiceId, "voice-b"); }); test("M7 measure insert/delete preserves ordinals and diagnoses removed anchors", () => { const store = new CommandStore(baseDocument()); const voice = store.snapshot().voices[0]; store.execute({ type: "insert-measure", voiceId: voice.id, index: 1, measure: { id: "measure-inserted", ordinal: 1, beats: rational(1), events: [measure("measure-inserted", 1, 4).events[0]] } }); assert.equal(store.snapshot().voices[0].measures.length, 4); assert.deepEqual(store.snapshot().voices[0].measures.map((item) => item.ordinal), [0, 1, 2, 3]); store.execute({ type: "delete-measure", voiceId: voice.id, measureId: "measure-inserted" }); assert.equal(store.snapshot().voices[0].measures.length, 3); assert.deepEqual(store.snapshot().voices[0].measures.map((item) => item.ordinal), [0, 1, 2]); }); test("M7 page text boxes and voice names are editable and persist", () => { const store = new CommandStore(baseDocument()); store.execute({ type: "set-voice-layout", voiceId: "voice-a", patch: { name: "主旋律改名", alignment: "independent" } }); store.execute({ type: "insert-page-object", object: { id: "page-note", kind: "TextBox", raw: "页面备注", sourceRange: null, position: { x: 20, y: 270 }, visible: true } }); store.execute({ type: "update-page-object", objectId: "page-note", patch: { raw: "页面备注2", position: { x: 22, y: 268 } } }); const reopened = decodeProject(encodeProject(store.snapshot())); assert.equal(reopened.voices[0].name, "主旋律改名"); assert.deepEqual(reopened.pageObjects.find((object) => object.id === "page-note")?.position, { x: 22, y: 268 }); store.execute({ type: "delete-page-object", objectId: "page-note" }); assert.equal(store.snapshot().pageObjects.some((object) => object.id === "page-note"), false); }); test("M7 voice groups support split, merge, undo and explicit mismatch diagnostics", () => { const store = new CommandStore(baseDocument()); store.execute({ type: "split-voice", voiceId: "voice-a", atMeasure: 1, newVoiceId: "voice-a-split", newName: "主旋律 B" }); assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a")?.measures.length, 1); assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a-split")?.measures.length, 2); store.undo(); assert.equal(store.snapshot().voices.some((voice) => voice.id === "voice-a-split"), false); store.redo(); store.execute({ type: "merge-voices", targetVoiceId: "voice-a", sourceVoiceId: "voice-a-split", strategy: "append" }); assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a")?.measures.length, 3); const mismatch = baseDocument(); mismatch.voices[1].alignment = "aligned"; mismatch.voices[1].measures = mismatch.voices[1].measures.slice(0, 1); const diagnostics = validateScore(mismatch).diagnostics; assert.ok(diagnostics.some((diagnostic) => diagnostic.code === "VOICE_MEASURE_COUNT_MISMATCH")); }); test("M7 temporary voice can be removed without deleting primary voices", () => { const store = new CommandStore(baseDocument()); const temporary = { id: "voice-temp", name: "临时", sectionId: null, visible: true, temporary: true, measures: [measure("measure-t-1", 0, 4)] }; store.execute({ type: "add-temporary-voice", voice: temporary }); store.execute({ type: "delete-voice", voiceId: "voice-temp" }); assert.equal(store.snapshot().voices.some((voice) => voice.id === "voice-temp"), false); assert.throws(() => store.execute({ type: "delete-voice", voiceId: "voice-a" }), /主声部不可/); });