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

119 lines
7.8 KiB
JavaScript

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" }), /主声部不可/);
});