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

135 lines
6.9 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
createEmptyDocument,
createNoteEvent,
createRestEvent,
rational,
modelEquivalent,
modelFromParse,
validateScore
} from "../dist/domain/score-model.js";
import { CommandRejectedError, CommandStore } from "../dist/domain/command-store.js";
import { decodeProject, encodeProject, ProjectCodecError, projectEquivalentAfterReopen } from "../dist/domain/project-codec.js";
import { parseJpwabc } from "../dist/domain/jpwabc.js";
function makeDocument() {
const note1 = createNoteEvent("event-note-1", 1, rational(1));
const note2 = createNoteEvent("event-note-2", 2, rational(1));
const note3 = createNoteEvent("event-note-3", 3, rational(1));
const rest = createRestEvent("event-rest-1", rational(1));
const text = { id: "event-text-1", kind: "text", pitch: null, duration: null, text: "表情", source: null };
const measure = { id: "measure-1", ordinal: 0, beats: rational(4), events: [note1, note2, note3, rest, text] };
const voice = { id: "voice-1", name: "主旋律", sectionId: null, visible: true, measures: [measure] };
const document = createEmptyDocument("document-test");
document.title.title = "测试曲";
document.voices.push(voice);
document.attachments.push({
id: "attachment-1",
kind: "Text",
raw: "Text@event-text-1 = 表情",
anchor: { raw: "event-text-1", startId: "event-text-1", endId: null },
status: "attached",
sourceRange: null
});
return { document, note1, text, voice, measure };
}
test("M2 validates durations and rejects invalid edits atomically", () => {
const { document, voice, measure } = makeDocument();
const store = new CommandStore(document);
const before = store.snapshot();
assert.equal(store.dirty, false);
assert.throws(() => store.execute({
type: "insert-event",
voiceId: voice.id,
measureId: measure.id,
event: createNoteEvent("event-invalid", 5, rational(2))
}), (error) => error instanceof CommandRejectedError && error.diagnostics.some((item) => item.code === "MEASURE_DURATION_MISMATCH"));
assert.equal(modelEquivalent(store.snapshot(), before), true);
assert.equal(store.dirty, false);
});
test("M2 insertion, modification, deletion, undo and redo preserve stable IDs", () => {
const { document, note1, voice, measure } = makeDocument();
const store = new CommandStore(document);
store.execute({
type: "insert-event",
voiceId: voice.id,
measureId: measure.id,
index: 0,
event: { id: "event-text-2", kind: "text", pitch: null, duration: null, text: "新文字", source: null }
});
store.execute({ type: "update-event", eventId: note1.id, patch: { pitch: { degree: 7, accidental: 1, octave: 1 } } });
store.execute({ type: "delete-event", eventId: "event-text-2" });
assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), false);
assert.equal(store.snapshot().voices[0].measures[0].events.find((event) => event.id === note1.id)?.pitch?.degree, 7);
store.undo();
assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), true);
assert.equal(store.snapshot().voices[0].measures[0].events.find((event) => event.id === note1.id)?.id, note1.id);
store.redo();
assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), false);
assert.equal(store.canUndo, true);
assert.equal(store.canRedo, false);
});
test("M2 deleting an anchored non-timed object retains an orphan diagnostic", () => {
const { document, text } = makeDocument();
const store = new CommandStore(document);
store.execute({ type: "delete-event", eventId: text.id });
const attachment = store.snapshot().attachments[0];
assert.equal(attachment.id, "attachment-1");
assert.equal(attachment.anchor.startId, null);
assert.equal(attachment.status, "orphaned");
assert.ok(store.model.validate().diagnostics.some((item) => item.code === "ORPHANED_ANCHOR"));
});
test("M2 ProjectCodec saves and reopens the current model without undo history", () => {
const { document, note1, voice, measure } = makeDocument();
const store = new CommandStore(document);
store.execute({ type: "set-title", field: "title", value: "编辑后曲名" });
store.execute({ type: "update-event", eventId: note1.id, patch: { text: "高音" } });
store.markSaved();
assert.equal(store.dirty, false);
const text = encodeProject(store.snapshot());
const reopened = decodeProject(text);
assert.equal(projectEquivalentAfterReopen(store.snapshot(), reopened), true);
assert.equal(reopened.documentId, "document-test");
assert.equal(reopened.title.title, "编辑后曲名");
assert.equal(reopened.voices[0].measures[0].events.find((event) => event.id === note1.id)?.text, "高音");
assert.doesNotMatch(text, /undoStack|redoStack|LayoutSnapshot|diagnostics/);
assert.equal(encodeProject(reopened), text);
assert.equal(store.canUndo, true);
assert.equal(store.canRedo, false);
assert.equal(voice.id, "voice-1");
assert.equal(measure.id, "measure-1");
});
test("M2 ProjectCodec rejects unknown schema fields and invalid nested types", () => {
const { document } = makeDocument();
const project = JSON.parse(encodeProject(document));
project.futureField = true;
assert.throws(() => decodeProject(JSON.stringify(project)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "UNKNOWN_FIELD"));
delete project.futureField;
project.schemaVersion = 99;
assert.throws(() => decodeProject(JSON.stringify(project)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "UNSUPPORTED_SCHEMA"));
const invalidDuration = JSON.parse(encodeProject(document));
invalidDuration.score.voices[0].measures[0].events[0].duration = "not-a-rational";
assert.throws(() => decodeProject(JSON.stringify(invalidDuration)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "INVALID_TYPE"));
});
test("M2 converts the parsed JPW-ABC AST into stable editable model IDs", async () => {
const bytes = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc");
const parsed = parseJpwabc(bytes);
const first = modelFromParse(parsed, { fileName: "康定情歌.jpwabc" });
const second = modelFromParse(parseJpwabc(bytes), { fileName: "康定情歌.jpwabc" });
const validation = validateScore(first);
assert.equal(validation.valid, true);
assert.equal(first.documentId, second.documentId);
assert.deepEqual(first.voices.map((voice) => voice.id), second.voices.map((voice) => voice.id));
assert.ok(first.unknownBlocks.length > 0 || first.attachments.some((attachment) => attachment.status !== "attached"));
assert.ok(first.source?.rawBase64);
assert.equal(projectEquivalentAfterReopen(first, decodeProject(encodeProject(first))), true);
});