diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index fbd817f..eb64d52 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -2,15 +2,19 @@ -
- - {typeLabel} - {detail} - {relTime} - {#if showLearnBtn} - +
+
+ + {#if showLearnBtn} + + {/if} +
+ + {#if expanded} +
+ {#if fired} +
Trigger {event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}
+
Action {event.action?.type ?? '?'}: {event.action?.summary ?? '?'}
+
Result {event.result ?? '?'}{event.error ? ` — ${event.error}` : ''}
+
Latency {event.latency_us != null ? formatLatency(event.latency_us) : '—'}
+
Mode {event.mode ?? '?'}
+
Time {formatAbsoluteTime(event.timestamp)}
+ {:else} +
Device {event.device_id ?? 'Unknown'}
+
Channel {event.channel ?? '—'}
+
Type {event.event_type}
+
Time {formatAbsoluteTime(event.timestamp)}
+ {/if} +
{/if}
diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 297ab04..af9da7a 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -1,5 +1,5 @@ /** - * EventRow tests (GUI v2 Phase 4) + * EventRow tests (GUI v2 Phase 4 + ADR-014 Phase 3A) */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -10,6 +10,17 @@ vi.mock('$lib/utils/event-helpers', () => ({ getEventTypeLabel: (e) => e.event_type === 'NoteOn' ? 'Note' : 'CC', getEventDetail: (e) => e.event_type === 'NoteOn' ? '36 on v=100 ch.1' : '7 = 127 ch.1', getRelativeTime: () => 'now', + formatLatency: (us) => `${(us / 1000).toFixed(1)}ms`, + formatAbsoluteTime: () => '12:34:56.789', + isFiredEvent: (e) => e?._type === 'mapping_fired', + getFiredDotClass: (e) => { + if (typeof e !== 'object' || e === null || e._type !== 'mapping_fired') return 'unknown'; + const t = e?.trigger?.type; + if (t === 'note') return 'note'; + if (t === 'cc') return 'cc'; + if (t === 'encoder') return 'encoder'; + return 'unknown'; + }, })); vi.mock('$lib/stores/events.js', async () => { @@ -31,6 +42,32 @@ vi.mock('$lib/stores/events.js', async () => { }; }); +const RAW_EVENT = { + timestamp: Date.now(), + event_type: 'NoteOn', + note: 36, + velocity: 100, + channel: 1, + device_id: 'Mikro', + _id: 1, +}; + +const FIRED_EVENT = { + timestamp: Date.now(), + event_type: 'MappingFired', + _type: 'mapping_fired', + _id: 2, + trigger: { type: 'note', device: 'Mikro', channel: null, number: 36, value: 100 }, + action: { type: 'keystroke', summary: '⌘+C' }, + result: 'ok', + latency_us: 450, + mode: 'Default', + mapping_label: 'Copy shortcut', + description: 'Fired: note → ⌘+C', + device_id: 'Mikro', + channel: null, +}; + describe('EventRow', () => { afterEach(cleanup); @@ -39,58 +76,262 @@ describe('EventRow', () => { return render(EventRow, { props }); } + // --- Existing functionality --- + it('renders event type label', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn', note: 36, velocity: 100, channel: 1 }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.getByText('Note')).toBeTruthy(); }); it('renders event detail', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn', note: 36, velocity: 100, channel: 1 }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.getByText('36 on v=100 ch.1')).toBeTruthy(); }); it('renders relative time', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.getByText('now')).toBeTruthy(); }); it('shows Learn button when showLearnBtn is true', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - showLearnBtn: true, - }); + await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); expect(screen.getByText('Learn')).toBeTruthy(); }); it('does not show Learn button by default', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.queryByText('Learn')).toBeNull(); }); it('applies highlight class when highlight prop is true', async () => { - const { container } = await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - highlight: true, - }); + const { container } = await importAndRender({ event: RAW_EVENT, highlight: true }); expect(container.querySelector('.highlight')).toBeTruthy(); }); it('renders Learn button that is clickable', async () => { - const event = { timestamp: Date.now(), event_type: 'NoteOn', note: 36 }; - await importAndRender({ event, showLearnBtn: true }); - + await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); const btn = screen.getByText('Learn'); - expect(btn).toBeTruthy(); - // Verify it's a button element that can be clicked expect(btn.tagName).toBe('BUTTON'); await fireEvent.click(btn); }); + + // --- Expand/Collapse (ADR-014 Phase 3A) --- + + it('starts collapsed by default', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + expect(container.querySelector('.event-row.expanded')).toBeNull(); + expect(container.querySelector('.event-detail-panel')).toBeNull(); + }); + + it('expands on click to show detail panel', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + await fireEvent.click(toggle); + expect(container.querySelector('.event-row.expanded')).toBeTruthy(); + expect(container.querySelector('.event-detail-panel')).toBeTruthy(); + }); + + it('collapses on second click', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + await fireEvent.click(toggle); + expect(container.querySelector('.expanded')).toBeTruthy(); + await fireEvent.click(toggle); + expect(container.querySelector('.expanded')).toBeNull(); + }); + + it('shows chevron element', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const chevron = container.querySelector('.event-chevron'); + expect(chevron).toBeTruthy(); + expect(chevron.textContent).toBe('▸'); + }); + + it('rotates chevron when expanded', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const toggle = container.querySelector('.event-toggle')!; + await fireEvent.click(toggle); + const chevron = container.querySelector('.event-chevron')!; + expect(chevron).toBeTruthy(); + expect(chevron.textContent).toBe('▾'); + }); + + it('detail panel shows device info for raw events', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Device'); + expect(panel.textContent).toContain('Mikro'); + }); + + it('detail panel shows channel for raw events', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Channel'); + expect(panel.textContent).toContain('1'); + }); + + it('detail panel shows absolute timestamp', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Time'); + expect(panel.textContent).toContain('12:34:56.789'); + }); + + // --- Fired event styling (ADR-014 Phase 3A) --- + + it('renders fired icon ⚡ for mapping_fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const icon = container.querySelector('.event-fired-icon')!; + expect(icon).toBeTruthy(); + expect(icon.textContent).toBe('⚡'); + }); + + it('does not render standard dot for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + expect(container.querySelector('.event-dot')).toBeNull(); + }); + + it('renders "Fired" label for mapping_fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const typeLabel = container.querySelector('.event-type')!; + expect(typeLabel).toBeTruthy(); + expect(typeLabel.textContent).toBe('Fired'); + }); + + it('applies fired class on event-row for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + expect(container.querySelector('.event-row.fired')).toBeTruthy(); + }); + + it('shows trigger→action content for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const detail = container.querySelector('.event-detail')!; + expect(detail).toBeTruthy(); + expect(detail.textContent).toContain('note'); + expect(detail.textContent).toContain('⌘+C'); + }); + + it('has left border colored by trigger type for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const row = container.querySelector('.event-row.fired'); + expect(row).toBeTruthy(); + // The fired class presence implies border styling via CSS + }); + + // --- Fired event detail panel --- + + it('fired detail panel shows trigger info', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Trigger'); + expect(panel.textContent).toContain('note'); + expect(panel.textContent).toContain('36'); + }); + + it('fired detail panel shows action info', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Action'); + expect(panel.textContent).toContain('⌘+C'); + }); + + it('fired detail panel shows result', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Result'); + expect(panel.textContent).toContain('ok'); + }); + + it('fired detail panel shows latency', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Latency'); + expect(panel.textContent).toContain('0.5ms'); + }); + + it('fired detail panel shows mode', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Mode'); + expect(panel.textContent).toContain('Default'); + }); + + it('fired detail panel shows error message when result is error', async () => { + const errorEvent = { + ...FIRED_EVENT, + result: 'error', + error: 'App not found', + }; + const { container } = await importAndRender({ event: errorEvent }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('error'); + expect(panel.textContent).toContain('App not found'); + }); + + it('Learn button click does not toggle expand', async () => { + const { container } = await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); + const btn = screen.getByText('Learn'); + await fireEvent.click(btn); + // stopPropagation should prevent expand + expect(container.querySelector('.expanded')).toBeNull(); + }); + + // --- Keyboard accessibility (native