You can test a mode's logic on your computer, with no hardware. The SDK ships a mock of the device that you drive from a plain C++ unit test: feed it MIDI and timing, run your mode, and check what comes out.
What the harness gives you
operator_sdk_sim is a host-side test harness. It provides a mock OperatorApi, the same function table your mode calls on the device, backed by in-memory state: an incoming and outgoing MIDI queue, a virtual clock, a framebuffer, and parameter storage. Your mode's own main.cpp compiles unchanged and links against it. There is no device and no on-screen simulator, just your mode's code exercised in a test.
The shape of a test
Tests use doctest, which the harness bundles. A test file registers the mode under test, then each TEST_CASE drives it:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
TEST_CASE("transpose shifts note-ons up a fifth") {
mode_init(api);
OpMidiMessage note{ .status = 0x90, .data1 = 60, .data2 = 100, .port = 0, .length = 3 };
mode_process(¬e, 1, api->
get_tick());
mode_destroy();
}
const OperatorApi * get_api()
The mock OperatorApi, ready to hand to a mode's init.
void reset_state()
Put the device back to how it starts: the clock at zero, the queues and the framebuffer empty,...
Public ABI umbrella header for Operator SDK modes.
The mock device a mode is tested against.
#define OP_MODE_UNDER_TEST()
Declare everything a mode exports, so a test can call it.
Definition operator_sdk_sim.h:185
A single MIDI message passed to a mode's process function.
Definition mode_api.h:22
uint8_t data1
First data byte (for example note number or controller).
Definition mode_api.h:25
The function table a mode calls into.
Definition mode_api.h:53
void(* set_param_value)(uint8_t param_id, int32_t value)
Write a declarative parameter value.
Definition mode_api.h:286
uint32_t(* get_tick)()
Monotonic microsecond counter since boot.
Definition mode_api.h:149
The building blocks
- op::sim::reset_state() clears the mock between tests.
- op::sim::get_api() returns the mock OperatorApi to hand to mode_init.
- mode_init, mode_process, and mode_destroy are your mode's hooks, called directly. OP_MODE_UNDER_TEST() makes them, and your kParams table, visible to the test.
- Parameters: set values with set_param_value by slot index (their order in OP_MODE_PARAMS). reset_state() zeroes the store, so to start from your declared defaults call op::sim::apply_param_defaults(kParams, kParamCount) after reset_state() and before mode_init.
- Incoming MIDI: build an OpMidiMessage batch and pass it to mode_process, or enqueue with op::sim::queue_incoming_midi.
- Outgoing MIDI: read what your mode emitted with op::sim::drain_outgoing_midi(out, buf, max).
- Timing: drive the clock with op::sim::advance_tick, op::sim::set_pulse_count, or op::sim::set_beat_position before calling mode_process. See Timing and Clock.
- Custom UI: call ui_render and read the pixels with op::sim::capture_framebuffer, and deliver input with ui_gesture(control, gesture, type, value).
- Files: seed a file with op::sim::seed_extras_file(mode, name, data, size) and a FilePicker choice with op::sim::pick_filename(slot, name).
- Outputs: flip an output on or off with op::sim::set_output_active(out, active) to simulate an unavailable output.
Common scenarios
Start from your declared defaults. reset_state() zeroes the parameter store, so seed the real defaults to exercise a mode the way a user would first meet it:
mode_init(api);
void apply_param_defaults(const ::op::modes::ParamSpec *specs, std::uint8_t count)
Seed the params with the defaults a mode declares, the way the device does before it starts one.
A filter that drops messages. Feed a message the mode should remove, run process, and check it was dropped (a dropped message has status == 0):
OpMidiMessage cc{ .status = 0xB0, .data1 = 1, .data2 = 64, .port = 0, .length = 3 };
uint8_t status
Status byte: message type in the high nibble, channel in the low nibble.
Definition mode_api.h:23
A generator driven by the clock. Advance the virtual clock, run process, then drain what the mode emitted:
mode_process(
nullptr, 0, api->
get_tick());
CHECK(n >= 1);
CHECK((out[0].status & 0xF0) == 0x90);
std::uint16_t drain_outgoing_midi(std::uint8_t output_index, OpMidiMessage *out, std::uint16_t max_messages)
Read back the MIDI a mode sent to one output, up to max_messages of it, and empty the queue.
void set_pulse_count(std::uint32_t pulse)
Set what api->get_pulse_count() reads.
For a clock-generating mode, assert the tempo it programmed with op::sim::get_last_clock_period_us().
A custom UI. Render a frame and check pixels, or push an input event and check the effect:
ui_render();
ui_gesture(1, op::Gesture::Rotate, op::GestureType::Change, +1);
constexpr std::size_t kFramebufferBytes
The size of the framebuffer, so a capture buffer can be stack-allocated.
Definition operator_sdk_sim.h:52
void capture_framebuffer(std::uint8_t out[kFramebufferBytes])
Copy the framebuffer out, as it stands after every draw a mode has made since the last reset_state().
int32_t(* get_param_value)(uint8_t param_id)
Read the current int32 value of a declarative parameter.
Definition mode_api.h:281
A file-reading mode. Seed both the file's contents and the user's FilePicker choice, then let the mode read it:
const uint8_t song[] = { };
mode_init(api);
mode_process(
nullptr, 0, api->
get_tick());
void seed_extras_file(const char *mode_name, const char *filename, const std::uint8_t *data, std::uint32_t size)
Put a file where a mode will find it, at /extras/<mode name>/<filename>.
void pick_filename(std::uint8_t param_id, const char *filename)
Pick a file for a FilePicker param, as a player would on the device.
An unavailable output. Turn an output off (for example USB when USB MIDI is not active) and check the mode routes around it:
void set_output_active(std::uint8_t output_index, bool active)
Take one output away, so a mode meets an output that is not there.
Wiring the build
A test is an ordinary executable that compiles your mode's source as a library and links the harness. Every sample follows this pattern, so the simplest starting point is to copy a samples/*/tests/CMakeLists.txt. The essence is:
add_library(my_mode_host STATIC ../main.cpp)
target_include_directories(my_mode_host PUBLIC ${SDK_INCLUDE_DIR})
add_executable(test_my_mode test_my_mode.cpp)
target_link_libraries(test_my_mode PRIVATE my_mode_host operator_sdk_sim)
Register each TEST_CASE with add_test so ctest runs it.
Test behavior, not internals
Drive your mode through its real hooks and assert on what a user would observe: the MIDI it emits, the pixels it draws, the parameters it changes. Do not reach into the mode's private state, and do not add an export just so a test can see something. If a test seems to need one, the behavior it is checking should usually be observable through the ABI instead. A random generator is the case people reach for a hook on, but the sim starts the tick at zero, so a mode that seeds from the tick already runs the same way every time and needs no seam.