117 lines
5.2 KiB
JavaScript
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"/);
|
|
});
|