refactor: chord guide system
This commit is contained in:
@@ -32,6 +32,32 @@ describe('ChordPickerPopup', () => {
|
||||
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports selecting dim7 from the popup controls', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="Gdim" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dim7' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('Gdim7');
|
||||
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
|
||||
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
|
||||
});
|
||||
|
||||
it('syncs the dim7 button when parsing text input', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="C" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'G#dim7' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('G#dim7');
|
||||
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
|
||||
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
|
||||
});
|
||||
|
||||
it('intercepts tab and delegates popup bar navigation', () => {
|
||||
const onTabNavigate = vi.fn();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const EXTENSION_LABELS: Record<ChordExtension, string> = {
|
||||
'#5': '#5',
|
||||
'6': '6',
|
||||
'7': '7',
|
||||
dim7: 'dim7',
|
||||
maj7: 'maj7',
|
||||
b9: 'b9',
|
||||
'9': '9',
|
||||
@@ -51,8 +52,9 @@ const QUALITY_ROWS: ChordQuality[][] = [
|
||||
|
||||
const EXTENSION_ROWS: ChordExtension[][] = [
|
||||
['b5', '#5', '6', '7'],
|
||||
['maj7', 'b9', '9', '#9'],
|
||||
['11', '#11', 'b13', '13'],
|
||||
['dim7', 'maj7', 'b9', '9'],
|
||||
['#9', '11', '#11', 'b13'],
|
||||
['13'],
|
||||
];
|
||||
|
||||
function createFallbackDescriptor(value: string): ChordDescriptor {
|
||||
@@ -86,13 +88,16 @@ function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor {
|
||||
removeExtensions(['#5']);
|
||||
}
|
||||
if (extensions.includes('7')) {
|
||||
removeExtensions(['maj7', '6']);
|
||||
removeExtensions(['dim7', 'maj7', '6']);
|
||||
}
|
||||
if (extensions.includes('dim7')) {
|
||||
removeExtensions(['7', 'maj7', '6']);
|
||||
}
|
||||
if (extensions.includes('maj7')) {
|
||||
removeExtensions(['7', '6']);
|
||||
removeExtensions(['7', 'dim7', '6']);
|
||||
}
|
||||
if (extensions.includes('6')) {
|
||||
removeExtensions(['7', 'maj7', '13', 'b13']);
|
||||
removeExtensions(['7', 'dim7', 'maj7', '13', 'b13']);
|
||||
}
|
||||
if (extensions.includes('13') || extensions.includes('b13')) {
|
||||
removeExtensions(['6']);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react';
|
||||
import { Playhead } from '../common';
|
||||
import SelectionBox from './SelectionBox';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
|
||||
import { generatePianoGridBackground } from '../../util/scaleUtil';
|
||||
import type { KeySignature } from '../../core/KGProject';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import SpectrogramCanvas from './SpectrogramCanvas';
|
||||
@@ -11,6 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas';
|
||||
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
||||
import { getNextChordCandidateIndex } from './chordGuideUtil';
|
||||
import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil';
|
||||
|
||||
interface PianoGridProps {
|
||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -167,7 +168,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
|
||||
// Use the utility function to get matching chords
|
||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||
return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||
return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
|
||||
|
||||
// Calculate chord highlights based on selected chord index
|
||||
|
||||
@@ -19,9 +19,9 @@ import { beatsToBar } from '../../util/midiUtil';
|
||||
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
|
||||
import { matchesKeyboardShortcut } from '../../util/osUtil';
|
||||
import { resolveChordGuideItems } from '../../util/chordGuideDataUtil';
|
||||
import {
|
||||
normalizeSpectrogramHeightResolution,
|
||||
type SpectrogramHeightResolution,
|
||||
@@ -739,7 +739,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
if (chordGuide === 'N') {
|
||||
// Disabled - clear chord data
|
||||
pianoRollState.setCurrentSuitableChords({});
|
||||
pianoRollState.setCurrentSuitableChords([]);
|
||||
pianoRollState.setCurrentSuitableChordsPitchClasses({});
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
@@ -748,28 +748,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
} else {
|
||||
// Get suitable chords for the selected function (T/S/D)
|
||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||
const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType);
|
||||
|
||||
// Convert note names to pitch classes (ensuring ascending order)
|
||||
const chordsPitchClasses: Record<string, number[]> = {};
|
||||
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
|
||||
const pitchClasses: number[] = [];
|
||||
let previousPitch = -1;
|
||||
|
||||
for (const noteName of noteNames) {
|
||||
let pitchClass = noteNameToPitchClass(noteName);
|
||||
|
||||
// If this pitch is lower than the previous one, add an octave
|
||||
if (previousPitch >= 0 && pitchClass <= previousPitch) {
|
||||
pitchClass += 12;
|
||||
}
|
||||
|
||||
pitchClasses.push(pitchClass);
|
||||
previousPitch = pitchClass;
|
||||
}
|
||||
|
||||
chordsPitchClasses[chordSymbol] = pitchClasses;
|
||||
}
|
||||
const suitableChords = resolveChordGuideItems(effectiveChordGuideKeySignature, chordGuideMode, functionType);
|
||||
const chordsPitchClasses = Object.fromEntries(
|
||||
suitableChords.map((item) => [item.name, item.pitchClasses])
|
||||
);
|
||||
|
||||
// Update piano roll state
|
||||
pianoRollState.setCurrentSuitableChords(suitableChords);
|
||||
|
||||
@@ -118,6 +118,17 @@ vi.mock('./PianoRollToolbar', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./chordGuideUtil', async () => {
|
||||
const actual = await vi.importActual<typeof import('./chordGuideUtil')>('./chordGuideUtil');
|
||||
return {
|
||||
...actual,
|
||||
resolveChordGuideContext: vi.fn(() => ({
|
||||
keySignature: 'C major',
|
||||
mode: 'ionian',
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PianoRoll zoom persistence', () => {
|
||||
beforeEach(() => {
|
||||
latestToolbarProps = null;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ChordGuideSettings from './ChordGuideSettings';
|
||||
|
||||
const configState = new Map<string, unknown>([
|
||||
['chord_guide.chord_definition', ''],
|
||||
]);
|
||||
|
||||
const configManagerMock = {
|
||||
getIsInitialized: vi.fn(() => true),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn((key: string) => configState.get(key)),
|
||||
set: vi.fn(async (key: string, value: unknown) => {
|
||||
configState.set(key, value);
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('../../../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => configManagerMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../util/scaleUtil', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../util/scaleUtil')>('../../../util/scaleUtil');
|
||||
return {
|
||||
...actual,
|
||||
validateFunctionalChordsJSON: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../util/dialogUtil', () => ({
|
||||
showAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../core/KGCore', () => ({
|
||||
KGCore: {
|
||||
ORIGINAL_FUNCTIONAL_CHORDS_DATA: {},
|
||||
FUNCTIONAL_CHORDS_DATA: {},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ChordGuideSettings', () => {
|
||||
beforeEach(() => {
|
||||
configState.set('chord_guide.chord_definition', '');
|
||||
configManagerMock.get.mockClear();
|
||||
configManagerMock.set.mockClear();
|
||||
});
|
||||
|
||||
it('renders the legacy notice for chord guide definitions', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,10 @@ const ChordGuideSettings: React.FC = () => {
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-group">
|
||||
<h4>Chord Definition</h4>
|
||||
<h4>Legacy Chord Definition</h4>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginBottom: '8px' }}>
|
||||
This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll.
|
||||
</div>
|
||||
<div className="settings-help-links">
|
||||
<button
|
||||
className="settings-help"
|
||||
@@ -132,7 +135,7 @@ const ChordGuideSettings: React.FC = () => {
|
||||
<div className="settings-item">
|
||||
<textarea
|
||||
className="settings-textarea"
|
||||
placeholder="Please input your chord definitions"
|
||||
placeholder="Please input your legacy chord definitions"
|
||||
rows={8}
|
||||
value={chordDefinition}
|
||||
onChange={(e) => handleChordDefinitionChange(e.target.value)}
|
||||
|
||||
Reference in New Issue
Block a user