Files
jp-editor/test/m8.test.mjs
T
2026-09-22 16:33:35 +08:00

92 lines
4.9 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { compilePlaybackTimeline, PlaybackController, RecordingPlaybackSink } from "../dist/playback/playback.js";
import { createEmptyDocument, createNoteEvent, rational } from "../dist/domain/score-model.js";
function note(id, degree, raw = id) {
const event = createNoteEvent(id, degree, rational(1));
event.source = { range: null, raw };
return event;
}
function measure(id, ordinal, events, patch = {}) {
return { id, ordinal, beats: rational(1), events, ...patch };
}
test("M8 expands repeat start/end and first/second endings without an unbounded loop", () => {
const document = createEmptyDocument("m8-repeat");
document.voices.push({ id: "voice-repeat", name: "反复", sectionId: null, visible: true, measures: [
measure("m1", 0, [note("n1", 1)], { repeatStart: true }),
measure("m2", 1, [note("n2", 2)], { repeatEnd: true, ending: 1 }),
measure("m3", 2, [note("n3", 3)], { ending: 2 })
] });
const timeline = compilePlaybackTimeline(document);
assert.deepEqual(timeline.events.filter((event) => event.kind === "note").map((event) => event.sourceEvent.id), ["n1", "n2", "n1", "n3"]);
assert.equal(timeline.durationTicks, 3840);
assert.equal(timeline.loopIterations, 2);
assert.equal(timeline.warnings.some((warning) => warning.includes("安全迭代")), false);
});
test("M8 handles DC/DS navigation with a bounded jump and exposes control state", () => {
const document = createEmptyDocument("m8-navigation");
const dc = note("dc", 2, "{DC}");
dc.decorations = [{ id: "dc-mark", kind: "DC", raw: "{DC}", playback: "visual", sourceRange: null }];
const ds = note("ds", 3, "{DS}");
ds.decorations = [{ id: "ds-mark", kind: "DS", raw: "{DS}", playback: "visual", sourceRange: null }];
document.voices.push({ id: "voice-nav", name: "导航", sectionId: null, visible: true, measures: [measure("nav-1", 0, [note("nav-1-note", 1)], { repeatStart: true }), measure("nav-2", 1, [dc]), measure("nav-3", 2, [ds])] });
const timeline = compilePlaybackTimeline(document);
assert.ok(timeline.events.length >= 6);
assert.ok(timeline.controlEvents?.some((event) => event.type === "navigation" && event.value === "DC"));
assert.ok(timeline.loopIterations >= 2);
});
test("M8 restores tempo, meter and key state at control ticks", () => {
const document = createEmptyDocument("m8-controls");
document.title.tempo = 60;
document.title.meter = "4/4";
document.title.key = "C";
document.voices.push({ id: "voice-controls", name: "控制", sectionId: null, visible: true, measures: [
measure("control-1", 0, [note("control-1-note", 1)]),
measure("control-2", 1, [note("control-2-note", 1)], { tempoBpm: 120, meter: { numerator: 3, denominator: 4 }, key: "G" })
] });
const timeline = compilePlaybackTimeline(document);
assert.equal(timeline.stateAtTick(0).tempoBpm, 60);
assert.equal(timeline.stateAtTick(960).tempoBpm, 120);
assert.deepEqual(timeline.stateAtTick(960).meter, { numerator: 3, denominator: 4 });
assert.equal(timeline.stateAtTick(960).key, "G");
assert.ok(Math.abs(timeline.secondsAtTick(1920) - 1.5) < 1e-12);
assert.ok(Math.abs(timeline.ticksAtSeconds(1.5) - 1920) < 1e-9);
});
test("M8 maps W1 dynamics, staccato, tuplets and chord tones to playback events", () => {
const document = createEmptyDocument("m8-w1");
const event = note("w1-note", 1, "1");
event.chord = [{ degree: 1, accidental: 0, octave: 0 }, { degree: 3, accidental: 0, octave: 0 }];
event.tuplet = { id: "tuplet-1", ratio: 3, ordinal: 0, raw: "{(3}" };
event.decorations = [
{ id: "dynamic", kind: "mf", raw: "{mf}", playback: "w1", sourceRange: null },
{ id: "staccato", kind: "staccato", raw: "{staccato}", playback: "w1", sourceRange: null }
];
document.voices.push({ id: "voice-w1", name: "W1", sectionId: null, visible: true, measures: [measure("w1-measure", 0, [event])] });
const timeline = compilePlaybackTimeline(document);
const notes = timeline.events.filter((candidate) => candidate.kind === "note");
assert.equal(notes.length, 2);
assert.equal(notes[0].durationTicks, 640);
assert.equal(notes[0].gateTicks, 320);
assert.equal(notes[0].velocity, 82);
assert.ok(timeline.controlEvents?.some((control) => control.type === "velocity"));
});
test("M8 controller schedules gate duration from the tempo segment containing the note", async () => {
const document = createEmptyDocument("m8-controller");
document.title.tempo = 60;
document.voices.push({ id: "voice", name: "控制器", sectionId: null, visible: true, measures: [measure("m1", 0, [note("n1", 1)]), measure("m2", 1, [note("n2", 2)], { tempoBpm: 120 })] });
const timeline = compilePlaybackTimeline(document);
const sink = new RecordingPlaybackSink();
const controller = new PlaybackController(timeline, { sink });
await controller.play();
assert.equal(sink.scheduled.length, 2);
assert.ok(Math.abs(sink.scheduled[1].durationSeconds - 0.5) < 1e-12);
controller.destroy();
});