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); });