Skip to content

Testing & mocking

Mocking this SDK is a transport swap, not a mode flag. There is no mockMode, no mock-ID sniffing, no jest.mock, and no module remapping. You hand the provider a config wired to a simulated transport, and every hook, engine, and code path runs exactly as it does against real hardware — because the only thing that changed is where the bytes come from. If that story did not fit on one page, the SDK’s own design goal would have failed.

createTestConfig() from @rogue/console-sdk/testing composes a deterministic clock, the simulated transport, memory storage, and an in-memory firmware manifest into a ready-to-use config, and hands back every handle a test drives.

import { createTestConfig } from '@rogue/console-sdk/testing';
const { config, transport, storage, clock } = createTestConfig({
consoles: [{ model: 'echo-rower', serial: '400123', connected: true }],
});

Pass config straight to <ConsoleProvider config={config}>. Everything is a fresh instance per call, so tests are isolated and parallel-safe by construction — there is no reset API because none is needed.

  • transport — drive the hardware side: add consoles, flip presence, inject faults, run scripts.
  • storage — inspect persisted state (saved devices).
  • clock — deterministic time; only durations ever need advancing.

The simulated clock starts frozen and moves only through clock.advance(ms), which walks pending timers, intervals, and frames and auto-flushes the microtask queue. Work that settles purely via promises (a connect resolving) needs no advance; only elapsed durations — a 30-second script, a reconnect backoff — do.

transport.console('400123').run(fixtures.scripted([{ kind: 'steady', seconds: 30 }]));
await clock.advance(30_000); // emit 30 s of frames deterministically
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { ConsoleProvider } from '@rogue/console-sdk';
import { createTestConfig } from '@rogue/console-sdk/testing';
test('connects to a discovered console', async () => {
const { config, transport } = createTestConfig({
consoles: [{ model: 'echo-rower', serial: '400123' }], // present, not connected
});
render(
<ConsoleProvider config={config}>
<ConnectScreen />
</ConsoleProvider>,
);
fireEvent.press(await screen.findByText('Echo Rower'));
await waitFor(() => expect(transport.console('400123').workoutState).toBeDefined());
expect(await screen.findByText(/connected/i)).toBeTruthy();
});

The transport injects link faults exactly where real ones occur. dropConnection() is an unexpected BLE loss (the reconnect-policy path); restoreConnection() brings the device back.

test('survives a mid-workout drop', async () => {
const { config, transport, clock } = createTestConfig({
consoles: [{ model: 'echo-rower', serial: '400123', connected: true }],
});
const console = transport.console('400123');
render(<ConsoleProvider config={config}><LiveConsoleScreen /></ConsoleProvider>);
console.run(fixtures.scripted([{ kind: 'steady', seconds: 60 }]));
await clock.advance(10_000);
console.dropConnection();
await clock.advance(1_000);
// device.connection.status === 'reconnecting' — degraded, not gone
console.restoreConnection();
await clock.advance(5_000); // reconnect backoff elapses
// back to 'ready'; the stream resumes
});

Run a full scripted session and assert on the reconciled value the consumer receives. (The workout/result surface lands with its own milestone; the shape of the test — build the world, run a script, advance the clock, assert on state — is identical.)

const { config, transport, clock } = createTestConfig({
consoles: [{ model: 'echo-rower', serial: '400123', connected: true }],
});
transport.console('400123').run(
fixtures.scripted([
{ kind: 'warmup', seconds: 30 },
{ kind: 'steady', seconds: 90 },
{ kind: 'sprint', seconds: 30 },
]),
);
await clock.advance(150_000);

transport.console(serial) returns a SimulatedConsole — “the person at the machine plus the firmware”. It speaks real protocol bytes, so correlation, fragmentation, retries, and reconciliation all execute for real above the transport seam. What a test can do to one console:

  • Presence: appear(), vanish(), powerOff().
  • Link faults: dropConnection(), restoreConnection().
  • Autopilot: run(script) for realistic frames; emitFrame(partial) for one exact frame; pressKey('select' | 'home') for front-panel input.
  • Command faults: failNextCommand('start', 'timeout') drops the response frame; failNextCommand('programWorkout', 'reject') answers with the firmware failure — one-shot, exactly where real faults happen.
  • Assertions: received.program, received.displayUnits, received.heartRateBpm, and workoutState.

fixtures builds the autopilot scripts a console runs:

import { fixtures } from '@rogue/console-sdk/testing';
// A phase-scripted synthetic session (driven through the real firmware reducer).
fixtures.scripted([
{ kind: 'warmup', seconds: 60 },
{ kind: 'steady', pace500Seconds: 122, strokeRate: 24, seconds: 120 },
{ kind: 'sprint', seconds: 30 },
]);
// The real 152-second rowing capture, lazy-loaded (kept out of bundles that
// never call it).
const capture = await fixtures.rowing152s();
// Bring your own capture (record one from a real console via diagnostics).
fixtures.fromRecording(myFrames);

Each phase carries field-realistic default pace and stroke rate; override any of them per phase. heartRateBpm accepts a fixed value or a { from, to } ramp.

For hook-level unit tests, wrap renderHook in a provider built from the same config:

import { renderHook, act } from '@testing-library/react-native';
import { ConsoleProvider } from '@rogue/console-sdk';
import { useDevices } from '@rogue/console-sdk';
import { createTestConfig } from '@rogue/console-sdk/testing';
test('useDevices reflects a connected console', async () => {
const { config, transport, clock } = createTestConfig({
consoles: [{ model: 'echo-rower', serial: '400123', connected: true }],
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ConsoleProvider config={config}>{children}</ConsoleProvider>
);
const { result } = renderHook(() => useDevices({ connected: true }), { wrapper });
await act(() => clock.advance(0)); // flush hydration + first commit
expect(result.current).toHaveLength(1);
});

Because /testing ships in production builds and costs nothing unimported, the same simulated transport is your demo mode. Build a demo config with clock: 'realtime'-style timing (scripts run on the wall clock) and swap it into the provider at runtime — remounting the provider with the demo config tears the real runtime down and stands the simulated one up.

Drive the fleet when a workout runs. A SimulatedConsole is the firmware without an athlete — it only streams frames while someone supplies effort. So a demo workout will sit at zero unless you play the “person at the machine”: watch useWorkoutSession() and start an effort script on each participating console when the session reaches running. Respect the fleet model — every device for a broadcast workout, only progress.activeDeviceId for a turn-based relay:

function AutoAthlete({ transport }: { transport: SimulatedTransport }) {
const session = useWorkoutSession();
const running = useRef(new Set<string>());
useEffect(() => {
if (session.phase !== 'running') return;
const active = session.progress.activeDeviceId;
const ids = active ? [active] : session.devices.map((d) => d.deviceId);
for (const id of ids) {
if (running.current.has(id)) continue;
const console = transport.consoles.find((c) => c.id === id);
console?.run(fixtures.scripted([{ kind: 'steady', seconds: 3600 }]));
running.current.add(id);
}
}, [session, transport]);
return null;
}

The example app ships exactly this as AutoAthlete — mount it once beside <ConsoleProvider> in demo mode and every tile lights up with no hardware.

These patterns are gone on purpose — do not look for them:

Old pattern Replaced by
A mockMode / demo config flag A different transport in config
Mock-ID sniffing (if (id.startsWith('MOCK'))) Real device ids from the simulated transport
jest.mock('react-native-ble-plx') createTestConfig() — no module mocking
moduleNameMapper BLE remaps The public transport seam
A reset/teardown API between tests Fresh instances per createTestConfig() call