Files
2026-09-22 16:33:35 +08:00

117 lines
5.2 KiB
JavaScript

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