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