feat: implemented chord guide customization feature
This commit is contained in:
+320
-1
@@ -7,7 +7,8 @@ import {
|
||||
getSuitableChords,
|
||||
getChordNotesInKey,
|
||||
getMatchingChordsForPitch,
|
||||
generatePianoGridBackground
|
||||
generatePianoGridBackground,
|
||||
validateFunctionalChordsJSON
|
||||
} from './scaleUtil'
|
||||
import { KGCore } from '../core/KGCore'
|
||||
import type { KeySignature } from '../core/KGProject'
|
||||
@@ -392,4 +393,322 @@ describe('scaleUtil', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -350,3 +350,134 @@ export const generatePianoGridBackground = (
|
||||
linear-gradient(to bottom, ${horizontalLines})
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation result for functional chords JSON
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates functional chords JSON structure
|
||||
* @param jsonString - JSON string to validate
|
||||
* @returns Validation result with errors if any
|
||||
*/
|
||||
export const validateFunctionalChordsJSON = (jsonString: string): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Try to parse JSON
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
return { valid: false, errors: ['Invalid JSON format'] };
|
||||
}
|
||||
|
||||
// Check if data is an object
|
||||
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
||||
return { valid: false, errors: ['Root must be an object'] };
|
||||
}
|
||||
|
||||
// Type guard to treat data as a record
|
||||
const dataRecord = data as Record<string, unknown>;
|
||||
|
||||
// Check if ionian mode exists
|
||||
if (!dataRecord.ionian) {
|
||||
errors.push('Missing required mode: "ionian"');
|
||||
}
|
||||
|
||||
// Regex patterns
|
||||
const namePattern = /^[A-Za-z0-9_\- ]+$/;
|
||||
const romanNumeralPattern = /^♭?[ivIV]+[⁶°+0-9]*$/;
|
||||
const notePattern = /^[A-G][b#]?$/;
|
||||
|
||||
// Validate each mode
|
||||
for (const [modeId, modeData] of Object.entries(dataRecord)) {
|
||||
const modePrefix = `Mode "${modeId}"`;
|
||||
|
||||
// Validate mode structure
|
||||
if (typeof modeData !== 'object' || modeData === null || Array.isArray(modeData)) {
|
||||
errors.push(`${modePrefix}: must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type guard for mode object
|
||||
const mode = modeData as Record<string, unknown>;
|
||||
|
||||
// Validate name
|
||||
if (typeof mode.name !== 'string' || !namePattern.test(mode.name)) {
|
||||
errors.push(`${modePrefix}: "name" must be a string with letters, numbers, underscores, dashes, and spaces`);
|
||||
}
|
||||
|
||||
// Validate steps
|
||||
if (!Array.isArray(mode.steps)) {
|
||||
errors.push(`${modePrefix}: "steps" must be an array`);
|
||||
} else {
|
||||
if (mode.steps.length !== 7) {
|
||||
errors.push(`${modePrefix}: "steps" must contain exactly 7 integers`);
|
||||
}
|
||||
if (!mode.steps.every((step: unknown) => Number.isInteger(step))) {
|
||||
errors.push(`${modePrefix}: "steps" must contain only integers`);
|
||||
}
|
||||
const sum = mode.steps.reduce((acc: number, val: unknown) => acc + (typeof val === 'number' ? val : 0), 0);
|
||||
if (sum !== 12) {
|
||||
errors.push(`${modePrefix}: "steps" must sum to 12 (got ${sum})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all chord symbols from T, S, D
|
||||
const allChordSymbols = new Set<string>();
|
||||
|
||||
// Validate T, S, D arrays
|
||||
for (const functionType of ['T', 'S', 'D']) {
|
||||
if (!Array.isArray(mode[functionType])) {
|
||||
errors.push(`${modePrefix}: "${functionType}" must be an array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chordSymbol of mode[functionType]) {
|
||||
if (typeof chordSymbol !== 'string' || !romanNumeralPattern.test(chordSymbol)) {
|
||||
errors.push(`${modePrefix}: Invalid chord symbol "${chordSymbol}" in "${functionType}" (must be Roman numeral I-VII with optional ♭ prefix and/or ⁶°+digit suffixes)`);
|
||||
}
|
||||
allChordSymbols.add(chordSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate chords object
|
||||
if (typeof mode.chords !== 'object' || mode.chords === null || Array.isArray(mode.chords)) {
|
||||
errors.push(`${modePrefix}: "chords" must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type guard for chords object
|
||||
const chords = mode.chords as Record<string, unknown>;
|
||||
|
||||
// Check if all chord symbols are defined in chords
|
||||
for (const chordSymbol of allChordSymbols) {
|
||||
if (!(chordSymbol in chords)) {
|
||||
errors.push(`${modePrefix}: Chord "${chordSymbol}" referenced in T/S/D but not defined in "chords"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each chord definition
|
||||
for (const [chordSymbol, chordNotes] of Object.entries(chords)) {
|
||||
if (!Array.isArray(chordNotes)) {
|
||||
errors.push(`${modePrefix}: Chord "${chordSymbol}" must be an array of notes`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const note of chordNotes) {
|
||||
if (typeof note !== 'string' || !notePattern.test(note)) {
|
||||
errors.push(`${modePrefix}: Invalid note "${note}" in chord "${chordSymbol}" (must be A-G with optional b or #)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user