import test from "node:test"; import assert from "node:assert/strict"; import { createEmptyDocument, createNoteEvent, createRestEvent, modelFromParse, rational } from "../dist/domain/score-model.js"; import { parseJpwabc } from "../dist/domain/jpwabc.js"; import { CompatibilityExportBlockedError, serializeJpwabc } from "../dist/domain/jpwabc-writer.js"; import { compilePlaybackTimeline, PlaybackController, RecordingPlaybackSink } from "../dist/playback/playback.js"; import { buildPrintableHtml, pixelsForMillimeters } from "../dist/output/output.js"; import { DEFAULT_PAPER_CONFIG } from "../dist/layout/layout-engine.js"; function eightVoiceDocument() { const document = createEmptyDocument("m5-eight-voice"); document.title.title = "M5 fixture"; document.title.key = "C"; document.title.meter = "4/4"; document.title.tempo = 120; for (let voiceIndex = 0; voiceIndex < 8; voiceIndex += 1) { const events = [ createNoteEvent(`v${voiceIndex}-1`, 1, rational(1)), createRestEvent(`v${voiceIndex}-r`, rational(1, 2)), createNoteEvent(`v${voiceIndex}-2`, 3, rational(1, 2)), createNoteEvent(`v${voiceIndex}-3`, 5, rational(2)) ]; document.voices.push({ id: `voice-${voiceIndex + 1}`, name: `Voice ${voiceIndex + 1}`, sectionId: null, visible: true, measures: [{ id: `measure-${voiceIndex + 1}`, ordinal: 0, beats: rational(4), events }] }); } return document; } test("M5 PlaybackTimeline compiles exact durations and eight simultaneous voices", () => { const document = eightVoiceDocument(); const timeline = compilePlaybackTimeline(document); assert.equal(timeline.voiceIds.length, 8); assert.equal(timeline.tempoBpm, 120); assert.equal(timeline.meter.numerator, 4); assert.equal(timeline.events.filter((event) => event.kind === "note" && event.startTick === 0).length, 8); assert.equal(timeline.events.find((event) => event.eventId === "v0-r").startTick, 960); assert.equal(timeline.events.find((event) => event.eventId === "v0-2").durationTicks, 480); assert.equal(timeline.durationTicks, 3840); assert.ok(Math.abs(timeline.secondsAtTick(3840) - 2) < 1e-12); }); test("M5 PlaybackController schedules, pauses, seeks and applies voice controls", async () => { const timeline = compilePlaybackTimeline(eightVoiceDocument()); const sink = new RecordingPlaybackSink(); const controller = new PlaybackController(timeline, { sink }); await controller.play(); assert.equal(controller.state, "playing"); assert.equal(sink.scheduled.length, 24); controller.setVoiceControl("voice-1", { muted: true }); assert.equal(controller.state, "playing"); assert.ok(sink.scheduled.every((entry) => entry.event.voiceId !== "voice-1")); controller.pause(); assert.equal(controller.state, "paused"); controller.seek(0); assert.equal(controller.currentTick, 0); controller.stop(); assert.equal(controller.state, "stopped"); assert.equal(controller.currentTick, 0); controller.destroy(); }); test("M5 core JPW-ABC writer emits UTF-16LE and blocks unsafe content", () => { const document = createEmptyDocument("writer-safe"); document.title.title = "核心写出"; document.title.key = "C"; document.title.meter = "4/4"; const event = createNoteEvent("writer-note", 1, rational(2)); event.pitch.octave = 1; document.voices.push({ id: "writer-voice", name: "主旋律", sectionId: null, visible: true, measures: [{ id: "writer-measure", ordinal: 0, beats: rational(2), events: [event] }] }); const result = serializeJpwabc(document); assert.deepEqual([...result.bytes.slice(0, 2)], [0xff, 0xfe]); assert.match(result.text, /\.Voice/); assert.match(result.text, /1g-/); const reopened = parseJpwabc(result.bytes); assert.equal(reopened.compatibility.originalExport, "safe"); assert.equal(reopened.voices.length, 1); const reopenedModel = modelFromParse(reopened); assert.equal(reopenedModel.voices[0].measures[0].events[0].duration.numerator / reopenedModel.voices[0].measures[0].events[0].duration.denominator, 2); assert.equal(reopenedModel.voices[0].measures[0].events[0].pitch.octave, 1); document.unknownBlocks.push({ id: "unknown-1", reason: "unknown-section", rawText: ".Private", context: ".Private", sourceRange: null }); assert.throws(() => serializeJpwabc(document), CompatibilityExportBlockedError); }); test("M5 output geometry is fixed A4 and printable HTML omits assistive layers", () => { const dimensions = pixelsForMillimeters(210, 297, 300); assert.deepEqual(dimensions, { width: 2480, height: 3508, dpi: 300 }); const snapshot = { version: 1, sourceDocumentId: "doc", documentTitle: "test", pageSizeMm: { width: 210, height: 297 }, contentRectMm: { x: 20, y: 20, width: 170, height: 257 }, config: DEFAULT_PAPER_CONFIG, pages: [{ pageNumber: 1, widthMm: 210, heightMm: 297, contentRectMm: { x: 20, y: 20, width: 170, height: 257 }, titleRectMm: { x: 20, y: 20, width: 170, height: 15 }, lines: [], measures: [], symbols: [], attachments: [], pageObjects: [] }], measures: {}, symbols: {}, attachments: {}, warnings: [], fontMeasurements: [], deterministicKey: "m5" }; const html = buildPrintableHtml(snapshot); assert.match(html, /size:A4 portrait/); assert.doesNotMatch(html, /data-layer="assistive"/); });