import { describe, it, expect, beforeEach, vi } from 'vitest'; import { getRootNoteFromKeySignature, noteNameToPitchClass, getScalePitchClasses, getModeSteps, getSuitableChords, getChordNotesInKey, getMatchingChordsForPitch, generatePianoGridBackground, validateFunctionalChordsJSON } from './scaleUtil'; import { KGCore } from '../core/KGCore'; import type { KeySignature } from '../core/KGProject'; import functionalChordsData from '../../public/resources/modes/functional_chords.json'; describe('scaleUtil', () => { // Setup: Mock KGCore with real chord data beforeEach(() => { // Use real functional chords data from JSON file (includes name, steps, and chord data) KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; }); describe('getRootNoteFromKeySignature', () => { it('should extract root note from C major', () => { expect(getRootNoteFromKeySignature('C major')).toBe('C'); }); it('should extract root note from F# minor', () => { expect(getRootNoteFromKeySignature('F# minor')).toBe('F#'); }); it('should extract root note from Bb major', () => { expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb'); }); it('should handle Db major', () => { expect(getRootNoteFromKeySignature('Db major')).toBe('Db'); }); it('should default to C for invalid format', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C'); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format')); warnSpy.mockRestore(); }); }); describe('noteNameToPitchClass', () => { it('should convert C to 0', () => { expect(noteNameToPitchClass('C')).toBe(0); }); it('should convert C# to 1', () => { expect(noteNameToPitchClass('C#')).toBe(1); }); it('should convert Db to 1', () => { expect(noteNameToPitchClass('Db')).toBe(1); }); it('should convert D to 2', () => { expect(noteNameToPitchClass('D')).toBe(2); }); it('should convert E to 4', () => { expect(noteNameToPitchClass('E')).toBe(4); }); it('should convert F to 5', () => { expect(noteNameToPitchClass('F')).toBe(5); }); it('should convert F# to 6', () => { expect(noteNameToPitchClass('F#')).toBe(6); }); it('should convert G to 7', () => { expect(noteNameToPitchClass('G')).toBe(7); }); it('should convert A to 9', () => { expect(noteNameToPitchClass('A')).toBe(9); }); it('should convert Bb to 10', () => { expect(noteNameToPitchClass('Bb')).toBe(10); }); it('should convert B to 11', () => { expect(noteNameToPitchClass('B')).toBe(11); }); it('should default to 0 for invalid note', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); expect(noteNameToPitchClass('X')).toBe(0); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name')); warnSpy.mockRestore(); }); }); describe('getModeSteps', () => { it('should return ionian steps', () => { expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]); }); it('should return dorian steps', () => { expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]); }); it('should default to ionian for invalid mode', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found')); warnSpy.mockRestore(); }); }); describe('getScalePitchClasses', () => { it('should return C major scale pitch classes', () => { const steps = [2, 2, 1, 2, 2, 2, 1]; const result = getScalePitchClasses('C', steps); expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]); // C D E F G A B }); it('should return D major scale pitch classes', () => { const steps = [2, 2, 1, 2, 2, 2, 1]; const result = getScalePitchClasses('D', steps); expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]); // D E F# G A B C# }); it('should return F# dorian scale pitch classes', () => { const steps = [2, 1, 2, 2, 2, 1, 2]; const result = getScalePitchClasses('F#', steps); expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]); // F# G# A B C# D# E }); }); describe('getSuitableChords', () => { it('should return tonic chords for C major ionian', () => { const result = getSuitableChords('C major', 'ionian', 'T'); expect(result).toHaveProperty('I'); expect(result).toHaveProperty('vi'); expect(result).toHaveProperty('iii'); expect(result['I']).toEqual(['C', 'E', 'G']); expect(result['vi']).toEqual(['A', 'C', 'E']); }); it('should return subdominant chords for C major ionian', () => { const result = getSuitableChords('C major', 'ionian', 'S'); expect(result).toHaveProperty('IV'); expect(result).toHaveProperty('ii'); expect(result['IV']).toEqual(['F', 'A', 'C']); expect(result['ii']).toEqual(['D', 'F', 'A']); }); it('should return dominant chords for C major ionian', () => { const result = getSuitableChords('C major', 'ionian', 'D'); expect(result).toHaveProperty('V'); expect(result).toHaveProperty('V7'); expect(result['V']).toEqual(['G', 'B', 'D']); expect(result['V7']).toEqual(['G', 'B', 'D', 'F']); }); it('should transpose chords for D major ionian', () => { const result = getSuitableChords('D major', 'ionian', 'T'); expect(result['I']).toEqual(['D', 'F#', 'A']); expect(result['vi']).toEqual(['B', 'D', 'F#']); }); it('should transpose chords for F# major ionian', () => { const result = getSuitableChords('F# major', 'ionian', 'T'); expect(result['I']).toEqual(['F#', 'A#', 'C#']); }); it('should return empty object for invalid mode', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = getSuitableChords('C major', 'invalid', 'T'); expect(result).toEqual({}); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found')); warnSpy.mockRestore(); }); }); describe('getChordNotesInKey', () => { it('should return I chord notes in C major ionian', () => { const result = getChordNotesInKey('I', 'C major', 'ionian'); expect(result).toEqual(['C', 'E', 'G']); }); it('should return V7 chord notes in C major ionian', () => { const result = getChordNotesInKey('V7', 'C major', 'ionian'); expect(result).toEqual(['G', 'B', 'D', 'F']); }); it('should transpose to D major', () => { const result = getChordNotesInKey('I', 'D major', 'ionian'); expect(result).toEqual(['D', 'F#', 'A']); }); it('should return empty array for invalid chord symbol', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = getChordNotesInKey('invalid', 'C major', 'ionian'); expect(result).toEqual([]); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found')); warnSpy.mockRestore(); }); }); describe('getMatchingChordsForPitch', () => { describe('valid inputs', () => { it('should return matching chords for C (pitch 60) in C major ionian tonic', () => { const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"] // Pitch 60 = C (pitch class 0) // Expected matches prioritized by position: // 1. I chord: C is root (position 0) → [0, 4, 7] (no offset) // 2. vi chord: C is 2nd note (position 1) → [9, 12, 16] offset by -12 → [-3, 0, 4] // 3. I⁶ chord: C is 3rd note (position 2) → [4, 7, 12] offset by -12 → [-8, -5, 0] expect(result).toEqual([ [0, 4, 7], // I chord (C-E-G): C is root [-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied [-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied ]); }); it('should return matching chords for E (pitch 64) in C major ionian tonic', () => { const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T'); // Pitch 64 = E (pitch class 4) // Expected matches prioritized by position: // 1. iii chord: E is root (position 0) → [4, 7, 11] (no offset) // 2. I⁶ chord: E is root (position 0) → [4, 7, 12] (no offset) // 3. I chord: E is 2nd note (position 1) → [0, 4, 7] (no offset, 4 < 12) // 4. vi chord: E is 3rd note (position 2) → [9, 12, 16] offset by -12 → [-3, 0, 4] expect(result).toEqual([ [4, 7, 11], // iii chord (E-G-B): E is root [4, 7, 12], // I⁶ chord (E-G-C): E is root [0, 4, 7], // I chord (C-E-G): E is 2nd [-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied ]); }); it('should prioritize root matches over other positions', () => { const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C // First chord should have C as root (position 0) if (result.length > 0) { expect(result[0][0] % 12).toBe(0); // First note of first chord should be C } }); it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => { const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S'); // F // Pitch 65 = F (pitch class 5) // C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"] // Expected matches prioritized by position: // 1. IV chord: F is root → [5, 9, 12] offset by -12 → [-7, -3, 0] // 2. ii chord: F is 2nd → [2, 5, 9] (no offset) // 3. IV⁶ chord: F is 3rd → [9, 12, 17] offset by -12 → [-3, 0, 5] expect(result).toEqual([ [5, 9, 12], // IV chord (F-A-C): F is root, offset applied [2, 5, 9], // ii chord (D-F-A): F is 2nd [-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied ]); }); it('should return matching chords for G (pitch 67) in C major ionian dominant', () => { const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D'); // G // Pitch 67 = G (pitch class 7) // C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"] // Expected matches prioritized by position: // 1. V chord: G is root → [7, 11, 14] offset by -12 → [-5, -1, 2] // 2. V7 chord: G is root → [7, 11, 14, 17] offset by -12 → [-5, -1, 2, 5] expect(result).toEqual([ [7, 11, 14], // V chord (G-B-D): G is root, offset applied [7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied ]); }); it('should work with different modes (dorian)', () => { const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T'); expect(result.length).toBeGreaterThan(0); }); it('should transpose correctly for D major', () => { const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T'); // D expect(result.length).toBeGreaterThan(0); expect(result.some(chord => chord.includes(2))).toBe(true); // Contains D (pitch class 2) }); it('should handle all MIDI pitch ranges (low)', () => { const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T'); // C1 expect(Array.isArray(result)).toBe(true); }); it('should handle all MIDI pitch ranges (high)', () => { const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T'); // C8 expect(Array.isArray(result)).toBe(true); }); }); describe('edge cases', () => { it('should return empty array for invalid mode', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T'); expect(result).toEqual([]); warnSpy.mockRestore(); }); it('should handle pitch class calculations correctly', () => { // Test that pitch 60 (C4) and pitch 72 (C5) both match C chords const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T'); expect(result1.length).toBe(result2.length); // Same chords match }); it('should return pitch classes in ascending order', () => { const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); result.forEach(chord => { for (let i = 1; i < chord.length; i++) { expect(chord[i]).toBeGreaterThan(chord[i - 1]); } }); }); it('should apply octave offset correctly for pitch classes >= 12', () => { const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // All pitch classes should be < 12 after offset result.forEach(chord => { chord.forEach(pitchClass => { expect(pitchClass).toBeLessThan(24); // Allowing for extended range }); }); }); }); describe('pitch class calculations', () => { it('should correctly convert hover pitch to pitch class', () => { // C4 (60) should match same chords as C5 (72) const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T'); expect(result1).toEqual(result2); }); it('should maintain ascending pitch order in results', () => { const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T'); result.forEach(chord => { for (let i = 1; i < chord.length; i++) { expect(chord[i]).toBeGreaterThan(chord[i - 1]); } }); }); }); describe('integration with helper functions', () => { it('should work with getSuitableChords', () => { const suitableChords = getSuitableChords('C major', 'ionian', 'T'); expect(Object.keys(suitableChords).length).toBeGreaterThan(0); const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); expect(matchingChords.length).toBeGreaterThan(0); }); it('should work with noteNameToPitchClass', () => { const cPitch = noteNameToPitchClass('C'); const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true); }); it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => { // Verify the data has the expected structure const data = KGCore.FUNCTIONAL_CHORDS_DATA; expect(data).toHaveProperty('ionian'); expect(data['ionian']).toHaveProperty('T'); expect(data['ionian']).toHaveProperty('chords'); }); }); }); describe('generatePianoGridBackground', () => { it('should generate CSS background for C major ionian', () => { const result = generatePianoGridBackground('ionian', 'C major'); expect(result).toContain('linear-gradient'); expect(typeof result).toBe('string'); expect(result).toContain('#282828'); expect(result).toContain('#303030'); expect(result).toContain('#404040 calc(var(--region-grid-bar-width) - 1px)'); expect(result).toContain('#343434 calc(var(--region-grid-beat-width) - 1px)'); expect(result).toContain('#303030 calc(var(--region-piano-key-height) * 0)'); expect(result).toContain('#404040 calc(var(--region-piano-key-height) * 12 - 1px)'); expect(result).toContain('#282828 calc(var(--region-piano-key-height) * 1)'); expect(result).toContain('#343434 calc(var(--region-piano-key-height) * 2 - 1px)'); }); it('should generate different backgrounds for different modes', () => { const ionian = generatePianoGridBackground('ionian', 'C major'); const dorian = generatePianoGridBackground('dorian', 'C major'); expect(ionian).not.toBe(dorian); }); }); describe('validateFunctionalChordsJSON', () => { const validJSON = `{ "ionian": { "name": "Ionian", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I", "vi", "iii", "I⁶"], "S": ["IV", "ii", "vi", "IV⁶"], "D": ["V", "V7", "vii°", "♭II"], "chords": { "I": ["C", "E", "G"], "vi": ["A", "C", "E"], "iii": ["E", "G", "B"], "I⁶": ["E", "G", "C"], "IV": ["F", "A", "C"], "ii": ["D", "F", "A"], "IV⁶": ["A", "C", "F"], "V": ["G", "B", "D"], "V7": ["G", "B", "D", "F"], "vii°": ["B", "D", "F"], "♭II": ["Db", "F", "Ab"] } }, "aeolian": { "name": "Aeolian", "steps": [2, 1, 2, 2, 1, 2, 2], "T": ["i", "VI", "III", "i⁶"], "S": ["iv", "ii°", "VI", "iv⁶"], "D": ["v", "♭VII"], "chords": { "i": ["C", "Eb", "G"], "VI": ["Ab", "C", "Eb"], "III": ["Eb", "G", "Bb"], "i⁶": ["Eb", "G", "C"], "iv": ["F", "Ab", "C"], "ii°": ["D", "F", "Ab"], "iv⁶": ["Ab", "C", "F"], "v": ["G", "Bb", "D"], "♭VII": ["Bb", "D", "F"] } } }`; describe('valid JSON', () => { it('should validate correct JSON with ionian and aeolian', () => { const result = validateFunctionalChordsJSON(validJSON); expect(result.valid).toBe(true); expect(result.errors).toEqual([]); }); it('should accept empty T/S/D arrays', () => { const json = `{ "ionian": { "name": "Test Mode", "steps": [2, 2, 1, 2, 2, 2, 1], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(true); }); it('should accept mode names with underscores, dashes, and spaces', () => { const json = `{ "ionian": { "name": "Test_Mode-123 Name", "steps": [2, 2, 1, 2, 2, 2, 1], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(true); }); it('should accept notes with sharp and flat', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I"], "S": [], "D": [], "chords": { "I": ["C#", "Eb", "F#"] } } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(true); }); it('should accept chord symbols with special characters', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I⁶", "vii°", "III+", "♭II"], "S": [], "D": [], "chords": { "I⁶": ["C", "E", "G"], "vii°": ["B", "D", "F"], "III+": ["E", "G#", "B"], "♭II": ["Db", "F", "Ab"] } } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(true); }); }); describe('invalid JSON', () => { it('should reject malformed JSON', () => { const result = validateFunctionalChordsJSON('{ invalid json }'); expect(result.valid).toBe(false); expect(result.errors).toContain('Invalid JSON format'); }); it('should reject JSON array as root', () => { const result = validateFunctionalChordsJSON('[]'); expect(result.valid).toBe(false); expect(result.errors).toContain('Root must be an object'); }); it('should reject missing ionian mode', () => { const json = `{ "dorian": { "name": "Dorian", "steps": [2, 1, 2, 2, 2, 1, 2], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required mode: "ionian"'); }); it('should reject invalid mode name', () => { const json = `{ "ionian": { "name": "Test@Mode!", "steps": [2, 2, 1, 2, 2, 2, 1], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true); }); it('should reject steps with wrong number of elements', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true); }); it('should reject steps that do not sum to 12', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 2, 2, 2, 2, 2], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true); }); it('should reject non-integer steps', () => { const json = `{ "ionian": { "name": "Test", "steps": [2.5, 2, 1, 2, 2, 2, 0.5], "T": [], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true); }); it('should reject invalid chord symbol in T/S/D', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["invalid123"], "S": [], "D": [], "chords": { "invalid123": ["C", "E", "G"] } } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true); }); it('should reject chord referenced but not defined', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I"], "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true); }); it('should reject invalid note name', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I"], "S": [], "D": [], "chords": { "I": ["C", "X", "G"] } } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true); }); it('should reject note with invalid accidental', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I"], "S": [], "D": [], "chords": { "I": ["C##", "E", "G"] } } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true); }); it('should reject lowercase note names', () => { const json = `{ "ionian": { "name": "Test", "steps": [2, 2, 1, 2, 2, 2, 1], "T": ["I"], "S": [], "D": [], "chords": { "I": ["c", "e", "g"] } } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true); }); }); describe('error messages', () => { it('should provide descriptive error messages for multiple errors', () => { const json = `{ "dorian": { "name": "Test@", "steps": [2, 2, 1], "T": "not-array", "S": [], "D": [], "chords": {} } }`; const result = validateFunctionalChordsJSON(json); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(1); }); }); }); });