|
|
@@ -0,0 +1,1062 @@
|
|
|
+/**
|
|
|
+ * 兼容性测试
|
|
|
+ *
|
|
|
+ * @description 测试简谱渲染引擎在各种场景下的兼容性
|
|
|
+ *
|
|
|
+ * 测试范围:
|
|
|
+ * 1. 不同浏览器测试 - Chrome、Edge、Safari API兼容性检测
|
|
|
+ * 2. 不同曲谱测试 - 简单、复杂、长曲谱、多声部
|
|
|
+ * 3. 边界情况测试 - 极短/极长音符、极端速度、变拍曲谱
|
|
|
+ */
|
|
|
+
|
|
|
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
+import { NoteDrawer } from '../core/drawer/NoteDrawer';
|
|
|
+import { LineDrawer, calcExtensionLineCount, calcUnderlineCount } from '../core/drawer/LineDrawer';
|
|
|
+import { MeasureLayoutEngine } from '../core/layout/MeasureLayoutEngine';
|
|
|
+import { SystemLayoutEngine } from '../core/layout/SystemLayoutEngine';
|
|
|
+import { MultiVoiceAligner } from '../core/layout/MultiVoiceAligner';
|
|
|
+import { NotePositionCalculator } from '../core/layout/NotePositionCalculator';
|
|
|
+import { DivisionsHandler } from '../core/parser/DivisionsHandler';
|
|
|
+import { TimeCalculator, realValueToSeconds, getFixTime } from '../core/parser/TimeCalculator';
|
|
|
+import { JianpuNote, createDefaultNote } from '../models/JianpuNote';
|
|
|
+import { JianpuMeasure, createDefaultMeasure } from '../models/JianpuMeasure';
|
|
|
+import { testUtils } from './setup';
|
|
|
+
|
|
|
+// ==================== 测试辅助函数 ====================
|
|
|
+
|
|
|
+/**
|
|
|
+ * 创建测试音符
|
|
|
+ */
|
|
|
+function createTestNote(options: Partial<JianpuNote> & { timestamp?: number } = {}): JianpuNote {
|
|
|
+ const note = createDefaultNote();
|
|
|
+ return {
|
|
|
+ ...note,
|
|
|
+ id: options.id || `test-note-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
+ pitch: options.pitch ?? 1,
|
|
|
+ octave: options.octave ?? 0,
|
|
|
+ duration: options.duration ?? 1,
|
|
|
+ x: options.x ?? 100,
|
|
|
+ y: options.y ?? 50,
|
|
|
+ isRest: options.isRest ?? false,
|
|
|
+ accidental: options.accidental,
|
|
|
+ dots: options.dots ?? 0,
|
|
|
+ startTime: options.startTime ?? 0,
|
|
|
+ timestamp: options.timestamp ?? options.startTime ?? 0,
|
|
|
+ ...options,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 创建测试小节
|
|
|
+ */
|
|
|
+function createTestMeasure(options: {
|
|
|
+ index?: number;
|
|
|
+ beats?: number;
|
|
|
+ beatType?: number;
|
|
|
+ notes?: JianpuNote[];
|
|
|
+ width?: number;
|
|
|
+} = {}): JianpuMeasure {
|
|
|
+ const measure = createDefaultMeasure(options.index ?? 1);
|
|
|
+ measure.timeSignature = {
|
|
|
+ beats: options.beats ?? 4,
|
|
|
+ beatType: options.beatType ?? 4,
|
|
|
+ };
|
|
|
+ if (options.notes) {
|
|
|
+ measure.voices = [options.notes];
|
|
|
+ }
|
|
|
+ if (options.width) {
|
|
|
+ measure.width = options.width;
|
|
|
+ }
|
|
|
+ return measure;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 创建SVG容器
|
|
|
+ */
|
|
|
+function createSVGContainer(): SVGSVGElement {
|
|
|
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
+ svg.setAttribute('width', '1200');
|
|
|
+ svg.setAttribute('height', '800');
|
|
|
+ document.body.appendChild(svg);
|
|
|
+ return svg;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 清理SVG容器
|
|
|
+ */
|
|
|
+function cleanupSVG(svg: SVGSVGElement): void {
|
|
|
+ svg.remove();
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 生成大量测试小节(用于性能测试)
|
|
|
+ */
|
|
|
+function generateManyMeasures(count: number, notesPerMeasure: number = 4): JianpuMeasure[] {
|
|
|
+ const measures: JianpuMeasure[] = [];
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
+ const notes: JianpuNote[] = [];
|
|
|
+ for (let j = 0; j < notesPerMeasure; j++) {
|
|
|
+ notes.push(createTestNote({
|
|
|
+ id: `note-${i}-${j}`,
|
|
|
+ pitch: (j % 7) + 1,
|
|
|
+ octave: Math.floor(j / 7) - 1,
|
|
|
+ duration: 1,
|
|
|
+ startTime: j * 0.25,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ measures.push(createTestMeasure({
|
|
|
+ index: i + 1,
|
|
|
+ notes,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ return measures;
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 1. 浏览器兼容性测试 ====================
|
|
|
+
|
|
|
+describe('1. 浏览器兼容性测试', () => {
|
|
|
+ describe('1.1 DOM API兼容性', () => {
|
|
|
+ it('应该支持 document.createElementNS', () => {
|
|
|
+ expect(typeof document.createElementNS).toBe('function');
|
|
|
+
|
|
|
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
+ expect(svg).toBeTruthy();
|
|
|
+ expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 SVG 元素创建', () => {
|
|
|
+ const svg = createSVGContainer();
|
|
|
+
|
|
|
+ // 测试各种SVG元素的创建
|
|
|
+ const elements = ['g', 'text', 'circle', 'rect', 'line', 'path'];
|
|
|
+ elements.forEach(tagName => {
|
|
|
+ const el = document.createElementNS('http://www.w3.org/2000/svg', tagName);
|
|
|
+ expect(el).toBeTruthy();
|
|
|
+ expect(el.tagName.toLowerCase()).toBe(tagName);
|
|
|
+ });
|
|
|
+
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 classList API', () => {
|
|
|
+ const div = document.createElement('div');
|
|
|
+
|
|
|
+ expect(typeof div.classList.add).toBe('function');
|
|
|
+ expect(typeof div.classList.remove).toBe('function');
|
|
|
+ expect(typeof div.classList.contains).toBe('function');
|
|
|
+
|
|
|
+ div.classList.add('test-class');
|
|
|
+ expect(div.classList.contains('test-class')).toBe(true);
|
|
|
+
|
|
|
+ div.classList.remove('test-class');
|
|
|
+ expect(div.classList.contains('test-class')).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 dataset API', () => {
|
|
|
+ const div = document.createElement('div');
|
|
|
+
|
|
|
+ div.dataset.testValue = 'hello';
|
|
|
+ expect(div.dataset.testValue).toBe('hello');
|
|
|
+ expect(div.getAttribute('data-test-value')).toBe('hello');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 querySelector 和 querySelectorAll', () => {
|
|
|
+ const container = document.createElement('div');
|
|
|
+ container.innerHTML = '<span class="test">1</span><span class="test">2</span>';
|
|
|
+
|
|
|
+ expect(container.querySelector('.test')).toBeTruthy();
|
|
|
+ expect(container.querySelectorAll('.test').length).toBe(2);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('1.2 SVG 属性兼容性', () => {
|
|
|
+ let svg: SVGSVGElement;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ svg = createSVGContainer();
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 transform 属性', () => {
|
|
|
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
|
+ g.setAttribute('transform', 'translate(100, 50)');
|
|
|
+ svg.appendChild(g);
|
|
|
+
|
|
|
+ expect(g.getAttribute('transform')).toBe('translate(100, 50)');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 text 元素的 text-anchor 属性', () => {
|
|
|
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
|
+ text.setAttribute('text-anchor', 'middle');
|
|
|
+ svg.appendChild(text);
|
|
|
+
|
|
|
+ expect(text.getAttribute('text-anchor')).toBe('middle');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 fill 和 stroke 属性', () => {
|
|
|
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
|
+ rect.setAttribute('fill', '#ff0000');
|
|
|
+ rect.setAttribute('stroke', '#000000');
|
|
|
+ rect.setAttribute('stroke-width', '2');
|
|
|
+ svg.appendChild(rect);
|
|
|
+
|
|
|
+ expect(rect.getAttribute('fill')).toBe('#ff0000');
|
|
|
+ expect(rect.getAttribute('stroke')).toBe('#000000');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 font 相关属性', () => {
|
|
|
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
|
+ text.setAttribute('font-size', '20');
|
|
|
+ text.setAttribute('font-family', 'Arial');
|
|
|
+ text.setAttribute('font-weight', 'bold');
|
|
|
+ svg.appendChild(text);
|
|
|
+
|
|
|
+ expect(text.getAttribute('font-size')).toBe('20');
|
|
|
+ expect(text.getAttribute('font-family')).toBe('Arial');
|
|
|
+ expect(text.getAttribute('font-weight')).toBe('bold');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('1.3 JavaScript API兼容性', () => {
|
|
|
+ it('应该支持 Array 方法', () => {
|
|
|
+ const arr = [1, 2, 3, 4, 5];
|
|
|
+
|
|
|
+ expect(typeof arr.map).toBe('function');
|
|
|
+ expect(typeof arr.filter).toBe('function');
|
|
|
+ expect(typeof arr.reduce).toBe('function');
|
|
|
+ expect(typeof arr.forEach).toBe('function');
|
|
|
+ expect(typeof arr.find).toBe('function');
|
|
|
+ expect(typeof arr.findIndex).toBe('function');
|
|
|
+ expect(typeof arr.includes).toBe('function');
|
|
|
+ expect(typeof arr.some).toBe('function');
|
|
|
+ expect(typeof arr.every).toBe('function');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 Object 方法', () => {
|
|
|
+ expect(typeof Object.keys).toBe('function');
|
|
|
+ expect(typeof Object.values).toBe('function');
|
|
|
+ expect(typeof Object.entries).toBe('function');
|
|
|
+ expect(typeof Object.assign).toBe('function');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 Math 方法', () => {
|
|
|
+ expect(typeof Math.floor).toBe('function');
|
|
|
+ expect(typeof Math.ceil).toBe('function');
|
|
|
+ expect(typeof Math.round).toBe('function');
|
|
|
+ expect(typeof Math.abs).toBe('function');
|
|
|
+ expect(typeof Math.min).toBe('function');
|
|
|
+ expect(typeof Math.max).toBe('function');
|
|
|
+ expect(typeof Math.log2).toBe('function');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 String 方法', () => {
|
|
|
+ const str = 'test string';
|
|
|
+
|
|
|
+ expect(typeof str.includes).toBe('function');
|
|
|
+ expect(typeof str.startsWith).toBe('function');
|
|
|
+ expect(typeof str.endsWith).toBe('function');
|
|
|
+ expect(typeof str.padStart).toBe('function');
|
|
|
+ expect(typeof str.padEnd).toBe('function');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该支持 Map 和 Set', () => {
|
|
|
+ const map = new Map();
|
|
|
+ map.set('key', 'value');
|
|
|
+ expect(map.get('key')).toBe('value');
|
|
|
+
|
|
|
+ const set = new Set();
|
|
|
+ set.add('item');
|
|
|
+ expect(set.has('item')).toBe(true);
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ==================== 2. 不同曲谱测试 ====================
|
|
|
+
|
|
|
+describe('2. 不同曲谱测试', () => {
|
|
|
+ describe('2.1 简单曲谱测试', () => {
|
|
|
+ let noteDrawer: NoteDrawer;
|
|
|
+ let lineDrawer: LineDrawer;
|
|
|
+ let svg: SVGSVGElement;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ noteDrawer = new NoteDrawer({
|
|
|
+ noteFontSize: 20,
|
|
|
+ fontFamily: 'Arial',
|
|
|
+ noteColor: '#000',
|
|
|
+ });
|
|
|
+ lineDrawer = new LineDrawer({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ noteFontSize: 20,
|
|
|
+ lineColor: '#000',
|
|
|
+ });
|
|
|
+ svg = createSVGContainer();
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染只有四分音符的曲谱', () => {
|
|
|
+ // 模拟 basic.xml 的内容
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', pitch: 1, duration: 1, x: 50 }), // do
|
|
|
+ createTestNote({ id: 'n2', pitch: 2, duration: 1, x: 100 }), // re
|
|
|
+ createTestNote({ id: 'n3', pitch: 3, duration: 1, x: 150 }), // mi
|
|
|
+ createTestNote({ id: 'n4', pitch: 4, duration: 1, x: 200 }), // fa
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+
|
|
|
+ const noteElements = svg.querySelectorAll('.vf-stavenote');
|
|
|
+ expect(noteElements.length).toBe(4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染带休止符的曲谱', () => {
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', pitch: 1, duration: 1, x: 50 }),
|
|
|
+ createTestNote({ id: 'n2', pitch: 0, duration: 1, x: 100, isRest: true }),
|
|
|
+ createTestNote({ id: 'n3', pitch: 3, duration: 1, x: 150 }),
|
|
|
+ createTestNote({ id: 'n4', pitch: 0, duration: 1, x: 200, isRest: true }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+
|
|
|
+ const restElements = svg.querySelectorAll('[data-rest="true"]');
|
|
|
+ expect(restElements.length).toBe(2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染高低音区的曲谱', () => {
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', pitch: 5, octave: -1, x: 50 }), // 低音sol
|
|
|
+ createTestNote({ id: 'n2', pitch: 1, octave: 0, x: 100 }), // 中音do
|
|
|
+ createTestNote({ id: 'n3', pitch: 3, octave: 1, x: 150 }), // 高音mi
|
|
|
+ createTestNote({ id: 'n4', pitch: 5, octave: 2, x: 200 }), // 高两个八度sol
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(svg.querySelectorAll('.vf-low-dot').length).toBe(1); // 1个低音点
|
|
|
+ expect(svg.querySelectorAll('.vf-high-dot').length).toBe(3); // 1+2个高音点
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('2.2 复杂曲谱测试', () => {
|
|
|
+ let noteDrawer: NoteDrawer;
|
|
|
+ let lineDrawer: LineDrawer;
|
|
|
+ let svg: SVGSVGElement;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ noteDrawer = new NoteDrawer({
|
|
|
+ noteFontSize: 20,
|
|
|
+ fontFamily: 'Arial',
|
|
|
+ noteColor: '#000',
|
|
|
+ });
|
|
|
+ lineDrawer = new LineDrawer({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ noteFontSize: 20,
|
|
|
+ lineColor: '#000',
|
|
|
+ });
|
|
|
+ svg = createSVGContainer();
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染带升降号的曲谱', () => {
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', pitch: 5, accidental: 'sharp', x: 50 }), // #sol
|
|
|
+ createTestNote({ id: 'n2', pitch: 4, accidental: 'sharp', x: 100 }), // #fa
|
|
|
+ createTestNote({ id: 'n3', pitch: 7, accidental: 'flat', x: 150 }), // bsi
|
|
|
+ createTestNote({ id: 'n4', pitch: 4, accidental: 'natural', x: 200 }), // 还原fa
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+
|
|
|
+ const accidentals = svg.querySelectorAll('.vf-accidental');
|
|
|
+ expect(accidentals.length).toBe(4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染混合时值的曲谱', () => {
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', pitch: 1, duration: 0.5, x: 50 }), // 八分音符
|
|
|
+ createTestNote({ id: 'n2', pitch: 2, duration: 0.25, x: 80 }), // 十六分音符
|
|
|
+ createTestNote({ id: 'n3', pitch: 3, duration: 2, x: 110 }), // 二分音符
|
|
|
+ createTestNote({ id: 'n4', pitch: 4, duration: 4, x: 210 }), // 全音符
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const noteGroup = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(noteGroup);
|
|
|
+
|
|
|
+ const lineGroup = lineDrawer.drawDurationLines(note, 50);
|
|
|
+ svg.appendChild(lineGroup);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 检查减时线
|
|
|
+ expect(svg.querySelectorAll('.vf-underline').length).toBeGreaterThanOrEqual(3);
|
|
|
+ // 检查增时线
|
|
|
+ expect(svg.querySelectorAll('.vf-extension-line').length).toBeGreaterThanOrEqual(4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染附点音符', () => {
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', pitch: 1, duration: 1.5, dots: 1, x: 50 }), // 附点四分音符
|
|
|
+ createTestNote({ id: 'n2', pitch: 2, duration: 1.75, dots: 2, x: 150 }), // 双附点四分音符
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+
|
|
|
+ const durationDots = svg.querySelectorAll('.vf-duration-dot');
|
|
|
+ expect(durationDots.length).toBe(3); // 1 + 2
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('2.3 多声部曲谱测试', () => {
|
|
|
+ it('应该正确处理两声部对齐', () => {
|
|
|
+ const aligner = new MultiVoiceAligner();
|
|
|
+
|
|
|
+ // 创建两个声部的音符(使用timestamp而不是startTime)
|
|
|
+ const voice1Notes = [
|
|
|
+ createTestNote({ id: 'v1n1', timestamp: 0, x: 100 }),
|
|
|
+ createTestNote({ id: 'v1n2', timestamp: 1, x: 150 }),
|
|
|
+ createTestNote({ id: 'v1n3', timestamp: 2, x: 200 }),
|
|
|
+ createTestNote({ id: 'v1n4', timestamp: 3, x: 250 }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const voice2Notes = [
|
|
|
+ createTestNote({ id: 'v2n1', timestamp: 0, x: 105 }),
|
|
|
+ createTestNote({ id: 'v2n2', timestamp: 2, x: 195 }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const measure = createDefaultMeasure(1);
|
|
|
+ measure.voices = [voice1Notes, voice2Notes];
|
|
|
+
|
|
|
+ // 对齐 - 使用正确的方法名 alignVoices
|
|
|
+ aligner.alignVoices(measure);
|
|
|
+
|
|
|
+ // 相同时间戳的音符应该有相同的X坐标
|
|
|
+ expect(voice1Notes[0].x).toBe(voice2Notes[0].x);
|
|
|
+ expect(voice1Notes[2].x).toBe(voice2Notes[1].x);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理三声部对齐', () => {
|
|
|
+ const aligner = new MultiVoiceAligner();
|
|
|
+
|
|
|
+ const voice1Notes = [
|
|
|
+ createTestNote({ id: 'v1n1', timestamp: 0, x: 100 }),
|
|
|
+ ];
|
|
|
+ const voice2Notes = [
|
|
|
+ createTestNote({ id: 'v2n1', timestamp: 0, x: 110 }),
|
|
|
+ ];
|
|
|
+ const voice3Notes = [
|
|
|
+ createTestNote({ id: 'v3n1', timestamp: 0, x: 120 }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const measure = createDefaultMeasure(1);
|
|
|
+ measure.voices = [voice1Notes, voice2Notes, voice3Notes];
|
|
|
+
|
|
|
+ // 使用正确的方法名 alignVoices
|
|
|
+ aligner.alignVoices(measure);
|
|
|
+
|
|
|
+ // 三个声部的音符应该有相同的X坐标
|
|
|
+ expect(voice1Notes[0].x).toBe(voice2Notes[0].x);
|
|
|
+ expect(voice2Notes[0].x).toBe(voice3Notes[0].x);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确计算多声部行高', () => {
|
|
|
+ const notePositionCalc = new NotePositionCalculator({
|
|
|
+ noteFontSize: 20,
|
|
|
+ voiceSpacing: 40,
|
|
|
+ });
|
|
|
+
|
|
|
+ const baseY = 100;
|
|
|
+
|
|
|
+ // 单声部高度 - calculateVoiceY(baseY, voiceIndex, voiceCount)
|
|
|
+ const singleVoiceY = notePositionCalc.calculateVoiceY(baseY, 0, 1);
|
|
|
+
|
|
|
+ // 双声部高度
|
|
|
+ const voice1Y = notePositionCalc.calculateVoiceY(baseY, 0, 2);
|
|
|
+ const voice2Y = notePositionCalc.calculateVoiceY(baseY, 1, 2);
|
|
|
+
|
|
|
+ // 单声部应该返回baseY
|
|
|
+ expect(singleVoiceY).toBe(baseY);
|
|
|
+
|
|
|
+ // 双声部的两个声部应该有不同的Y坐标
|
|
|
+ expect(voice1Y).not.toBe(voice2Y);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('2.4 长曲谱测试(100+小节)', () => {
|
|
|
+ it('应该能够处理100个小节的布局', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measures = generateManyMeasures(100);
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ layoutEngine.layoutMeasures(measures);
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ // 布局100个小节应该在合理时间内完成(<500ms)
|
|
|
+ expect(endTime - startTime).toBeLessThan(500);
|
|
|
+
|
|
|
+ // 验证所有小节都有宽度
|
|
|
+ measures.forEach(measure => {
|
|
|
+ expect(measure.width).toBeGreaterThan(0);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该能够处理100个小节的换行', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+ const systemEngine = new SystemLayoutEngine({
|
|
|
+ systemWidth: 800,
|
|
|
+ systemHeight: 100,
|
|
|
+ systemSpacing: 50,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measures = generateManyMeasures(100);
|
|
|
+ layoutEngine.layoutMeasures(measures);
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ const result = systemEngine.layoutSystems(measures);
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ // 换行计算应该在合理时间内完成(<200ms)
|
|
|
+ expect(endTime - startTime).toBeLessThan(200);
|
|
|
+
|
|
|
+ // 验证有多个行
|
|
|
+ expect(result.systems.length).toBeGreaterThan(10);
|
|
|
+
|
|
|
+ // 验证所有小节都被分配到行中
|
|
|
+ let totalMeasures = 0;
|
|
|
+ result.systems.forEach(system => {
|
|
|
+ totalMeasures += system.measures.length;
|
|
|
+ });
|
|
|
+ expect(totalMeasures).toBe(100);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该能够处理200个小节', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measures = generateManyMeasures(200);
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ layoutEngine.layoutMeasures(measures);
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ // 布局200个小节应该在合理时间内完成(<1000ms)
|
|
|
+ expect(endTime - startTime).toBeLessThan(1000);
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ==================== 3. 边界情况测试 ====================
|
|
|
+
|
|
|
+describe('3. 边界情况测试', () => {
|
|
|
+ describe('3.1 极短音符测试', () => {
|
|
|
+ it('应该正确计算三十二分音符的减时线数量', () => {
|
|
|
+ // 三十二分音符 realValue = 0.125
|
|
|
+ expect(calcUnderlineCount(0.125)).toBe(3);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确计算六十四分音符的减时线数量', () => {
|
|
|
+ // 六十四分音符 realValue = 0.0625
|
|
|
+ expect(calcUnderlineCount(0.0625)).toBe(4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染三十二分音符', () => {
|
|
|
+ const lineDrawer = new LineDrawer({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ noteFontSize: 20,
|
|
|
+ lineColor: '#000',
|
|
|
+ });
|
|
|
+ const svg = createSVGContainer();
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ pitch: 1,
|
|
|
+ duration: 0.125, // 三十二分音符
|
|
|
+ });
|
|
|
+
|
|
|
+ const group = lineDrawer.drawDurationLines(note, 50);
|
|
|
+ svg.appendChild(group);
|
|
|
+
|
|
|
+ const underlines = svg.querySelectorAll('.vf-underline');
|
|
|
+ expect(underlines.length).toBe(3);
|
|
|
+
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理极短音符的最小间距', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ minNoteSpacing: 15, // 最小间距
|
|
|
+ });
|
|
|
+
|
|
|
+ // 8个三十二分音符
|
|
|
+ const notes: JianpuNote[] = [];
|
|
|
+ for (let i = 0; i < 8; i++) {
|
|
|
+ notes.push(createTestNote({
|
|
|
+ id: `n${i}`,
|
|
|
+ pitch: (i % 7) + 1,
|
|
|
+ duration: 0.125,
|
|
|
+ startTime: i * 0.125,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ const measure = createTestMeasure({ notes });
|
|
|
+ layoutEngine.layoutMeasures([measure]);
|
|
|
+
|
|
|
+ // 验证音符间距不小于最小间距
|
|
|
+ for (let i = 1; i < notes.length; i++) {
|
|
|
+ const spacing = notes[i].x - notes[i - 1].x;
|
|
|
+ expect(spacing).toBeGreaterThanOrEqual(15);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('3.2 极长音符测试', () => {
|
|
|
+ it('应该正确计算全音符的增时线数量', () => {
|
|
|
+ // 全音符 realValue = 4
|
|
|
+ expect(calcExtensionLineCount(4)).toBe(3);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确计算双全音符(breve)的增时线数量', () => {
|
|
|
+ // 双全音符 realValue = 8
|
|
|
+ expect(calcExtensionLineCount(8)).toBe(7);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染全音符的增时线', () => {
|
|
|
+ const lineDrawer = new LineDrawer({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ noteFontSize: 20,
|
|
|
+ lineColor: '#000',
|
|
|
+ });
|
|
|
+ const svg = createSVGContainer();
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ pitch: 1,
|
|
|
+ duration: 4, // 全音符
|
|
|
+ });
|
|
|
+
|
|
|
+ const group = lineDrawer.drawDurationLines(note, 50);
|
|
|
+ svg.appendChild(group);
|
|
|
+
|
|
|
+ const extensionLines = svg.querySelectorAll('.vf-extension-line');
|
|
|
+ expect(extensionLines.length).toBe(3);
|
|
|
+
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('增时线应该按时值均匀分布', () => {
|
|
|
+ const lineDrawer = new LineDrawer({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ noteFontSize: 20,
|
|
|
+ lineColor: '#000',
|
|
|
+ });
|
|
|
+ const svg = createSVGContainer();
|
|
|
+
|
|
|
+ const note = createTestNote({
|
|
|
+ pitch: 1,
|
|
|
+ duration: 4,
|
|
|
+ x: 100,
|
|
|
+ });
|
|
|
+
|
|
|
+ const group = lineDrawer.drawDurationLines(note, 50);
|
|
|
+ svg.appendChild(group);
|
|
|
+
|
|
|
+ const extensionLines = svg.querySelectorAll('.vf-extension-line');
|
|
|
+ const xPositions: number[] = [];
|
|
|
+ extensionLines.forEach(line => {
|
|
|
+ xPositions.push(parseFloat(line.getAttribute('x') || '0'));
|
|
|
+ });
|
|
|
+
|
|
|
+ // 验证增时线间距均匀
|
|
|
+ for (let i = 1; i < xPositions.length; i++) {
|
|
|
+ const spacing = xPositions[i] - xPositions[i - 1];
|
|
|
+ expect(spacing).toBeCloseTo(50, 1); // 四分音符间距
|
|
|
+ }
|
|
|
+
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('3.3 极端速度测试', () => {
|
|
|
+ it('应该正确计算30BPM下四分音符的时长', () => {
|
|
|
+ // 30 BPM 时,四分音符 = 2秒
|
|
|
+ const quarterNoteDuration = realValueToSeconds(1, 30);
|
|
|
+ expect(quarterNoteDuration).toBeCloseTo(2, 2);
|
|
|
+
|
|
|
+ // 八分音符 = 1秒
|
|
|
+ const eighthNoteDuration = realValueToSeconds(0.5, 30);
|
|
|
+ expect(eighthNoteDuration).toBeCloseTo(1, 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确计算300BPM下四分音符的时长', () => {
|
|
|
+ // 300 BPM 时,四分音符 = 0.2秒
|
|
|
+ const quarterNoteDuration = realValueToSeconds(1, 300);
|
|
|
+ expect(quarterNoteDuration).toBeCloseTo(0.2, 2);
|
|
|
+
|
|
|
+ // 八分音符 = 0.1秒
|
|
|
+ const eighthNoteDuration = realValueToSeconds(0.5, 300);
|
|
|
+ expect(eighthNoteDuration).toBeCloseTo(0.1, 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确计算极慢速度的节拍器前奏', () => {
|
|
|
+ // 30 BPM × 4拍 = 8秒前奏
|
|
|
+ const fixtime = getFixTime(30, 4);
|
|
|
+ expect(fixtime).toBeCloseTo(8, 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确计算极快速度的节拍器前奏', () => {
|
|
|
+ // 300 BPM × 4拍 = 0.8秒前奏
|
|
|
+ const fixtime = getFixTime(300, 4);
|
|
|
+ expect(fixtime).toBeCloseTo(0.8, 2);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('3.4 变拍曲谱测试', () => {
|
|
|
+ it('应该正确处理4/4到3/4的变拍', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measure44 = createTestMeasure({ index: 1, beats: 4, beatType: 4 });
|
|
|
+ const measure34 = createTestMeasure({ index: 2, beats: 3, beatType: 4 });
|
|
|
+
|
|
|
+ layoutEngine.layoutMeasures([measure44, measure34]);
|
|
|
+
|
|
|
+ // 4/4和3/4宽度比应该是 4:3
|
|
|
+ const content44 = measure44.width - 40; // 减去padding
|
|
|
+ const content34 = measure34.width - 40;
|
|
|
+ expect(content44 / content34).toBeCloseTo(4 / 3, 1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理5/4拍', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measure = createTestMeasure({ beats: 5, beatType: 4 });
|
|
|
+ layoutEngine.layoutMeasures([measure]);
|
|
|
+
|
|
|
+ // 5/4拍宽度 = 5 × 50 + 40 = 290
|
|
|
+ expect(measure.width).toBe(290);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理7/8拍', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measure = createTestMeasure({ beats: 7, beatType: 8 });
|
|
|
+ layoutEngine.layoutMeasures([measure]);
|
|
|
+
|
|
|
+ // 7/8拍 = 3.5个四分音符时值
|
|
|
+ // 宽度 = 3.5 × 50 + 40 = 215
|
|
|
+ expect(measure.width).toBe(215);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理2/2拍(切分拍)', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measure = createTestMeasure({ beats: 2, beatType: 2 });
|
|
|
+ layoutEngine.layoutMeasures([measure]);
|
|
|
+
|
|
|
+ // 2/2拍 = 4个四分音符时值
|
|
|
+ // 宽度 = 4 × 50 + 40 = 240
|
|
|
+ expect(measure.width).toBe(240);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理连续变拍', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measures = [
|
|
|
+ createTestMeasure({ index: 1, beats: 4, beatType: 4 }), // 4/4
|
|
|
+ createTestMeasure({ index: 2, beats: 5, beatType: 4 }), // 5/4
|
|
|
+ createTestMeasure({ index: 3, beats: 7, beatType: 8 }), // 7/8
|
|
|
+ createTestMeasure({ index: 4, beats: 2, beatType: 2 }), // 2/2
|
|
|
+ createTestMeasure({ index: 5, beats: 3, beatType: 4 }), // 3/4
|
|
|
+ ];
|
|
|
+
|
|
|
+ layoutEngine.layoutMeasures(measures);
|
|
|
+
|
|
|
+ // 验证每个小节宽度都是正确的
|
|
|
+ expect(measures[0].width).toBe(240); // 4/4
|
|
|
+ expect(measures[1].width).toBe(290); // 5/4
|
|
|
+ expect(measures[2].width).toBe(215); // 7/8
|
|
|
+ expect(measures[3].width).toBe(240); // 2/2
|
|
|
+ expect(measures[4].width).toBe(190); // 3/4
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('3.5 极端八度测试', () => {
|
|
|
+ let noteDrawer: NoteDrawer;
|
|
|
+ let svg: SVGSVGElement;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ noteDrawer = new NoteDrawer({
|
|
|
+ noteFontSize: 20,
|
|
|
+ fontFamily: 'Arial',
|
|
|
+ noteColor: '#000',
|
|
|
+ });
|
|
|
+ svg = createSVGContainer();
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染低两个八度的音符', () => {
|
|
|
+ const note = createTestNote({ octave: -2 });
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+
|
|
|
+ const lowDots = group.querySelectorAll('.vf-low-dot');
|
|
|
+ expect(lowDots.length).toBe(2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确渲染高三个八度的音符', () => {
|
|
|
+ const note = createTestNote({ octave: 3 });
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+
|
|
|
+ const highDots = group.querySelectorAll('.vf-high-dot');
|
|
|
+ expect(highDots.length).toBe(3);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理从低两个八度到高三个八度的跨度', () => {
|
|
|
+ const notes = [
|
|
|
+ createTestNote({ id: 'n1', octave: -2, x: 50 }),
|
|
|
+ createTestNote({ id: 'n2', octave: -1, x: 100 }),
|
|
|
+ createTestNote({ id: 'n3', octave: 0, x: 150 }),
|
|
|
+ createTestNote({ id: 'n4', octave: 1, x: 200 }),
|
|
|
+ createTestNote({ id: 'n5', octave: 2, x: 250 }),
|
|
|
+ createTestNote({ id: 'n6', octave: 3, x: 300 }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+
|
|
|
+ const noteElements = svg.querySelectorAll('.vf-stavenote');
|
|
|
+ expect(noteElements.length).toBe(6);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('3.6 Divisions处理边界情况', () => {
|
|
|
+ it('应该正确处理divisions=1', () => {
|
|
|
+ const handler = new DivisionsHandler();
|
|
|
+ handler.setDivisions(1);
|
|
|
+
|
|
|
+ // duration=1 应该等于1个四分音符
|
|
|
+ expect(handler.toRealValue(1)).toBe(1);
|
|
|
+ expect(handler.toRealValue(2)).toBe(2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理divisions=960', () => {
|
|
|
+ const handler = new DivisionsHandler();
|
|
|
+ handler.setDivisions(960);
|
|
|
+
|
|
|
+ // 四分音符 = 960
|
|
|
+ expect(handler.toRealValue(960)).toBe(1);
|
|
|
+ // 八分音符 = 480
|
|
|
+ expect(handler.toRealValue(480)).toBe(0.5);
|
|
|
+ // 全音符 = 3840
|
|
|
+ expect(handler.toRealValue(3840)).toBe(4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理divisions=0(使用默认值256)', () => {
|
|
|
+ const handler = new DivisionsHandler();
|
|
|
+ handler.setDivisions(0);
|
|
|
+
|
|
|
+ // 默认值256,所以256=1个四分音符
|
|
|
+ expect(handler.toRealValue(256)).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('应该正确处理负数duration(取绝对值)', () => {
|
|
|
+ const handler = new DivisionsHandler();
|
|
|
+ handler.setDivisions(256);
|
|
|
+
|
|
|
+ // 负数duration会取绝对值
|
|
|
+ const result = handler.toRealValue(-256);
|
|
|
+ // 取绝对值后 256/256 = 1
|
|
|
+ expect(result).toBe(1);
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ==================== 4. 性能基准测试 ====================
|
|
|
+
|
|
|
+describe('4. 性能基准测试', () => {
|
|
|
+ describe('4.1 渲染性能', () => {
|
|
|
+ it('绘制100个音符应该在100ms内完成', () => {
|
|
|
+ const noteDrawer = new NoteDrawer({
|
|
|
+ noteFontSize: 20,
|
|
|
+ fontFamily: 'Arial',
|
|
|
+ noteColor: '#000',
|
|
|
+ });
|
|
|
+ const svg = createSVGContainer();
|
|
|
+
|
|
|
+ const notes: JianpuNote[] = [];
|
|
|
+ for (let i = 0; i < 100; i++) {
|
|
|
+ notes.push(createTestNote({
|
|
|
+ id: `perf-note-${i}`,
|
|
|
+ pitch: (i % 7) + 1,
|
|
|
+ octave: (i % 3) - 1,
|
|
|
+ x: i * 30,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ expect(endTime - startTime).toBeLessThan(100);
|
|
|
+
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('绘制500个音符应该在500ms内完成', () => {
|
|
|
+ const noteDrawer = new NoteDrawer({
|
|
|
+ noteFontSize: 20,
|
|
|
+ fontFamily: 'Arial',
|
|
|
+ noteColor: '#000',
|
|
|
+ });
|
|
|
+ const svg = createSVGContainer();
|
|
|
+
|
|
|
+ const notes: JianpuNote[] = [];
|
|
|
+ for (let i = 0; i < 500; i++) {
|
|
|
+ notes.push(createTestNote({
|
|
|
+ id: `perf-note-${i}`,
|
|
|
+ pitch: (i % 7) + 1,
|
|
|
+ x: i * 20,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ notes.forEach(note => {
|
|
|
+ const group = noteDrawer.drawNote(note);
|
|
|
+ svg.appendChild(group);
|
|
|
+ });
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ expect(endTime - startTime).toBeLessThan(500);
|
|
|
+
|
|
|
+ cleanupSVG(svg);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('4.2 布局性能', () => {
|
|
|
+ it('布局100个小节应该在100ms内完成', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measures = generateManyMeasures(100);
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ layoutEngine.layoutMeasures(measures);
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ expect(endTime - startTime).toBeLessThan(100);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('换行计算100个小节应该在50ms内完成', () => {
|
|
|
+ const layoutEngine = new MeasureLayoutEngine({
|
|
|
+ quarterNoteSpacing: 50,
|
|
|
+ measurePadding: 20,
|
|
|
+ noteFontSize: 20,
|
|
|
+ });
|
|
|
+ const systemEngine = new SystemLayoutEngine({
|
|
|
+ systemWidth: 800,
|
|
|
+ systemHeight: 100,
|
|
|
+ systemSpacing: 50,
|
|
|
+ });
|
|
|
+
|
|
|
+ const measures = generateManyMeasures(100);
|
|
|
+ layoutEngine.layoutMeasures(measures);
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+ systemEngine.layoutSystems(measures);
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ expect(endTime - startTime).toBeLessThan(50);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('4.3 时间计算性能', () => {
|
|
|
+ it('计算100个小节的realValueToSeconds应该在50ms内完成', () => {
|
|
|
+ const measures = generateManyMeasures(100, 4);
|
|
|
+ const bpm = 120;
|
|
|
+
|
|
|
+ const startTime = performance.now();
|
|
|
+
|
|
|
+ // 遍历所有小节和音符计算时间
|
|
|
+ for (const measure of measures) {
|
|
|
+ for (const voice of measure.voices) {
|
|
|
+ for (const note of voice) {
|
|
|
+ realValueToSeconds(note.duration, bpm);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const endTime = performance.now();
|
|
|
+
|
|
|
+ expect(endTime - startTime).toBeLessThan(50);
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|
|
|
+
|