|
|
@@ -1,587 +1,513 @@
|
|
|
/**
|
|
|
- * 解析器集成测试
|
|
|
+ * 简谱渲染引擎 - 集成测试
|
|
|
*
|
|
|
- * 使用真实的MusicXML文件测试完整的解析流程:
|
|
|
- * 1. MusicXML解析 → 模拟OSMD对象
|
|
|
- * 2. OSMDDataParser解析 → JianpuScore
|
|
|
- * 3. TimeCalculator计算 → 时间数据
|
|
|
- *
|
|
|
- * 测试文件:
|
|
|
- * - basic.xml: 基础简谱
|
|
|
- * - mixed-durations.xml: 混合时值
|
|
|
+ * @description 测试新增绘制器与主渲染流程的集成
|
|
|
*/
|
|
|
|
|
|
-import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
|
-import { readFileSync } from 'fs';
|
|
|
-import { join } from 'path';
|
|
|
-import { OSMDDataParser } from '../core/parser/OSMDDataParser';
|
|
|
-import { TimeCalculator } from '../core/parser/TimeCalculator';
|
|
|
-import { DivisionsHandler } from '../core/parser/DivisionsHandler';
|
|
|
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
+import { JianpuRenderer, createJianpuRenderer } from '../JianpuRenderer';
|
|
|
+import { JianpuScore } from '../models/JianpuScore';
|
|
|
+import { JianpuMeasure } from '../models/JianpuMeasure';
|
|
|
+import { JianpuNote, Accidental } from '../models/JianpuNote';
|
|
|
|
|
|
-// ==================== MusicXML 解析器 ====================
|
|
|
+// ==================== 测试数据工厂 ====================
|
|
|
|
|
|
/**
|
|
|
- * 简单的MusicXML解析器
|
|
|
- * 将MusicXML转换为模拟的OSMD对象结构
|
|
|
+ * 创建测试音符
|
|
|
*/
|
|
|
-class SimpleMusicXMLParser {
|
|
|
- private divisionsHandler = new DivisionsHandler();
|
|
|
-
|
|
|
- /**
|
|
|
- * 解析MusicXML字符串
|
|
|
- */
|
|
|
- parse(xmlString: string): any {
|
|
|
- const parser = new DOMParser();
|
|
|
- const doc = parser.parseFromString(xmlString, 'text/xml');
|
|
|
-
|
|
|
- // 检查解析错误
|
|
|
- const parseError = doc.querySelector('parsererror');
|
|
|
- if (parseError) {
|
|
|
- throw new Error(`XML解析错误: ${parseError.textContent}`);
|
|
|
- }
|
|
|
-
|
|
|
- return this.buildOSMDObject(doc);
|
|
|
- }
|
|
|
-
|
|
|
- private buildOSMDObject(doc: Document): any {
|
|
|
- const title = doc.querySelector('work-title')?.textContent ?? 'Untitled';
|
|
|
- const composer = doc.querySelector('creator[type="composer"]')?.textContent ?? '';
|
|
|
-
|
|
|
- // 解析小节
|
|
|
- const measureElements = doc.querySelectorAll('measure');
|
|
|
- const sourceMeasures: any[] = [];
|
|
|
- const notes: any[] = [];
|
|
|
-
|
|
|
- let currentDivisions = 256;
|
|
|
- let currentTempo = 120;
|
|
|
- let currentTimeSignature = { numerator: 4, denominator: 4 };
|
|
|
- let currentKeySignature = { keyTypeOriginal: 0, Mode: 0 };
|
|
|
-
|
|
|
- measureElements.forEach((measureEl, measureIndex) => {
|
|
|
- // 解析attributes
|
|
|
- const attributes = measureEl.querySelector('attributes');
|
|
|
- if (attributes) {
|
|
|
- const divisions = attributes.querySelector('divisions');
|
|
|
- if (divisions) {
|
|
|
- currentDivisions = parseInt(divisions.textContent ?? '256');
|
|
|
- this.divisionsHandler.setDivisions(currentDivisions);
|
|
|
- }
|
|
|
-
|
|
|
- const time = attributes.querySelector('time');
|
|
|
- if (time) {
|
|
|
- currentTimeSignature = {
|
|
|
- numerator: parseInt(time.querySelector('beats')?.textContent ?? '4'),
|
|
|
- denominator: parseInt(time.querySelector('beat-type')?.textContent ?? '4'),
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- const key = attributes.querySelector('key');
|
|
|
- if (key) {
|
|
|
- currentKeySignature = {
|
|
|
- keyTypeOriginal: parseInt(key.querySelector('fifths')?.textContent ?? '0'),
|
|
|
- Mode: key.querySelector('mode')?.textContent === 'minor' ? 1 : 0,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 解析速度
|
|
|
- const sound = measureEl.querySelector('sound[tempo]');
|
|
|
- if (sound) {
|
|
|
- currentTempo = parseInt(sound.getAttribute('tempo') ?? '120');
|
|
|
- }
|
|
|
-
|
|
|
- const metronome = measureEl.querySelector('metronome per-minute');
|
|
|
- if (metronome) {
|
|
|
- currentTempo = parseInt(metronome.textContent ?? '120');
|
|
|
- }
|
|
|
-
|
|
|
- // 创建SourceMeasure
|
|
|
- const sourceMeasure: any = {
|
|
|
- MeasureNumberXML: measureIndex + 1,
|
|
|
- measureListIndex: measureIndex,
|
|
|
- tempoInBPM: currentTempo,
|
|
|
- ActiveTimeSignature: currentTimeSignature,
|
|
|
- ActiveKeySignature: currentKeySignature,
|
|
|
- Duration: { RealValue: currentTimeSignature.numerator / currentTimeSignature.denominator },
|
|
|
- lastRepetitionInstructions: [],
|
|
|
- verticalMeasureList: [],
|
|
|
- };
|
|
|
-
|
|
|
- sourceMeasures.push(sourceMeasure);
|
|
|
-
|
|
|
- // 解析音符
|
|
|
- const noteElements = measureEl.querySelectorAll('note');
|
|
|
- let timestamp = 0;
|
|
|
-
|
|
|
- noteElements.forEach((noteEl) => {
|
|
|
- const note = this.parseNote(noteEl, measureIndex, sourceMeasure, timestamp);
|
|
|
- if (note) {
|
|
|
- notes.push({
|
|
|
- note,
|
|
|
- measureIndex,
|
|
|
- timestamp,
|
|
|
- });
|
|
|
-
|
|
|
- // 更新时间戳(除非是和弦音符)
|
|
|
- if (!noteEl.querySelector('chord')) {
|
|
|
- timestamp += note.length.realValue;
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- // 构建cursor迭代器
|
|
|
- let noteIndex = 0;
|
|
|
- const iterator = {
|
|
|
- EndReached: notes.length === 0,
|
|
|
- currentVoiceEntries: notes.length > 0 ? [{
|
|
|
- Notes: [notes[0]?.note],
|
|
|
- notes: [notes[0]?.note],
|
|
|
- ParentVoice: { VoiceId: 0 },
|
|
|
- }] : [],
|
|
|
- CurrentVoiceEntries: [],
|
|
|
- currentMeasureIndex: 0,
|
|
|
- currentTimeStamp: { RealValue: 0, realValue: 0 },
|
|
|
- moveToNextVisibleVoiceEntry: () => {
|
|
|
- noteIndex++;
|
|
|
- if (noteIndex >= notes.length) {
|
|
|
- iterator.EndReached = true;
|
|
|
- } else {
|
|
|
- const { note, measureIndex, timestamp } = notes[noteIndex];
|
|
|
- iterator.currentVoiceEntries = [{
|
|
|
- Notes: [note],
|
|
|
- notes: [note],
|
|
|
- ParentVoice: { VoiceId: 0 },
|
|
|
- }];
|
|
|
- iterator.CurrentVoiceEntries = iterator.currentVoiceEntries;
|
|
|
- iterator.currentMeasureIndex = measureIndex;
|
|
|
- iterator.currentTimeStamp = { RealValue: timestamp, realValue: timestamp };
|
|
|
- }
|
|
|
- },
|
|
|
- };
|
|
|
- iterator.CurrentVoiceEntries = iterator.currentVoiceEntries;
|
|
|
-
|
|
|
- return {
|
|
|
- Sheet: {
|
|
|
- Title: { text: title },
|
|
|
- Composer: { text: composer },
|
|
|
- SourceMeasures: sourceMeasures,
|
|
|
- },
|
|
|
- GraphicSheet: {
|
|
|
- MeasureList: sourceMeasures.map(m => [{ parentSourceMeasure: m }]),
|
|
|
- },
|
|
|
- cursor: {
|
|
|
- Iterator: iterator,
|
|
|
- reset: () => {
|
|
|
- noteIndex = 0;
|
|
|
- iterator.EndReached = notes.length === 0;
|
|
|
- if (notes.length > 0) {
|
|
|
- const { note, measureIndex, timestamp } = notes[0];
|
|
|
- iterator.currentVoiceEntries = [{
|
|
|
- Notes: [note],
|
|
|
- notes: [note],
|
|
|
- ParentVoice: { VoiceId: 0 },
|
|
|
- }];
|
|
|
- iterator.CurrentVoiceEntries = iterator.currentVoiceEntries;
|
|
|
- iterator.currentMeasureIndex = measureIndex;
|
|
|
- iterator.currentTimeStamp = { RealValue: timestamp, realValue: timestamp };
|
|
|
- }
|
|
|
- },
|
|
|
- next: () => iterator.moveToNextVisibleVoiceEntry(),
|
|
|
- },
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- private parseNote(noteEl: Element, measureIndex: number, sourceMeasure: any, timestamp: number): any {
|
|
|
- const isRest = noteEl.querySelector('rest') !== null;
|
|
|
- const durationEl = noteEl.querySelector('duration');
|
|
|
- const duration = parseInt(durationEl?.textContent ?? '256');
|
|
|
- const realValue = this.divisionsHandler.toRealValue(duration);
|
|
|
-
|
|
|
- // 解析音高
|
|
|
- let pitch: any = null;
|
|
|
- let halfTone = 60; // 默认C4
|
|
|
-
|
|
|
- if (!isRest) {
|
|
|
- const pitchEl = noteEl.querySelector('pitch');
|
|
|
- if (pitchEl) {
|
|
|
- const step = pitchEl.querySelector('step')?.textContent ?? 'C';
|
|
|
- const octave = parseInt(pitchEl.querySelector('octave')?.textContent ?? '4');
|
|
|
- const alter = parseInt(pitchEl.querySelector('alter')?.textContent ?? '0');
|
|
|
-
|
|
|
- pitch = {
|
|
|
- step,
|
|
|
- octave,
|
|
|
- alter,
|
|
|
- frequency: this.calculateFrequency(step, octave, alter),
|
|
|
- };
|
|
|
-
|
|
|
- halfTone = this.calculateHalfTone(step, octave, alter);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 解析附点
|
|
|
- const dots = noteEl.querySelectorAll('dot').length;
|
|
|
-
|
|
|
- // 解析类型
|
|
|
- const typeEl = noteEl.querySelector('type');
|
|
|
- const noteType = typeEl?.textContent ?? 'quarter';
|
|
|
-
|
|
|
- return {
|
|
|
- pitch,
|
|
|
- halfTone,
|
|
|
- length: { realValue, RealValue: realValue },
|
|
|
- isRestFlag: isRest,
|
|
|
- IsRest: isRest,
|
|
|
- IsGraceNote: noteEl.querySelector('grace') !== null,
|
|
|
- IsChordNote: noteEl.querySelector('chord') !== null,
|
|
|
- dots,
|
|
|
- DotsXml: dots,
|
|
|
- noteTypeXml: noteType,
|
|
|
- sourceMeasure,
|
|
|
- duration,
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- private calculateHalfTone(step: string, octave: number, alter: number): number {
|
|
|
- const stepToSemitone: Record<string, number> = {
|
|
|
- 'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11
|
|
|
- };
|
|
|
- return (octave + 1) * 12 + (stepToSemitone[step] ?? 0) + alter;
|
|
|
- }
|
|
|
-
|
|
|
- private calculateFrequency(step: string, octave: number, alter: number): number {
|
|
|
- const halfTone = this.calculateHalfTone(step, octave, alter);
|
|
|
- return 440 * Math.pow(2, (halfTone - 69) / 12);
|
|
|
- }
|
|
|
+function createTestNote(overrides: Partial<JianpuNote> = {}): JianpuNote {
|
|
|
+ return {
|
|
|
+ id: 'test-note-1',
|
|
|
+ pitch: 1,
|
|
|
+ octave: 0,
|
|
|
+ duration: 1,
|
|
|
+ realValue: 1,
|
|
|
+ isRest: false,
|
|
|
+ x: 50,
|
|
|
+ y: 60,
|
|
|
+ width: 20,
|
|
|
+ height: 24,
|
|
|
+ ...overrides,
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
-// ==================== 测试工具函数 ====================
|
|
|
-
|
|
|
/**
|
|
|
- * 加载测试XML文件
|
|
|
+ * 创建测试小节
|
|
|
*/
|
|
|
-function loadTestXML(filename: string): string {
|
|
|
- const filePath = join(__dirname, 'fixtures', filename);
|
|
|
- return readFileSync(filePath, 'utf-8');
|
|
|
+function createTestMeasure(overrides: Partial<JianpuMeasure> = {}): JianpuMeasure {
|
|
|
+ return {
|
|
|
+ index: 0,
|
|
|
+ measureNumber: 1,
|
|
|
+ timeSignature: { beats: 4, beatType: 4 },
|
|
|
+ keySignature: { fifths: 0, mode: 'major' },
|
|
|
+ voices: [[createTestNote()]],
|
|
|
+ x: 0,
|
|
|
+ y: 50,
|
|
|
+ width: 200,
|
|
|
+ height: 100,
|
|
|
+ hasBarline: true,
|
|
|
+ barlineType: 'single',
|
|
|
+ ...overrides,
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 解析MusicXML为模拟OSMD对象
|
|
|
+ * 创建测试乐谱
|
|
|
*/
|
|
|
-function parseXMLToOSMD(xmlString: string): any {
|
|
|
- const parser = new SimpleMusicXMLParser();
|
|
|
- return parser.parse(xmlString);
|
|
|
+function createTestScore(measures: JianpuMeasure[] = []): JianpuScore {
|
|
|
+ if (measures.length === 0) {
|
|
|
+ measures = [createTestMeasure()];
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: 'Test Score',
|
|
|
+ composer: 'Test',
|
|
|
+ tempo: 120,
|
|
|
+ measures,
|
|
|
+ systems: [{
|
|
|
+ index: 0,
|
|
|
+ measures,
|
|
|
+ x: 0,
|
|
|
+ y: 0,
|
|
|
+ width: 800,
|
|
|
+ height: 150,
|
|
|
+ }],
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
-// ==================== 测试用例 ====================
|
|
|
+// ==================== 测试套件 ====================
|
|
|
|
|
|
-describe('解析器集成测试', () => {
|
|
|
- let xmlParser: SimpleMusicXMLParser;
|
|
|
- let osmdParser: OSMDDataParser;
|
|
|
- let timeCalculator: TimeCalculator;
|
|
|
+describe('JianpuRenderer 集成测试', () => {
|
|
|
+ let container: HTMLElement;
|
|
|
+ let renderer: JianpuRenderer;
|
|
|
|
|
|
- beforeAll(() => {
|
|
|
- xmlParser = new SimpleMusicXMLParser();
|
|
|
- osmdParser = new OSMDDataParser();
|
|
|
- timeCalculator = new TimeCalculator();
|
|
|
- vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
- vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
+ beforeEach(() => {
|
|
|
+ container = document.createElement('div');
|
|
|
+ container.id = 'test-container';
|
|
|
+ document.body.appendChild(container);
|
|
|
});
|
|
|
|
|
|
- // ==================== basic.xml 测试 ====================
|
|
|
-
|
|
|
- describe('basic.xml - 基础简谱', () => {
|
|
|
- let xmlContent: string;
|
|
|
- let osmdObject: any;
|
|
|
+ afterEach(() => {
|
|
|
+ container.remove();
|
|
|
+ });
|
|
|
|
|
|
- beforeAll(() => {
|
|
|
- xmlContent = loadTestXML('basic.xml');
|
|
|
- osmdObject = parseXMLToOSMD(xmlContent);
|
|
|
- });
|
|
|
+ // ==================== 基础集成测试 ====================
|
|
|
|
|
|
- it('应该正确解析XML文件', () => {
|
|
|
- expect(osmdObject).toBeDefined();
|
|
|
- expect(osmdObject.Sheet).toBeDefined();
|
|
|
- expect(osmdObject.cursor).toBeDefined();
|
|
|
+ describe('基础集成', () => {
|
|
|
+ it('应该正确创建渲染器实例', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+ expect(renderer).toBeInstanceOf(JianpuRenderer);
|
|
|
});
|
|
|
|
|
|
- it('应该正确解析标题和作曲家', () => {
|
|
|
- expect(osmdObject.Sheet.Title.text).toBe('基础简谱测试');
|
|
|
- expect(osmdObject.Sheet.Composer.text).toBe('测试');
|
|
|
+ it('应该能获取所有扩展绘制器', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ // 验证所有新绘制器都可访问
|
|
|
+ expect(renderer.getSlurTieDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getTupletDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getRepeatDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getDynamicsDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getArticulationDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getChordDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getOrnamentDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getTempoDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getOctaveShiftDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getPedalDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getTablatureDrawer()).toBeDefined();
|
|
|
+ expect(renderer.getPercussionDrawer()).toBeDefined();
|
|
|
});
|
|
|
|
|
|
- it('应该正确解析4个小节', () => {
|
|
|
- expect(osmdObject.Sheet.SourceMeasures.length).toBe(4);
|
|
|
+ it('绘制器应该有正确的默认配置', () => {
|
|
|
+ renderer = createJianpuRenderer(container, {
|
|
|
+ musicColor: '#333333',
|
|
|
+ });
|
|
|
+
|
|
|
+ const articulationDrawer = renderer.getArticulationDrawer();
|
|
|
+ const config = articulationDrawer.getConfig();
|
|
|
+
|
|
|
+ expect(config.color).toBe('#333333');
|
|
|
});
|
|
|
+ });
|
|
|
|
|
|
- it('应该正确解析拍号', () => {
|
|
|
- const firstMeasure = osmdObject.Sheet.SourceMeasures[0];
|
|
|
- expect(firstMeasure.ActiveTimeSignature.numerator).toBe(4);
|
|
|
- expect(firstMeasure.ActiveTimeSignature.denominator).toBe(4);
|
|
|
- });
|
|
|
+ // ==================== 演奏技法集成测试 ====================
|
|
|
|
|
|
- it('应该正确解析速度', () => {
|
|
|
- const firstMeasure = osmdObject.Sheet.SourceMeasures[0];
|
|
|
- expect(firstMeasure.tempoInBPM).toBe(120);
|
|
|
+ describe('演奏技法集成', () => {
|
|
|
+ it('应该正确绘制带顿音的音符', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ modifiers: {
|
|
|
+ articulations: ['staccato'],
|
|
|
+ ornaments: [],
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const measure = createTestMeasure({
|
|
|
+ voices: [[note]],
|
|
|
+ });
|
|
|
+
|
|
|
+ const score = createTestScore([measure]);
|
|
|
+
|
|
|
+ // 手动设置score(模拟load后的状态)
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ // 执行渲染
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ // 验证SVG已创建
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
});
|
|
|
|
|
|
- describe('OSMDDataParser解析', () => {
|
|
|
- let score: any;
|
|
|
-
|
|
|
- beforeAll(() => {
|
|
|
- osmdParser = new OSMDDataParser();
|
|
|
- score = osmdParser.parse(osmdObject);
|
|
|
+ it('应该正确绘制带多个技法的音符', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ modifiers: {
|
|
|
+ articulations: ['staccato', 'accent'],
|
|
|
+ ornaments: [],
|
|
|
+ },
|
|
|
});
|
|
|
-
|
|
|
- it('应该返回JianpuScore对象', () => {
|
|
|
- expect(score).toBeDefined();
|
|
|
- expect(score.measures).toBeDefined();
|
|
|
- expect(score.tempo).toBeDefined();
|
|
|
+
|
|
|
+ const measure = createTestMeasure({
|
|
|
+ voices: [[note]],
|
|
|
});
|
|
|
+
|
|
|
+ const score = createTestScore([measure]);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
- it('应该解析出4个小节', () => {
|
|
|
- expect(score.measures.length).toBe(4);
|
|
|
- });
|
|
|
+ // ==================== 装饰音集成测试 ====================
|
|
|
|
|
|
- it('应该解析出正确的音符数量', () => {
|
|
|
- const stats = osmdParser.getStats();
|
|
|
- // basic.xml: 4小节,每小节4个音符,共16个(包含休止符)
|
|
|
- expect(stats.noteCount).toBeGreaterThanOrEqual(14);
|
|
|
+ describe('装饰音集成', () => {
|
|
|
+ it('应该正确绘制带颤音的音符', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ modifiers: {
|
|
|
+ articulations: [],
|
|
|
+ ornaments: ['trill'],
|
|
|
+ },
|
|
|
});
|
|
|
-
|
|
|
- it('第1小节应该包含do re mi fa', () => {
|
|
|
- const notes = score.measures[0].voices[0];
|
|
|
- expect(notes.length).toBeGreaterThanOrEqual(4);
|
|
|
-
|
|
|
- // 验证音高
|
|
|
- const pitches = notes.slice(0, 4).map((n: any) => n.pitch);
|
|
|
- expect(pitches).toContain(1); // do
|
|
|
- expect(pitches).toContain(2); // re
|
|
|
- expect(pitches).toContain(3); // mi
|
|
|
- expect(pitches).toContain(4); // fa
|
|
|
+
|
|
|
+ const measure = createTestMeasure({
|
|
|
+ voices: [[note]],
|
|
|
});
|
|
|
+
|
|
|
+ const score = createTestScore([measure]);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
+ });
|
|
|
|
|
|
- it('第3小节应该包含休止符', () => {
|
|
|
- const notes = score.measures[2].voices[0];
|
|
|
- const hasRest = notes.some((n: any) => n.isRest);
|
|
|
- expect(hasRest).toBe(true);
|
|
|
+ it('应该正确绘制带倚音的音符', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ modifiers: {
|
|
|
+ articulations: [],
|
|
|
+ ornaments: [],
|
|
|
+ graceNotes: {
|
|
|
+ notes: [
|
|
|
+ { pitch: 5, octave: 0, duration: 0.25 },
|
|
|
+ ],
|
|
|
+ slash: true,
|
|
|
+ },
|
|
|
+ },
|
|
|
});
|
|
|
-
|
|
|
- it('第4小节应该包含不同八度的音符', () => {
|
|
|
- const notes = score.measures[3].voices[0];
|
|
|
- const octaves = notes.map((n: any) => n.octave);
|
|
|
-
|
|
|
- // 应该有低音(-1)、中音(0)、高音(1)
|
|
|
- expect(octaves).toContain(-1); // G3 -> octave -1
|
|
|
- expect(octaves).toContain(0); // C4 -> octave 0
|
|
|
- expect(octaves).toContain(1); // E5, G5 -> octave 1
|
|
|
+
|
|
|
+ const measure = createTestMeasure({
|
|
|
+ voices: [[note]],
|
|
|
});
|
|
|
+
|
|
|
+ const score = createTestScore([measure]);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
});
|
|
|
+ });
|
|
|
|
|
|
- describe('TimeCalculator计算', () => {
|
|
|
- let score: any;
|
|
|
+ // ==================== 反复记号集成测试 ====================
|
|
|
|
|
|
- beforeAll(() => {
|
|
|
- osmdParser = new OSMDDataParser();
|
|
|
- score = osmdParser.parse(osmdObject);
|
|
|
- timeCalculator.calculateTimes(score);
|
|
|
+ describe('反复记号集成', () => {
|
|
|
+ it('应该正确绘制带跳房子的小节', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const measure = createTestMeasure({
|
|
|
+ volta: {
|
|
|
+ number: 1,
|
|
|
+ text: '1.',
|
|
|
+ type: 'start',
|
|
|
+ },
|
|
|
});
|
|
|
+
|
|
|
+ const score = createTestScore([measure]);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
+ });
|
|
|
|
|
|
- it('应该为所有音符计算时间', () => {
|
|
|
- for (const measure of score.measures) {
|
|
|
- for (const voice of measure.voices) {
|
|
|
- for (const note of voice) {
|
|
|
- expect(note.startTime).toBeDefined();
|
|
|
- expect(note.endTime).toBeDefined();
|
|
|
- expect(note.endTime).toBeGreaterThan(note.startTime);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ it('应该正确绘制带反复标记的小节', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const measure = createTestMeasure({
|
|
|
+ repeatMark: {
|
|
|
+ type: 'dc',
|
|
|
+ text: 'D.C.',
|
|
|
+ },
|
|
|
});
|
|
|
+
|
|
|
+ const score = createTestScore([measure]);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
- it('BPM=120时四分音符应该是0.5秒', () => {
|
|
|
- const firstNote = score.measures[0].voices[0][0];
|
|
|
- const duration = firstNote.endTime - firstNote.startTime;
|
|
|
- expect(duration).toBeCloseTo(0.5, 2);
|
|
|
- });
|
|
|
+ // ==================== 速度标记集成测试 ====================
|
|
|
|
|
|
- it('总时长应该约为8秒(4小节×4拍×0.5秒)', () => {
|
|
|
- const result = timeCalculator.getResult();
|
|
|
- expect(result.totalDuration).toBeCloseTo(8.0, 1);
|
|
|
- });
|
|
|
+ describe('速度标记集成', () => {
|
|
|
+ it('应该在第一行绘制速度标记', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const score = createTestScore();
|
|
|
+ score.tempo = 120;
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
});
|
|
|
});
|
|
|
|
|
|
- // ==================== mixed-durations.xml 测试 ====================
|
|
|
-
|
|
|
- describe('mixed-durations.xml - 混合时值', () => {
|
|
|
- let xmlContent: string;
|
|
|
- let osmdObject: any;
|
|
|
+ // ==================== 绘制器独立使用测试 ====================
|
|
|
|
|
|
- beforeAll(() => {
|
|
|
- xmlContent = loadTestXML('mixed-durations.xml');
|
|
|
- osmdObject = parseXMLToOSMD(xmlContent);
|
|
|
+ describe('绘制器独立使用', () => {
|
|
|
+ it('应该能独立使用连线绘制器', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const slurTieDrawer = renderer.getSlurTieDrawer();
|
|
|
+ const tieGroup = slurTieDrawer.drawTie(
|
|
|
+ { x: 50, y: 60 },
|
|
|
+ { x: 100, y: 60 },
|
|
|
+ 'above'
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(tieGroup.tagName).toBe('g');
|
|
|
+ expect(tieGroup.getAttribute('class')).toContain('vf-tie');
|
|
|
});
|
|
|
|
|
|
- it('应该正确解析XML文件', () => {
|
|
|
- expect(osmdObject).toBeDefined();
|
|
|
- expect(osmdObject.Sheet.SourceMeasures.length).toBe(6);
|
|
|
+ it('应该能独立使用力度绘制器', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const dynamicsDrawer = renderer.getDynamicsDrawer();
|
|
|
+ const dynamicGroup = dynamicsDrawer.drawDynamic('f', 100, 80);
|
|
|
+
|
|
|
+ expect(dynamicGroup.tagName).toBe('g');
|
|
|
+ expect(dynamicGroup.getAttribute('class')).toContain('vf-dynamic');
|
|
|
});
|
|
|
|
|
|
- describe('OSMDDataParser解析', () => {
|
|
|
- let score: any;
|
|
|
-
|
|
|
- beforeAll(() => {
|
|
|
- osmdParser = new OSMDDataParser();
|
|
|
- score = osmdParser.parse(osmdObject);
|
|
|
- });
|
|
|
-
|
|
|
- it('应该解析出6个小节', () => {
|
|
|
- expect(score.measures.length).toBe(6);
|
|
|
- });
|
|
|
-
|
|
|
- it('第1小节应该包含8个八分音符', () => {
|
|
|
- const notes = score.measures[0].voices[0];
|
|
|
- expect(notes.length).toBe(8);
|
|
|
-
|
|
|
- // 验证时值都是0.5(八分音符)
|
|
|
- notes.forEach((note: any) => {
|
|
|
- expect(note.duration).toBeCloseTo(0.5, 2);
|
|
|
- });
|
|
|
- });
|
|
|
+ it('应该能独立使用和弦绘制器', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const chordDrawer = renderer.getChordDrawer();
|
|
|
+ const notes: JianpuNote[] = [
|
|
|
+ createTestNote({ pitch: 1, y: 60 }),
|
|
|
+ createTestNote({ pitch: 3, y: 60 }),
|
|
|
+ createTestNote({ pitch: 5, y: 60 }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 使用 drawChordFromNotes 方法
|
|
|
+ const chordGroup = chordDrawer.drawChordFromNotes(notes, 100, 60);
|
|
|
+
|
|
|
+ expect(chordGroup.tagName).toBe('g');
|
|
|
+ expect(chordGroup.getAttribute('class')).toContain('vf-chord');
|
|
|
+ });
|
|
|
|
|
|
- it('第2小节应该包含2个二分音符', () => {
|
|
|
- const notes = score.measures[1].voices[0];
|
|
|
- expect(notes.length).toBe(2);
|
|
|
-
|
|
|
- notes.forEach((note: any) => {
|
|
|
- expect(note.duration).toBeCloseTo(2.0, 2);
|
|
|
- });
|
|
|
- });
|
|
|
+ it('应该能独立使用八度记号绘制器', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const octaveDrawer = renderer.getOctaveShiftDrawer();
|
|
|
+ const octaveGroup = octaveDrawer.draw8va(50, 200, 60);
|
|
|
+
|
|
|
+ expect(octaveGroup.tagName).toBe('g');
|
|
|
+ expect(octaveGroup.getAttribute('class')).toContain('vf-octave-8va');
|
|
|
+ });
|
|
|
|
|
|
- it('第3小节应该包含1个全音符', () => {
|
|
|
- const notes = score.measures[2].voices[0];
|
|
|
- expect(notes.length).toBe(1);
|
|
|
- expect(notes[0].duration).toBeCloseTo(4.0, 2);
|
|
|
+ it('应该能独立使用踏板绘制器', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const pedalDrawer = renderer.getPedalDrawer();
|
|
|
+ const pedalGroup = pedalDrawer.drawPedalRange({
|
|
|
+ type: 'sustain',
|
|
|
+ startX: 50,
|
|
|
+ endX: 200,
|
|
|
+ y: 100,
|
|
|
});
|
|
|
+
|
|
|
+ expect(pedalGroup.tagName).toBe('g');
|
|
|
+ expect(pedalGroup.getAttribute('class')).toContain('vf-pedal');
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
- it('第4小节应该包含附点音符', () => {
|
|
|
- const notes = score.measures[3].voices[0];
|
|
|
-
|
|
|
- // 查找附点四分音符(时值1.5)
|
|
|
- const dottedQuarter = notes.find((n: any) => Math.abs(n.duration - 1.5) < 0.1);
|
|
|
- expect(dottedQuarter).toBeDefined();
|
|
|
- expect(dottedQuarter.dots).toBe(1);
|
|
|
- });
|
|
|
+ // ==================== 多小节渲染测试 ====================
|
|
|
|
|
|
- it('第6小节应该包含十六分音符', () => {
|
|
|
- const notes = score.measures[5].voices[0];
|
|
|
-
|
|
|
- // 查找十六分音符(时值0.25)
|
|
|
- const sixteenthNotes = notes.filter((n: any) => Math.abs(n.duration - 0.25) < 0.1);
|
|
|
- expect(sixteenthNotes.length).toBe(4);
|
|
|
- });
|
|
|
+ describe('多小节渲染', () => {
|
|
|
+ it('应该正确渲染多个小节', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const measures = [
|
|
|
+ createTestMeasure({ index: 0, measureNumber: 1, x: 0 }),
|
|
|
+ createTestMeasure({ index: 1, measureNumber: 2, x: 200 }),
|
|
|
+ createTestMeasure({ index: 2, measureNumber: 3, x: 400 }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const score = createTestScore(measures);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
+
|
|
|
+ // 验证有3个小节容器
|
|
|
+ const measureGroups = svg!.querySelectorAll('.vf-measure');
|
|
|
+ expect(measureGroups.length).toBe(3);
|
|
|
});
|
|
|
|
|
|
- describe('TimeCalculator计算', () => {
|
|
|
- let score: any;
|
|
|
+ it('应该正确渲染带各种修饰的多小节', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const measures = [
|
|
|
+ createTestMeasure({
|
|
|
+ index: 0,
|
|
|
+ measureNumber: 1,
|
|
|
+ x: 0,
|
|
|
+ voices: [[createTestNote({
|
|
|
+ modifiers: {
|
|
|
+ articulations: ['staccato'],
|
|
|
+ ornaments: ['trill'],
|
|
|
+ },
|
|
|
+ })]],
|
|
|
+ }),
|
|
|
+ createTestMeasure({
|
|
|
+ index: 1,
|
|
|
+ measureNumber: 2,
|
|
|
+ x: 200,
|
|
|
+ volta: { number: 1, text: '1.', type: 'start' },
|
|
|
+ }),
|
|
|
+ createTestMeasure({
|
|
|
+ index: 2,
|
|
|
+ measureNumber: 3,
|
|
|
+ x: 400,
|
|
|
+ repeatMark: { type: 'fine', text: 'Fine' },
|
|
|
+ }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const score = createTestScore(measures);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const svg = renderer.getSVGElement();
|
|
|
+ expect(svg).not.toBeNull();
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
- beforeAll(() => {
|
|
|
- osmdParser = new OSMDDataParser();
|
|
|
- score = osmdParser.parse(osmdObject);
|
|
|
- timeCalculator.calculateTimes(score);
|
|
|
- });
|
|
|
+ // ==================== 配置更新测试 ====================
|
|
|
|
|
|
- it('八分音符时长应该是0.3秒(BPM=100)', () => {
|
|
|
- const eighthNote = score.measures[0].voices[0][0];
|
|
|
- const duration = eighthNote.endTime - eighthNote.startTime;
|
|
|
- // BPM=100, 四分音符=0.6秒, 八分音符=0.3秒
|
|
|
- expect(duration).toBeCloseTo(0.3, 2);
|
|
|
+ describe('配置更新', () => {
|
|
|
+ it('更新配置后应该正确重新渲染', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const score = createTestScore();
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ // 更新配置
|
|
|
+ renderer.updateConfig({
|
|
|
+ noteColor: '#0000ff',
|
|
|
});
|
|
|
+
|
|
|
+ // 配置应该已更新
|
|
|
+ const config = renderer.getConfig();
|
|
|
+ expect(config.noteColor).toBe('#0000ff');
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
- it('全音符时长应该是2.4秒(BPM=100)', () => {
|
|
|
- const wholeNote = score.measures[2].voices[0][0];
|
|
|
- const duration = wholeNote.endTime - wholeNote.startTime;
|
|
|
- // BPM=100, 四分音符=0.6秒, 全音符=2.4秒
|
|
|
- expect(duration).toBeCloseTo(2.4, 2);
|
|
|
- });
|
|
|
+ // ==================== 统计信息测试 ====================
|
|
|
|
|
|
- it('总时长应该约为14.4秒(6小节×4拍×0.6秒)', () => {
|
|
|
- const result = timeCalculator.getResult();
|
|
|
- expect(result.totalDuration).toBeCloseTo(14.4, 1);
|
|
|
- });
|
|
|
+ describe('统计信息', () => {
|
|
|
+ it('应该正确返回渲染统计', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
+
|
|
|
+ const measures = [
|
|
|
+ createTestMeasure({
|
|
|
+ voices: [[
|
|
|
+ createTestNote({ id: 'n1' }),
|
|
|
+ createTestNote({ id: 'n2' }),
|
|
|
+ ]],
|
|
|
+ }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const score = createTestScore(measures);
|
|
|
+ (renderer as any).score = score;
|
|
|
+
|
|
|
+ renderer.render();
|
|
|
+
|
|
|
+ const stats = renderer.getStats();
|
|
|
+ expect(stats.drawTime).toBeGreaterThan(0);
|
|
|
+ expect(stats.layoutTime).toBeGreaterThan(0);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
// ==================== 性能测试 ====================
|
|
|
|
|
|
describe('性能测试', () => {
|
|
|
- it('解析basic.xml应该在100ms内完成', () => {
|
|
|
- const xmlContent = loadTestXML('basic.xml');
|
|
|
+ it('渲染10个小节应该在100ms内完成', () => {
|
|
|
+ renderer = createJianpuRenderer(container);
|
|
|
|
|
|
- const startTime = performance.now();
|
|
|
- const osmdObject = parseXMLToOSMD(xmlContent);
|
|
|
- const parser = new OSMDDataParser();
|
|
|
- const score = parser.parse(osmdObject);
|
|
|
- const calc = new TimeCalculator();
|
|
|
- calc.calculateTimes(score);
|
|
|
- const endTime = performance.now();
|
|
|
+ const measures: JianpuMeasure[] = [];
|
|
|
+ for (let i = 0; i < 10; i++) {
|
|
|
+ measures.push(createTestMeasure({
|
|
|
+ index: i,
|
|
|
+ measureNumber: i + 1,
|
|
|
+ x: i * 200,
|
|
|
+ voices: [[
|
|
|
+ createTestNote({ id: `n${i}-1` }),
|
|
|
+ createTestNote({ id: `n${i}-2` }),
|
|
|
+ ]],
|
|
|
+ }));
|
|
|
+ }
|
|
|
|
|
|
- expect(endTime - startTime).toBeLessThan(100);
|
|
|
- });
|
|
|
-
|
|
|
- it('解析mixed-durations.xml应该在100ms内完成', () => {
|
|
|
- const xmlContent = loadTestXML('mixed-durations.xml');
|
|
|
+ const score = createTestScore(measures);
|
|
|
+ (renderer as any).score = score;
|
|
|
|
|
|
const startTime = performance.now();
|
|
|
- const osmdObject = parseXMLToOSMD(xmlContent);
|
|
|
- const parser = new OSMDDataParser();
|
|
|
- const score = parser.parse(osmdObject);
|
|
|
- const calc = new TimeCalculator();
|
|
|
- calc.calculateTimes(score);
|
|
|
+ renderer.render();
|
|
|
const endTime = performance.now();
|
|
|
|
|
|
expect(endTime - startTime).toBeLessThan(100);
|
|
|
});
|
|
|
});
|
|
|
-
|
|
|
- // ==================== 边界情况测试 ====================
|
|
|
-
|
|
|
- describe('边界情况', () => {
|
|
|
- it('空XML应该抛出错误', () => {
|
|
|
- expect(() => parseXMLToOSMD('')).toThrow();
|
|
|
- });
|
|
|
-
|
|
|
- it('无效XML应该抛出错误', () => {
|
|
|
- expect(() => parseXMLToOSMD('<invalid>')).toThrow();
|
|
|
- });
|
|
|
- });
|
|
|
-});
|
|
|
-
|
|
|
-// ==================== DivisionsHandler 集成测试 ====================
|
|
|
-
|
|
|
-describe('DivisionsHandler 集成测试', () => {
|
|
|
- it('应该正确处理basic.xml的divisions=256', () => {
|
|
|
- const handler = new DivisionsHandler();
|
|
|
- handler.setDivisions(256);
|
|
|
-
|
|
|
- // 四分音符 duration=256
|
|
|
- expect(handler.toRealValue(256)).toBe(1.0);
|
|
|
-
|
|
|
- // 八分音符 duration=128
|
|
|
- expect(handler.toRealValue(128)).toBe(0.5);
|
|
|
-
|
|
|
- // 二分音符 duration=512
|
|
|
- expect(handler.toRealValue(512)).toBe(2.0);
|
|
|
-
|
|
|
- // 全音符 duration=1024
|
|
|
- expect(handler.toRealValue(1024)).toBe(4.0);
|
|
|
-
|
|
|
- // 附点四分音符 duration=384
|
|
|
- expect(handler.toRealValue(384)).toBe(1.5);
|
|
|
-
|
|
|
- // 十六分音符 duration=64
|
|
|
- expect(handler.toRealValue(64)).toBe(0.25);
|
|
|
- });
|
|
|
});
|