119 lines
7.8 KiB
JavaScript
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" }), /主声部不可/);
|
|
});
|