import { fireEvent, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { MidiMappingEditorModal } from "../midi-mapping-editor-modal"
// Mock icons
vi.mock("lucide-react", () => ({
// Add any icons used by the component here
}))
// Mock i18n
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, disabled, variant, className }: any) => (
),
}))
vi.mock("@/components/ui/label", () => ({
Label: ({ children, htmlFor, className }: any) => (
),
}))
vi.mock("@/components/ui/select", () => ({
Select: ({ value, onValueChange, children }: any) => (
{children}
onValueChange?.(e.target.value)} data-testid="select-input" />
),
SelectContent: ({ children }: any) => {children}
,
SelectItem: ({ value, children }: any) => (
{
const select = document.querySelector('[data-testid="select"]')
const input = select?.querySelector('[data-testid="select-input"]')
if (input) {
input.value = value
input.dispatchEvent(new Event("change", { bubbles: true }))
}
}}
>
{children}
),
SelectTrigger: ({ children, id, className }: any) => (
{children}
),
SelectValue: () => ,
}))
vi.mock("@/components/ui/slider", () => ({
Slider: ({ value, onValueChange, min, max, step, className }: any) => (
onValueChange?.([Number.parseFloat(e.target.value)])}
min={min}
max={max}
step={step}
className={className}
data-testid="slider"
/>
),
}))
// Mock modal service
const mockCloseModal = vi.fn()
const mockModalData = {
mapping: null as any,
onSave: vi.fn(),
}
vi.mock("@/features/modals/services", () => ({
useModal: () => ({
modalData: mockModalData,
closeModal: mockCloseModal,
}),
}))
describe("MidiMappingEditorModal", () => {
const defaultMapping = {
targetParameter: "channel.1.volume",
messageType: "cc",
controller: 7,
channel: 1,
min: 0,
max: 1,
curve: "linear",
}
beforeEach(() => {
vi.clearAllMocks()
mockModalData.mapping = defaultMapping
mockModalData.onSave = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("Rendering", () => {
it("should render with mapping data", () => {
render()
expect(screen.getByText("channel.1.volume")).toBeInTheDocument()
expect(screen.getByText("CC CC7 CH1")).toBeInTheDocument()
})
it("should render all controls", () => {
render()
expect(screen.getByText("fairlightAudio.midi.mappingEditor.minimumValue")).toBeInTheDocument()
expect(screen.getByText("fairlightAudio.midi.mappingEditor.maximumValue")).toBeInTheDocument()
expect(screen.getByText("fairlightAudio.midi.mappingEditor.responseCurve")).toBeInTheDocument()
})
it("should render curve preview", () => {
const { container } = render()
const svg = container.querySelector("svg")
expect(svg).toBeInTheDocument()
expect(svg?.tagName).toBe("svg")
expect(screen.getByText("fairlightAudio.midi.mappingEditor.responseCurvePreview")).toBeInTheDocument()
})
it("should return null if no mapping provided", () => {
mockModalData.mapping = null
const { container } = render()
expect(container.firstChild).toBeNull()
})
it("should display mapping info correctly for different message types", () => {
mockModalData.mapping = {
targetParameter: "master.volume",
messageType: "noteon",
channel: 2,
min: 0.2,
max: 0.8,
curve: "exponential",
}
render()
expect(screen.getByText("master.volume")).toBeInTheDocument()
expect(screen.getByText("NOTEON CH2")).toBeInTheDocument()
})
it("should display initial slider values", () => {
mockModalData.mapping = {
...defaultMapping,
min: 0.25,
max: 0.75,
}
render()
expect(screen.getByText("0.25")).toBeInTheDocument()
expect(screen.getByText("0.75")).toBeInTheDocument()
})
})
describe("Value Controls", () => {
it("should update minimum value", () => {
render()
const sliders = screen.getAllByTestId("slider")
const minSlider = sliders[0]
fireEvent.change(minSlider, { target: { value: "0.3" } })
expect(screen.getByText("0.30")).toBeInTheDocument()
})
it("should update maximum value", () => {
render()
const sliders = screen.getAllByTestId("slider")
const maxSlider = sliders[1]
fireEvent.change(maxSlider, { target: { value: "0.85" } })
expect(screen.getByText("0.85")).toBeInTheDocument()
})
it("should handle slider edge cases", () => {
render()
const sliders = screen.getAllByTestId("slider")
// Test min boundary
fireEvent.change(sliders[0], { target: { value: "0" } })
expect(screen.getByText("0.00")).toBeInTheDocument()
// Test max boundary
fireEvent.change(sliders[1], { target: { value: "1" } })
expect(screen.getByText("1.00")).toBeInTheDocument()
})
})
describe("Curve Selection", () => {
it("should render all curve options", () => {
render()
expect(screen.getByText("fairlightAudio.midi.mappingEditor.curves.linear")).toBeInTheDocument()
expect(screen.getByText("fairlightAudio.midi.mappingEditor.curves.exponential")).toBeInTheDocument()
expect(screen.getByText("fairlightAudio.midi.mappingEditor.curves.logarithmic")).toBeInTheDocument()
})
it("should update curve type", async () => {
render()
// Verify initial state
const select = screen.getByTestId("select")
expect(select).toHaveAttribute("data-value", "linear")
// Verify all curve options exist
const exponentialOption = screen.getByText("fairlightAudio.midi.mappingEditor.curves.exponential")
expect(exponentialOption).toBeInTheDocument()
// The SelectItem component has data-value attribute
const selectItem = exponentialOption.closest("[data-value]")
expect(selectItem).toHaveAttribute("data-value", "exponential")
})
it("should show initial curve selection", () => {
mockModalData.mapping = {
...defaultMapping,
curve: "logarithmic",
}
render()
const select = screen.getByTestId("select")
expect(select).toHaveAttribute("data-value", "logarithmic")
})
})
describe("Curve Preview", () => {
it("should render SVG curve path", () => {
const { container } = render()
const path = container.querySelector('path[stroke="rgb(59, 130, 246)"]')
expect(path).toBeInTheDocument()
expect(path).toHaveAttribute("d")
expect(path).toHaveAttribute("fill", "none")
expect(path).toHaveAttribute("stroke-width", "2")
})
it("should render min/max indicators", () => {
const { container } = render()
const circles = container.querySelectorAll('circle[fill="rgb(59, 130, 246)"]')
expect(circles).toHaveLength(2)
// Check min indicator
expect(circles[0]).toHaveAttribute("cx", "0")
expect(circles[0]).toHaveAttribute("r", "3")
// Check max indicator
expect(circles[1]).toHaveAttribute("cx", "100")
expect(circles[1]).toHaveAttribute("r", "3")
})
it("should update curve preview when values change", () => {
const { container } = render()
// Change min value to see path update
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[0], { target: { value: "0.5" } })
// Path should reflect new min value
const path = container.querySelector("path")
const d = path?.getAttribute("d") || ""
// Path should start at y=50 (100 - 0.5 * 100)
expect(d).toMatch(/^M 0,50/)
})
it("should render grid lines", () => {
const { container } = render()
const gridLines = container.querySelectorAll("g.stroke-zinc-800 line")
expect(gridLines).toHaveLength(2)
// Horizontal line
expect(gridLines[0]).toHaveAttribute("x1", "0")
expect(gridLines[0]).toHaveAttribute("y1", "50")
expect(gridLines[0]).toHaveAttribute("x2", "100")
expect(gridLines[0]).toHaveAttribute("y2", "50")
// Vertical line
expect(gridLines[1]).toHaveAttribute("x1", "50")
expect(gridLines[1]).toHaveAttribute("y1", "0")
expect(gridLines[1]).toHaveAttribute("x2", "50")
expect(gridLines[1]).toHaveAttribute("y2", "100")
})
})
describe("Modal Actions", () => {
it("should close modal on cancel", () => {
render()
const cancelButton = screen.getByText("fairlightAudio.midi.mappingEditor.cancel")
fireEvent.click(cancelButton)
expect(mockCloseModal).toHaveBeenCalled()
})
it("should save changes and close modal", () => {
render()
// Change values
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[0], { target: { value: "0.2" } })
fireEvent.change(sliders[1], { target: { value: "0.9" } })
// Save without changing curve (keep it linear)
const saveButton = screen.getByText("fairlightAudio.midi.mappingEditor.saveChanges")
fireEvent.click(saveButton)
expect(mockModalData.onSave).toHaveBeenCalledWith({
min: 0.2,
max: 0.9,
curve: "linear",
})
expect(mockCloseModal).toHaveBeenCalled()
})
it("should handle save without onSave callback", () => {
mockModalData.onSave = undefined
render()
const saveButton = screen.getByText("fairlightAudio.midi.mappingEditor.saveChanges")
fireEvent.click(saveButton)
// Should still close modal
expect(mockCloseModal).toHaveBeenCalled()
})
it("should use default values when mapping lacks them", () => {
mockModalData.mapping = {
targetParameter: "test.param",
messageType: "cc",
// Missing min, max, curve
}
render()
// Check defaults are applied
expect(screen.getByText("0.00")).toBeInTheDocument()
expect(screen.getByText("1.00")).toBeInTheDocument()
const select = screen.getByTestId("select")
expect(select).toHaveAttribute("data-value", "linear")
})
})
describe("Curve Path Generation", () => {
it("should generate valid SVG path for linear curve", () => {
const { container } = render()
const path = container.querySelector("path")
const d = path?.getAttribute("d") || ""
// Should start with M and contain L commands
expect(d).toMatch(/^M/)
expect(d).toContain(" L ")
// Should have multiple points
const points = d.split(" L ").length
expect(points).toBeGreaterThan(10)
})
it("should update path when curve type changes", () => {
const { container } = render()
// Get initial path with default values (0 to 1)
const path = container.querySelector("path")
const d = path?.getAttribute("d") || ""
// Default linear path should go from bottom-left to top-right
expect(d).toMatch(/^M 0,100/) // starts at bottom (100 - 0 * 100)
expect(d).toContain("100,0") // ends at top (100 - 1 * 100)
// Path should have multiple points (more than 10)
const points = d.split(" L ").length
expect(points).toBeGreaterThan(10)
})
it("should update indicator positions based on min/max", () => {
const { container } = render()
// Change min value
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[0], { target: { value: "0.5" } })
const circles = container.querySelectorAll("circle")
// Min indicator should be at y=50 (100 - 0.5 * 100)
expect(circles[0]).toHaveAttribute("cy", "50")
// Change max value
fireEvent.change(sliders[1], { target: { value: "0.75" } })
const updatedCircles = container.querySelectorAll("circle")
// Max indicator should be at y=25 (100 - 0.75 * 100)
expect(updatedCircles[1]).toHaveAttribute("cy", "25")
})
})
describe("Edge Cases", () => {
it("should handle mapping without controller info", () => {
mockModalData.mapping = {
targetParameter: "test.param",
messageType: "noteon",
channel: 1,
// No controller field
}
render()
expect(screen.getByText("NOTEON CH1")).toBeInTheDocument()
})
it("should handle mapping without channel info", () => {
mockModalData.mapping = {
targetParameter: "test.param",
messageType: "pitchbend",
// No channel field
}
render()
expect(screen.getByText("PITCHBEND")).toBeInTheDocument()
})
it("should maintain state between re-renders", () => {
const { rerender } = render()
// Change values
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[0], { target: { value: "0.33" } })
// Re-render
rerender()
// Value should persist
expect(screen.getByText("0.33")).toBeInTheDocument()
})
})
})