| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587 |
- /**
- * 解析器集成测试
- *
- * 使用真实的MusicXML文件测试完整的解析流程:
- * 1. MusicXML解析 → 模拟OSMD对象
- * 2. OSMDDataParser解析 → JianpuScore
- * 3. TimeCalculator计算 → 时间数据
- *
- * 测试文件:
- * - basic.xml: 基础简谱
- * - mixed-durations.xml: 混合时值
- */
- 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';
- // ==================== 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);
- }
- }
- // ==================== 测试工具函数 ====================
- /**
- * 加载测试XML文件
- */
- function loadTestXML(filename: string): string {
- const filePath = join(__dirname, 'fixtures', filename);
- return readFileSync(filePath, 'utf-8');
- }
- /**
- * 解析MusicXML为模拟OSMD对象
- */
- function parseXMLToOSMD(xmlString: string): any {
- const parser = new SimpleMusicXMLParser();
- return parser.parse(xmlString);
- }
- // ==================== 测试用例 ====================
- describe('解析器集成测试', () => {
- let xmlParser: SimpleMusicXMLParser;
- let osmdParser: OSMDDataParser;
- let timeCalculator: TimeCalculator;
- beforeAll(() => {
- xmlParser = new SimpleMusicXMLParser();
- osmdParser = new OSMDDataParser();
- timeCalculator = new TimeCalculator();
- vi.spyOn(console, 'log').mockImplementation(() => {});
- vi.spyOn(console, 'warn').mockImplementation(() => {});
- });
- // ==================== basic.xml 测试 ====================
- describe('basic.xml - 基础简谱', () => {
- let xmlContent: string;
- let osmdObject: any;
- beforeAll(() => {
- xmlContent = loadTestXML('basic.xml');
- osmdObject = parseXMLToOSMD(xmlContent);
- });
- it('应该正确解析XML文件', () => {
- expect(osmdObject).toBeDefined();
- expect(osmdObject.Sheet).toBeDefined();
- expect(osmdObject.cursor).toBeDefined();
- });
- it('应该正确解析标题和作曲家', () => {
- expect(osmdObject.Sheet.Title.text).toBe('基础简谱测试');
- expect(osmdObject.Sheet.Composer.text).toBe('测试');
- });
- it('应该正确解析4个小节', () => {
- expect(osmdObject.Sheet.SourceMeasures.length).toBe(4);
- });
- 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('OSMDDataParser解析', () => {
- let score: any;
- beforeAll(() => {
- osmdParser = new OSMDDataParser();
- score = osmdParser.parse(osmdObject);
- });
- it('应该返回JianpuScore对象', () => {
- expect(score).toBeDefined();
- expect(score.measures).toBeDefined();
- expect(score.tempo).toBeDefined();
- });
- it('应该解析出4个小节', () => {
- expect(score.measures.length).toBe(4);
- });
- it('应该解析出正确的音符数量', () => {
- const stats = osmdParser.getStats();
- // basic.xml: 4小节,每小节4个音符,共16个(包含休止符)
- expect(stats.noteCount).toBeGreaterThanOrEqual(14);
- });
- 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
- });
- it('第3小节应该包含休止符', () => {
- const notes = score.measures[2].voices[0];
- const hasRest = notes.some((n: any) => n.isRest);
- expect(hasRest).toBe(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
- });
- });
- describe('TimeCalculator计算', () => {
- let score: any;
- beforeAll(() => {
- osmdParser = new OSMDDataParser();
- score = osmdParser.parse(osmdObject);
- timeCalculator.calculateTimes(score);
- });
- 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('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);
- });
- });
- });
- // ==================== mixed-durations.xml 测试 ====================
- describe('mixed-durations.xml - 混合时值', () => {
- let xmlContent: string;
- let osmdObject: any;
- beforeAll(() => {
- xmlContent = loadTestXML('mixed-durations.xml');
- osmdObject = parseXMLToOSMD(xmlContent);
- });
- it('应该正确解析XML文件', () => {
- expect(osmdObject).toBeDefined();
- expect(osmdObject.Sheet.SourceMeasures.length).toBe(6);
- });
- 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('第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('第3小节应该包含1个全音符', () => {
- const notes = score.measures[2].voices[0];
- expect(notes.length).toBe(1);
- expect(notes[0].duration).toBeCloseTo(4.0, 2);
- });
- 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('TimeCalculator计算', () => {
- let score: any;
- 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);
- });
- 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('解析basic.xml应该在100ms内完成', () => {
- const xmlContent = loadTestXML('basic.xml');
-
- 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();
-
- expect(endTime - startTime).toBeLessThan(100);
- });
- it('解析mixed-durations.xml应该在100ms内完成', () => {
- const xmlContent = loadTestXML('mixed-durations.xml');
-
- 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();
-
- 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);
- });
- });
|