velxio/frontend/src/__tests__/oscilloscope-trigger.test.ts

189 lines
7.4 KiB
TypeScript
Raw Normal View History

feat(scope/trigger): add Auto / Normal / Single-shot trigger modes Real digital storage scopes have a trigger that pins the visible window around a detected edge — without it, sparse activity (UART bytes once per loop, an interrupt firing every few seconds) scrolls off the screen faster than the eye can catch. Velxio's scope was free-running only, which made the recent UART TX waveform work effectively invisible at fine time/div settings: the byte burst was 87 µs but the window only showed the most recent 1 ms. Three trigger modes, matching what you'd find on a Rigol / Tektronix: * Auto — current free-running behaviour, window's right edge tracks the most recent sample. Default. * Normal — window pins around each triggering edge so the event lands at `triggerPosition * windowMs` from the left (default centred at 0.5). Keeps re-pinning on every new triggering edge. * Single — arms once, freezes the trace on the first triggering edge by flipping `running = false`. User clicks "Re-arm" to capture again. Three knobs configurable per mode: - source: which channel produces the trigger event - edge: rising (↑) / falling (↓) / either (⇅) - position: trigger lands at this fraction of the window (UI hard-codes centre 0.5 for now; the store field accepts any value if we want a draggable handle later) UI additions in the scope header (only shown when mode != auto): - source / edge dropdowns - status badge (Armed / Triggered / Captured) with pulse animation on Armed so the user knows the scope is waiting for an event - Re-arm button in Single mode after capture Canvas changes: - Dashed orange "T" marker drawn at the trigger position when an edge is latched and within the visible window. Store changes: - pushSample peeks at the trigger channel's previous state, detects a matching edge, sets triggeredAtMs (and stops `running` for Single mode). matchesTriggerEdge() exported for unit testing. - clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge all re-arm the trigger; rearmTrigger() explicitly resets and resumes capture (used by the Re-arm button after a single-shot). Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the existing 1892 tests still pass. Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat line" UX trap reported on the Discord follow-up — at 0.1 ms/div the window is 1 ms but bytes fire every 2 s, so without a trigger the chance of catching the burst is < 0.05 %. With Normal trigger on rising D1 the burst pins in the middle of the window and the user can zoom down to bit level (8.68 µs each) without losing it.
2026-05-23 02:11:03 +07:00
/**
* Oscilloscope trigger logic
*
* The trigger turns the otherwise free-running scope into something that
* actually behaves like a digital storage scope windows pin around
* detected edges instead of scrolling away from sparse activity. These
* tests pin down the three modes (auto / normal / single), the three
* edge selectors (rising / falling / either), and the re-arm flow.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
useOscilloscopeStore,
matchesTriggerEdge,
} from '../store/useOscilloscopeStore';
const initialState = useOscilloscopeStore.getState();
beforeEach(() => {
useOscilloscopeStore.setState({
...initialState,
channels: [],
samples: {},
triggerMode: 'auto',
triggerChannelId: null,
triggerEdge: 'rising',
triggerPosition: 0.5,
triggeredAtMs: null,
triggerStatus: 'idle',
running: true,
});
});
describe('matchesTriggerEdge', () => {
it('detects rising edges only when prev=LOW → new=HIGH', () => {
expect(matchesTriggerEdge(false, true, 'rising')).toBe(true);
expect(matchesTriggerEdge(true, false, 'rising')).toBe(false);
expect(matchesTriggerEdge(false, false, 'rising')).toBe(false);
});
it('detects falling edges only when prev=HIGH → new=LOW', () => {
expect(matchesTriggerEdge(true, false, 'falling')).toBe(true);
expect(matchesTriggerEdge(false, true, 'falling')).toBe(false);
expect(matchesTriggerEdge(true, true, 'falling')).toBe(false);
});
it('detects both directions when edge is either', () => {
expect(matchesTriggerEdge(false, true, 'either')).toBe(true);
expect(matchesTriggerEdge(true, false, 'either')).toBe(true);
expect(matchesTriggerEdge(true, true, 'either')).toBe(false);
expect(matchesTriggerEdge(false, false, 'either')).toBe(false);
});
});
describe('pushSample under each trigger mode', () => {
it('auto mode never sets triggeredAtMs', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
const chId = useOscilloscopeStore.getState().channels[0].id;
s.pushSample(chId, 0, false);
s.pushSample(chId, 1, true); // rising edge — auto ignores
s.pushSample(chId, 2, false); // falling edge — auto ignores
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
expect(useOscilloscopeStore.getState().samples[chId]).toHaveLength(3);
});
it('normal mode latches triggeredAtMs on the configured rising edge', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
const chId = useOscilloscopeStore.getState().channels[0].id;
s.setTriggerMode('normal');
s.pushSample(chId, 0, false); // first sample — no prior state, no trigger
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
s.pushSample(chId, 1, true); // ↑ rising — trigger fires
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
expect(useOscilloscopeStore.getState().triggerStatus).toBe('triggered');
s.pushSample(chId, 2, false); // ↓ falling — not "rising", no re-trigger
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
s.pushSample(chId, 3, true); // ↑ rising — re-trigger to new time
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(3);
});
it('single-shot mode stops capture after the first triggering edge', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
const chId = useOscilloscopeStore.getState().channels[0].id;
s.setTriggerMode('single');
s.pushSample(chId, 0, false);
s.pushSample(chId, 1, true); // ↑ trigger → capture, freeze
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
expect(useOscilloscopeStore.getState().running).toBe(false);
expect(useOscilloscopeStore.getState().triggerStatus).toBe('captured');
// Further pushes are ignored (`running === false` early-exit + capture lock)
s.pushSample(chId, 2, false);
s.pushSample(chId, 3, true);
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
expect(useOscilloscopeStore.getState().samples[chId]).toHaveLength(2);
});
it('rearmTrigger resumes single-shot capture', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
const chId = useOscilloscopeStore.getState().channels[0].id;
s.setTriggerMode('single');
s.pushSample(chId, 0, false);
s.pushSample(chId, 1, true); // capture
expect(useOscilloscopeStore.getState().running).toBe(false);
s.rearmTrigger();
expect(useOscilloscopeStore.getState().running).toBe(true);
expect(useOscilloscopeStore.getState().triggerStatus).toBe('armed');
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
s.pushSample(chId, 2, false);
s.pushSample(chId, 3, true); // re-capture
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(3);
});
it('falling-edge selector ignores rising edges', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
const chId = useOscilloscopeStore.getState().channels[0].id;
s.setTriggerMode('normal');
s.setTriggerEdge('falling');
s.pushSample(chId, 0, false);
s.pushSample(chId, 1, true); // rising — ignored
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
s.pushSample(chId, 2, false); // falling — fires
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(2);
});
it('only the configured trigger channel fires the trigger', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
s.addChannel('uno-1', 13, 'D13');
const [chD1, chD13] = useOscilloscopeStore.getState().channels.map((c) => c.id);
s.setTriggerMode('normal');
s.setTriggerChannel(chD13);
s.pushSample(chD1, 0, false);
s.pushSample(chD1, 1, true); // edge on D1 — should NOT trigger (channel mismatch)
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
s.pushSample(chD13, 0, false);
s.pushSample(chD13, 1, true); // edge on D13 — triggers
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
});
it('removing the trigger channel falls back to the first remaining channel', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
s.addChannel('uno-1', 13, 'D13');
const [chD1, chD13] = useOscilloscopeStore.getState().channels.map((c) => c.id);
s.setTriggerMode('normal');
s.setTriggerChannel(chD13);
s.removeChannel(chD13);
expect(useOscilloscopeStore.getState().triggerChannelId).toBeNull();
// With triggerChannelId === null, the resolver falls back to the first
// remaining channel (chD1) so rising edges on D1 now trigger.
s.pushSample(chD1, 0, false);
s.pushSample(chD1, 1, true);
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
});
it('clearSamples re-arms the trigger', () => {
const s = useOscilloscopeStore.getState();
s.addChannel('uno-1', 1, 'D1');
const chId = useOscilloscopeStore.getState().channels[0].id;
s.setTriggerMode('normal');
s.pushSample(chId, 0, false);
s.pushSample(chId, 1, true);
expect(useOscilloscopeStore.getState().triggeredAtMs).toBe(1);
s.clearSamples();
expect(useOscilloscopeStore.getState().triggeredAtMs).toBeNull();
expect(useOscilloscopeStore.getState().triggerStatus).toBe('armed');
expect(useOscilloscopeStore.getState().samples[chId]).toHaveLength(0);
});
});