135 lines
6.9 KiB
JavaScript
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);
|
|
});
|