diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 75a2e498..bdbe4cf7 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -13,7 +13,7 @@ use super::mcp_types::{ToolCallResult, ToolDefinition, ToolRiskTier}; use conductor_core::config::{ActionConfig, Config, Trigger}; use conductor_core::device_intelligence::fingerprint::{ - EventStats, suggest_binding as compute_suggestion, + DeviceCategory, EventStats, suggest_binding as compute_suggestion, }; use dashmap::DashMap; use serde_json::{Value, json}; @@ -688,7 +688,7 @@ pub fn get_tool_definitions() -> Vec { // Event fingerprinting (ADR-022 Phase 5D, #755) ToolDefinition { name: "conductor_suggest_binding".to_string(), - description: "Suggest a binding configuration for a port. Uses live event fingerprinting when events have been observed (method: event_fingerprint, confidence based on observed event volume), and falls back to port-name heuristics otherwise (method: port_name_heuristic, confidence capped at 0.5). Returns device category, confidence score, suggested alias, and reasoning.".to_string(), + description: "Suggest a binding configuration for a port. Uses live event fingerprinting when events have been observed (method: event_fingerprint), falls back to port-name heuristics (method: port_name_heuristic, confidence capped at 0.5). Returns primary suggestion (category, confidence, alias, protocol, reasoning) plus ranked alternatives array with lower-confidence fallback categories.".to_string(), input_schema: json!({ "type": "object", "properties": { @@ -795,6 +795,68 @@ impl McpToolExecutor { Self {} } + /// Build ranked alternative suggestions using core's suggest_binding (P18). + /// Returns up to 2 alternatives with lower confidence than the primary. + fn build_alternatives( + primary_category: &DeviceCategory, + primary_confidence: f64, + port_name: &str, + ) -> Vec { + // No alternatives when primary has no confidence (Unknown/no data) + if primary_confidence <= 0.0 { + return Vec::new(); + } + + // Minimal synthetic EventStats to trigger each category. + // Uses core's compute_suggestion to keep alias/protocol in sync. + let categories = [ + (DeviceCategory::PadController, 36u8, 40u8, false, false), + (DeviceCategory::Keyboard, 21, 100, false, false), + (DeviceCategory::FaderController, 0, 1, true, false), + (DeviceCategory::EncoderController, 0, 0, true, true), + (DeviceCategory::GameController, 0, 0, false, false), + ]; + let mut alts = Vec::new(); + let mut rank = 0usize; + for (cat, a, b, is_cc, is_encoder) in &categories { + if cat == primary_category { + continue; + } + if rank >= 2 { + break; + } + let mut stats = EventStats::new(); + if *cat == DeviceCategory::GameController { + stats.record_gamepad(); + stats.record_gamepad(); + } else if *is_encoder { + // Encoder: high event density on few CCs (>10 hits, <=4 unique CCs) + for _ in 0..12 { + stats.record_cc(0); + } + } else if *is_cc { + stats.record_cc(*a); + stats.record_cc(*b); + } else { + stats.record_note(*a, 80); + stats.record_note(*b, 80); + } + let suggestion = compute_suggestion(&stats, port_name); + let cat_value = serde_json::to_value(cat) + .unwrap_or(serde_json::Value::String("Unknown".to_string())); + let decay = if rank == 0 { 0.3 } else { 0.15 }; + let alt_conf = (primary_confidence * decay).min(0.2); + alts.push(json!({ + "category": cat_value, + "suggested_alias": suggestion.suggested_alias, + "suggested_protocol": suggestion.suggested_protocol, + "confidence": alt_conf, + })); + rank += 1; + } + alts + } + /// Look up last event timestamp for a port from the fingerprint stats. fn lookup_last_event_ms( event_stats: Option<&DashMap>, @@ -1689,6 +1751,11 @@ impl McpToolExecutor { let suggestion = compute_suggestion(stats, port_name); let category_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); + let alternatives = Self::build_alternatives( + &suggestion.category, + suggestion.confidence, + port_name, + ); Some(ToolCallResult::json(&json!({ "port_name": port_name, "category": category_value, @@ -1698,6 +1765,7 @@ impl McpToolExecutor { "reasoning": suggestion.reasoning, "method": "event_fingerprint", "event_count": stats.note_count + stats.cc_count + stats.gamepad_count, + "alternatives": alternatives, "note": "Classification based on observed event patterns" }))) } else { @@ -1795,6 +1863,9 @@ impl McpToolExecutor { let category_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); + let alternatives = + Self::build_alternatives(&suggestion.category, heuristic_confidence, port_name); + ToolCallResult::json(&json!({ "port_name": port_name, "category": category_value, @@ -1803,6 +1874,7 @@ impl McpToolExecutor { "suggested_protocol": suggestion.suggested_protocol, "reasoning": suggestion.reasoning, "method": "port_name_heuristic", + "alternatives": alternatives, "note": "Based on port name heuristics. Confidence is capped at 0.5 without real event data." })) } @@ -2199,6 +2271,23 @@ mod tests { assert_eq!(parsed["category"], "PadController"); assert!(parsed["confidence"].as_f64().unwrap() <= 0.5); assert_eq!(parsed["method"], "port_name_heuristic"); + // P18: ranked alternatives + let alts = parsed["alternatives"] + .as_array() + .expect("should have alternatives"); + assert!(alts.len() >= 1, "should have at least 1 alternative"); + let mut prev_conf = parsed["confidence"].as_f64().unwrap(); + for alt in alts { + assert!(alt["category"].is_string()); + assert!(alt["suggested_alias"].is_string()); + let alt_conf = alt["confidence"].as_f64().unwrap(); + assert!(alt_conf > 0.0, "alternative confidence should be positive"); + assert!( + alt_conf <= prev_conf, + "alternatives should have decreasing confidence" + ); + prev_conf = alt_conf; + } } else { panic!("Expected text content"); } @@ -2286,6 +2375,37 @@ mod tests { } } + #[tokio::test] + async fn test_suggest_binding_unknown_port_returns_empty_alternatives() { + let executor = McpToolExecutor::new(); + // Unknown port name — primary confidence is 0, so alternatives should be empty + let result = executor + .execute( + "conductor_suggest_binding", + Some(json!({"port_name": "Totally Unknown Device XYZ"})), + None, + None, + None, + None, + ) + .await; + assert!(result.is_error.is_none()); + let content = &result.content[0]; + if let ToolContent::Text { text } = content { + let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["category"], "Unknown"); + let alts = parsed["alternatives"] + .as_array() + .expect("should have alternatives array"); + assert!( + alts.is_empty(), + "Unknown port (0 confidence) should have no alternatives" + ); + } else { + panic!("Expected text content"); + } + } + #[tokio::test] async fn test_suggest_binding_falls_back_with_empty_stats() { let executor = McpToolExecutor::new(); diff --git a/conductor-gui/src-tauri/src/commands.rs b/conductor-gui/src-tauri/src/commands.rs index 44856d9b..c95c159b 100644 --- a/conductor-gui/src-tauri/src/commands.rs +++ b/conductor-gui/src-tauri/src/commands.rs @@ -2248,6 +2248,32 @@ pub struct DiscoveredPort { pub direction: String, pub binding: Option, pub connected: bool, + /// Port metadata (always includes display_name; HID ports add manufacturer, vendor_id, product_id) + #[serde(default)] + pub metadata: std::collections::HashMap, +} + +/// Extract metadata from a port name. Strips OS-appended instance suffixes +/// (" #2", " Port 3") while preserving model numbers (e.g., "nanoKONTROL2"). +fn port_metadata(name: &str) -> std::collections::HashMap { + let mut meta = std::collections::HashMap::new(); + let base = if let Some((prefix, suffix)) = name.rsplit_once(" #") { + if suffix.chars().all(|c| c.is_ascii_digit()) { + prefix + } else { + name + } + } else if let Some((prefix, suffix)) = name.rsplit_once(" Port ") { + if suffix.chars().all(|c| c.is_ascii_digit()) { + prefix + } else { + name + } + } else { + name + }; + meta.insert("display_name".to_string(), base.to_string()); + meta } /// Get all discovered ports across protocols with binding status (ADR-022 Phase 2A) @@ -2281,12 +2307,14 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); + let metadata = port_metadata(&name); ports.push(DiscoveredPort { name, protocol: "midi".to_string(), direction: "receive".to_string(), binding, connected: true, + metadata, }); } } @@ -2308,12 +2336,14 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); + let metadata = port_metadata(&name); ports.push(DiscoveredPort { name, protocol: "midi".to_string(), direction: "send".to_string(), binding, connected: true, + metadata, }); } } @@ -2348,12 +2378,19 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); + let mut metadata = port_metadata(name); + if let Some(mfg) = device_info.manufacturer_string() { + metadata.insert("manufacturer".to_string(), mfg.to_string()); + } + metadata.insert("vendor_id".to_string(), format!("0x{:04X}", vid)); + metadata.insert("product_id".to_string(), format!("0x{:04X}", pid)); ports.push(DiscoveredPort { name: name.to_string(), protocol: "hid".to_string(), direction: "receive".to_string(), binding, connected: true, + metadata, }); } } @@ -2750,4 +2787,34 @@ mod tests { .contains("must be within the midimon config directory") ); } + + #[test] + fn test_port_metadata_preserves_model_numbers() { + let meta = port_metadata("nanoKONTROL2"); + assert_eq!(meta["display_name"], "nanoKONTROL2"); + } + + #[test] + fn test_port_metadata_strips_instance_suffix() { + let meta = port_metadata("Maschine Mikro MK3 #2"); + assert_eq!(meta["display_name"], "Maschine Mikro MK3"); + } + + #[test] + fn test_port_metadata_strips_port_suffix() { + let meta = port_metadata("USB MIDI Port 3"); + assert_eq!(meta["display_name"], "USB MIDI"); + } + + #[test] + fn test_port_metadata_no_suffix() { + let meta = port_metadata("Launchpad Mini MK3 MIDI"); + assert_eq!(meta["display_name"], "Launchpad Mini MK3 MIDI"); + } + + #[test] + fn test_port_metadata_preserves_apc40() { + let meta = port_metadata("APC40"); + assert_eq!(meta["display_name"], "APC40"); + } } diff --git a/conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte b/conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte deleted file mode 100644 index 8b2e7d2c..00000000 --- a/conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - diff --git a/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts b/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts deleted file mode 100644 index 1fc5e723..00000000 --- a/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * DeviceStatusPills tests (GUI v2 Phase 4) - * Updated for ADR-019 Phase 1B: multi-select activeDeviceFilter from workspaceFilters.js - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; -import { get } from 'svelte/store'; - -const mockDeviceBindings = vi.hoisted(() => { - const { writable } = require('svelte/store'); - return writable({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true }, - { device_id: 'nanok', alias: 'nanoK', connected: true, enabled: false }, - ], - loading: false, - error: null, - }); -}); - -const mockToggleMute = vi.hoisted(() => vi.fn()); - -const mockActiveDeviceFilter = vi.hoisted(() => { - const { writable } = require('svelte/store'); - return writable(new Set()); -}); - -vi.mock('$lib/stores.js', () => ({ - deviceBindingsStore: { - subscribe: mockDeviceBindings.subscribe, - toggleMute: mockToggleMute, - }, -})); - -vi.mock('$lib/stores/workspaceFilters.js', () => ({ - activeDeviceFilter: mockActiveDeviceFilter, -})); - -vi.mock('$lib/stores/workspace.js', () => { - const { writable } = require('svelte/store'); - return { - workspaceView: writable('mappings'), - WORKSPACE_VIEWS: { - MAPPINGS: 'mappings', - SIGNAL_FLOW: 'signal-flow', - }, - }; -}); - -vi.mock('$lib/stores/events.js', async () => { - const { writable, derived, readable } = await vi.importActual('svelte/store'); - return { - eventBuffer: writable([]), - eventTypeFilter: writable('all'), - eventChannelFilter: writable('all'), - eventFiredFilter: writable(true), - toastsEnabled: writable(true), - toastsContinuous: writable(false), - eventStreamVisible: writable(true), - learnSessionActive: writable(false), - autoScroll: writable(true), - nowTick: readable(Date.now()), - filteredEvents: derived(writable([]), ($e) => $e), - pushEvent: vi.fn(), - pushEvents: vi.fn(), - clearEvents: vi.fn(), - initEventListener: vi.fn().mockResolvedValue(null), - mappingFireState: writable({}), - mappingFireCount: writable({}), - isMappingFired: () => false, - getMappingFireCount: () => 0, - }; -}); - -vi.mock('$lib/utils/event-helpers', () => ({ - normalizeEventType: (e) => e.event_type, -})); - -describe('DeviceStatusPills', () => { - afterEach(() => { - cleanup(); - mockActiveDeviceFilter.set(new Set()); - mockToggleMute.mockClear(); - // Reset bindings to default for tests that modify them - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true }, - { device_id: 'nanok', alias: 'nanoK', connected: true, enabled: false }, - ], - loading: false, - error: null, - }); - }); - - it('renders device pills', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - expect(screen.getByText('Mikro')).toBeTruthy(); - expect(screen.getByText('nanoK')).toBeTruthy(); - }); - - it('clicking a pill adds device to filter', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(true); - }); - - it('clicking selected pill removes it from filter', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.size).toBe(0); - }); - - it('multi-select: clicking second device adds to Set', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('nanoK')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(true); - expect(filter.has('nanok')).toBe(true); - expect(filter.size).toBe(2); - }); - - it('multi-select: clicking selected device removes from Set', async () => { - mockActiveDeviceFilter.set(new Set(['mikro', 'nanok'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(false); - expect(filter.has('nanok')).toBe(true); - expect(filter.size).toBe(1); - }); - - it('deselecting last device resets to empty Set (all)', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.size).toBe(0); - }); - - it('all pills active when filter is empty Set', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pills = container.querySelectorAll('.device-pill'); - expect(pills[0].classList.contains('active')).toBe(true); - expect(pills[1].classList.contains('active')).toBe(true); - }); - - it('only selected pill active when filter has one device', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pills = container.querySelectorAll('.device-pill'); - expect(pills[0].classList.contains('active')).toBe(true); // Mikro - expect(pills[1].classList.contains('active')).toBe(false); // nanoK - }); - - it('muted device has muted class', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pills = container.querySelectorAll('.device-pill'); - // nanoK is muted (enabled: false) - expect(pills[1].classList.contains('muted')).toBe(true); - // Mikro is not muted - expect(pills[0].classList.contains('muted')).toBe(false); - }); - - it('right-click shows context menu', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.contextMenu(screen.getByText('Mikro')); - expect(screen.getByText('🔇 Mute Binding')).toBeTruthy(); - expect(screen.getByText('⚙ Binding Settings')).toBeTruthy(); - }); - - it('right-click on muted device shows Unmute', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.contextMenu(screen.getByText('nanoK')); - expect(screen.getByText('🔊 Unmute Binding')).toBeTruthy(); - }); - - it('clicking mute in context menu calls toggleMute', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.contextMenu(screen.getByText('Mikro')); - await fireEvent.click(screen.getByText('🔇 Mute Binding')); - expect(mockToggleMute).toHaveBeenCalledWith('mikro', false); - }); - - it('renders nothing when no bindings', async () => { - mockDeviceBindings.set({ bindings: [], loading: false, error: null }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - expect(container.querySelector('.device-pills')).toBeNull(); - }); - - it('compact prop truncates long names', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'longdevice', alias: 'VeryLongDeviceName', connected: true, enabled: true }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills, { props: { compact: true } }); - expect(screen.getByText('VeryLong\u2026')).toBeTruthy(); - }); - - it('compact prop does not truncate short names', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills, { props: { compact: true } }); - expect(screen.getByText('Mikro')).toBeTruthy(); - expect(screen.getByText('nanoK')).toBeTruthy(); - }); - - it('linked prop shows link indicator', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills, { props: { linked: true } }); - const linkIndicator = container.querySelector('.link-indicator'); - expect(linkIndicator).toBeTruthy(); - }); - - it('linked prop absent hides link indicator', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const linkIndicator = container.querySelector('.link-indicator'); - expect(linkIndicator).toBeNull(); - }); - - // ADR-021 Phase 3B — DirectionBadge and output-only pills (#673) - describe('DirectionBadge integration (#673)', () => { - it('renders DirectionBadge with arrow for input device', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Input' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const badge = container.querySelector('.direction-badge'); - expect(badge).toBeTruthy(); - expect(badge?.textContent?.trim()).toBe('\u2190'); // ← - }); - - it('renders DirectionBadge with arrow for output device', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const badge = container.querySelector('.direction-badge'); - expect(badge?.textContent?.trim()).toBe('\u2192'); // → - }); - - it('renders DirectionBadge with arrow for bidirectional device', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Bidirectional' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const badge = container.querySelector('.direction-badge'); - expect(badge?.textContent?.trim()).toBe('\u2194'); // ↔ - }); - - it('output-only pill has .output-only class', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.classList.contains('output-only')).toBe(true); - }); - - it('clicking output-only pill does NOT add to filter', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Synth')); - const filter = get(mockActiveDeviceFilter); - expect(filter.size).toBe(0); - }); - - it('output-only pill aria-label includes "no event filtering"', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.getAttribute('aria-label')).toContain('no event filtering'); - }); - - it('input pill aria-label includes device name and protocol', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Input' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.getAttribute('aria-label')).toBe('Mikro, MIDI'); - }); - - it('muted output-only pill has both .output-only and .muted classes', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: false, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.classList.contains('output-only')).toBe(true); - expect(pill?.classList.contains('muted')).toBe(true); - }); - - it('prunes output-only device from activeDeviceFilter when bindings update', async () => { - // Simulate: device was Input and in the filter, then switched to Output - mockActiveDeviceFilter.set(new Set(['synth'])); - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - // The reactive pruning should remove 'synth' from the filter - await vi.waitFor(() => { - const filter = get(mockActiveDeviceFilter); - expect(filter.has('synth')).toBe(false); - }); - }); - - it('input devices still toggle filter normally (regression)', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Input' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(true); - }); - }); -});