Stabilize control surface and external bridge v1

This commit is contained in:
jan
2026-04-20 01:13:27 +02:00
parent a56cecb23d
commit 07c52db5fb
29 changed files with 8818 additions and 1510 deletions

View File

@@ -0,0 +1,96 @@
{
"name": "staged pattern plus transition commit",
"steps": [
{
"request": {
"request_id": "style",
"session_id": "desk-a",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_transition_style",
"payload": {
"style": "chase",
"duration_ms": 480
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "transition style staged",
"session": {
"parameter_keys": [],
"transition_style": "chase",
"transition_duration_ms": 480
}
}
},
{
"request": {
"request_id": "pattern",
"session_id": "desk-a",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise",
"session": {
"pattern_id": "noise",
"parameter_keys": [],
"transition_style": "chase",
"transition_duration_ms": 480
}
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-a",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise",
"session": {
"parameter_keys": []
}
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"active_pattern_id": "noise",
"transition_style": "chase",
"transition_duration_ms": 480
}
}
}
]
}

View File

@@ -0,0 +1,160 @@
{
"name": "group update plus parameter change plus commit",
"steps": [
{
"request": {
"request_id": "group",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "upsert_group",
"payload": {
"group_id": "focus_pair",
"tags": ["runtime", "focus"],
"members": [
{ "node_id": "node-01", "panel_position": "top" },
{ "node_id": "node-01", "panel_position": "middle" }
],
"overwrite": true
}
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "group updated: focus_pair"
}
},
{
"request": {
"request_id": "param",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_group_parameter",
"payload": {
"group_id": "focus_pair",
"key": "grain",
"value": { "kind": "scalar", "value": 0.81 }
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "group parameter staged: grain",
"session": {
"has_group_target": true,
"group_id": "focus_pair",
"parameter_keys": ["grain"]
}
}
},
{
"request": {
"request_id": "style",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_transition_style",
"payload": {
"style": "chase",
"duration_ms": 420
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "transition style staged",
"session": {
"has_group_target": true,
"group_id": "focus_pair",
"parameter_keys": ["grain"],
"transition_style": "chase",
"transition_duration_ms": 420
}
}
},
{
"request": {
"request_id": "pattern",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise",
"session": {
"pattern_id": "noise",
"has_group_target": true,
"group_id": "focus_pair",
"parameter_keys": ["grain"],
"transition_style": "chase",
"transition_duration_ms": 420
}
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-b",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise on focus_pair",
"session": {
"parameter_keys": []
}
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"selected_group": "focus_pair",
"active_pattern_id": "noise",
"active_scene_group": "focus_pair",
"transition_style": "chase",
"transition_duration_ms": 420,
"scalar_parameters": [
{ "key": "grain", "value": 0.81 }
]
}
}
}
]
}

View File

@@ -0,0 +1,103 @@
{
"name": "preset recall during running transition",
"steps": [
{
"request": {
"request_id": "style",
"session_id": "desk-c",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_transition_style",
"payload": {
"style": "crossfade",
"duration_ms": 1600
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "transition style staged"
}
},
{
"request": {
"request_id": "pattern",
"session_id": "desk-c",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise"
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-c",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise"
}
},
{
"request": {
"request_id": "recall",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "recall_preset",
"payload": {
"preset_id": "ocean_gradient"
}
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "preset recalled: ocean_gradient"
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"preset_id": "ocean_gradient",
"active_pattern_id": "gradient",
"active_transition_present": true,
"event_message_contains": "preset recalled: ocean_gradient"
}
}
}
]
}

View File

@@ -0,0 +1,123 @@
{
"name": "blackout during staged session",
"steps": [
{
"request": {
"request_id": "pattern",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "pattern staged: noise",
"session": {
"pattern_id": "noise",
"parameter_keys": []
}
}
},
{
"request": {
"request_id": "param",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_group_parameter",
"payload": {
"group_id": "bottom_panels",
"key": "grain",
"value": { "kind": "scalar", "value": 0.74 }
}
}
}
}
},
"expect": {
"result_type": "primitive_buffered",
"summary_contains": "group parameter staged: grain",
"session": {
"pattern_id": "noise",
"has_group_target": true,
"group_id": "bottom_panels",
"parameter_keys": ["grain"]
}
}
},
{
"request": {
"request_id": "blackout",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "blackout",
"payload": {
"enabled": true
}
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "blackout enabled",
"session": {
"pattern_id": "noise",
"has_group_target": true,
"group_id": "bottom_panels",
"parameter_keys": ["grain"]
}
}
},
{
"request": {
"request_id": "trigger",
"session_id": "desk-d",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "trigger_transition"
}
}
}
},
"expect": {
"result_type": "command_accepted",
"summary_contains": "transition triggered: noise on bottom_panels"
}
},
{
"request": {
"request_id": "state",
"command": {
"type": "get_state"
}
},
"expect": {
"result_type": "state",
"state": {
"blackout": true,
"active_pattern_id": "noise",
"active_scene_group": "bottom_panels",
"scalar_parameters": [
{ "key": "grain", "value": 0.74 }
]
}
}
}
]
}

View File

@@ -0,0 +1,25 @@
{
"name": "stateless bridge path rejects staged primitive",
"steps": [
{
"request": {
"request_id": "reject",
"command": {
"type": "execute_primitive",
"payload": {
"primitive": {
"primitive": "set_pattern",
"payload": {
"pattern_id": "noise"
}
}
}
}
},
"expect": {
"result_type": "error",
"error_code": "show_control_session_required"
}
}
]
}

View File

@@ -0,0 +1,307 @@
use infinity_config::ProjectConfig;
use infinity_host::{
ExternalBridgeRequest, ExternalBridgeResponse, ExternalBridgeResult, ExternalBridgeSessionView,
ExternalBridgeStateView, ExternalControlBridge, HostApiPort, SceneParameterValue,
SimulationHostService,
};
use serde::Deserialize;
use std::{fs, path::PathBuf, sync::Arc};
#[derive(Debug, Deserialize)]
struct GoldenTrace {
name: String,
steps: Vec<GoldenStep>,
}
#[derive(Debug, Deserialize)]
struct GoldenStep {
request: ExternalBridgeRequest,
expect: GoldenExpectation,
}
#[derive(Debug, Deserialize)]
struct GoldenExpectation {
result_type: String,
#[serde(default)]
summary_contains: Option<String>,
#[serde(default)]
error_code: Option<String>,
#[serde(default)]
session: Option<ExpectedSession>,
#[serde(default)]
state: Option<ExpectedState>,
}
#[derive(Debug, Deserialize)]
struct ExpectedSession {
#[serde(default)]
pattern_id: Option<String>,
#[serde(default)]
has_group_target: Option<bool>,
#[serde(default)]
group_id: Option<String>,
#[serde(default)]
parameter_keys: Vec<String>,
#[serde(default)]
transition_style: Option<String>,
#[serde(default)]
transition_duration_ms: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ExpectedState {
#[serde(default)]
blackout: Option<bool>,
#[serde(default)]
selected_group: Option<String>,
#[serde(default)]
active_pattern_id: Option<String>,
#[serde(default)]
preset_id: Option<String>,
#[serde(default)]
active_scene_group: Option<String>,
#[serde(default)]
transition_style: Option<String>,
#[serde(default)]
transition_duration_ms: Option<u32>,
#[serde(default)]
active_transition_present: Option<bool>,
#[serde(default)]
event_message_contains: Option<String>,
#[serde(default)]
scalar_parameters: Vec<ExpectedScalarParameter>,
}
#[derive(Debug, Deserialize)]
struct ExpectedScalarParameter {
key: String,
value: f32,
}
#[test]
fn show_control_v1_golden_traces_replay_cleanly() {
for path in golden_trace_paths() {
let fixture = fs::read_to_string(&path).expect("golden trace fixture must be readable");
let trace: GoldenTrace =
serde_json::from_str(&fixture).expect("golden trace fixture must parse");
run_trace(&trace, &path);
}
}
fn run_trace(trace: &GoldenTrace, path: &PathBuf) {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
let mut bridge = ExternalControlBridge::new(service);
for (index, step) in trace.steps.iter().enumerate() {
let response = bridge.handle_request(step.request.clone());
assert_response(trace, path, index, &response, &step.expect);
}
}
fn assert_response(
trace: &GoldenTrace,
path: &PathBuf,
step_index: usize,
response: &ExternalBridgeResponse,
expect: &GoldenExpectation,
) {
let context = format!(
"{} step {} ({})",
path.display(),
step_index + 1,
trace.name
);
match expect.result_type.as_str() {
"primitive_buffered" => match &response.result {
ExternalBridgeResult::PrimitiveBuffered { summary } => {
assert_summary_contains(summary, expect.summary_contains.as_deref(), &context);
}
other => panic!("{context}: expected primitive_buffered, got {other:?}"),
},
"command_accepted" => match &response.result {
ExternalBridgeResult::CommandAccepted { summary, .. } => {
assert_summary_contains(summary, expect.summary_contains.as_deref(), &context);
}
other => panic!("{context}: expected command_accepted, got {other:?}"),
},
"state" => match &response.result {
ExternalBridgeResult::State { state } => {
assert_state(state, expect.state.as_ref(), &context);
}
other => panic!("{context}: expected state, got {other:?}"),
},
"error" => match &response.result {
ExternalBridgeResult::Error { error } => {
let expected_code = expect
.error_code
.as_deref()
.expect("error result requires error_code");
assert_eq!(
error.code, expected_code,
"{context}: unexpected error code"
);
}
other => panic!("{context}: expected error, got {other:?}"),
},
other => panic!("{context}: unsupported expected result type '{other}'"),
}
assert_session(response.session.as_ref(), expect.session.as_ref(), &context);
}
fn assert_summary_contains(summary: &str, expected: Option<&str>, context: &str) {
if let Some(expected) = expected {
assert!(
summary.contains(expected),
"{context}: summary '{summary}' does not contain '{expected}'"
);
}
}
fn assert_session(
actual: Option<&ExternalBridgeSessionView>,
expected: Option<&ExpectedSession>,
context: &str,
) {
let Some(expected) = expected else {
return;
};
let actual = actual.expect("expected session view");
assert_eq!(
actual.pending.pattern_id.as_deref(),
expected.pattern_id.as_deref(),
"{context}: unexpected pending pattern"
);
if let Some(has_group_target) = expected.has_group_target {
assert_eq!(
actual.pending.has_group_target, has_group_target,
"{context}: unexpected pending group target flag"
);
}
assert_eq!(
actual.pending.group_id.as_deref(),
expected.group_id.as_deref(),
"{context}: unexpected pending group id"
);
if let Some(style) = expected.transition_style.as_deref() {
assert_eq!(
actual.pending.transition_style.map(|value| value.label()),
Some(style),
"{context}: unexpected pending transition style"
);
}
if let Some(duration_ms) = expected.transition_duration_ms {
assert_eq!(
actual.pending.transition_duration_ms,
Some(duration_ms),
"{context}: unexpected pending transition duration"
);
}
for key in &expected.parameter_keys {
assert!(
actual.pending.parameters.contains_key(key),
"{context}: missing pending parameter '{key}'"
);
}
}
fn assert_state(actual: &ExternalBridgeStateView, expected: Option<&ExpectedState>, context: &str) {
let Some(expected) = expected else {
return;
};
if let Some(blackout) = expected.blackout {
assert_eq!(
actual.global.blackout, blackout,
"{context}: unexpected blackout state"
);
}
if let Some(selected_group) = expected.selected_group.as_deref() {
assert_eq!(
actual.global.selected_group.as_deref(),
Some(selected_group),
"{context}: unexpected selected group"
);
}
if let Some(active_pattern_id) = expected.active_pattern_id.as_deref() {
assert_eq!(
actual.active_scene.pattern_id, active_pattern_id,
"{context}: unexpected active pattern"
);
}
if let Some(preset_id) = expected.preset_id.as_deref() {
assert_eq!(
actual.active_scene.preset_id.as_deref(),
Some(preset_id),
"{context}: unexpected preset id"
);
}
if let Some(active_scene_group) = expected.active_scene_group.as_deref() {
assert_eq!(
actual.active_scene.target_group.as_deref(),
Some(active_scene_group),
"{context}: unexpected active scene group"
);
}
if let Some(transition_style) = expected.transition_style.as_deref() {
assert_eq!(
actual.global.transition_style.label(),
transition_style,
"{context}: unexpected transition style"
);
}
if let Some(transition_duration_ms) = expected.transition_duration_ms {
assert_eq!(
actual.global.transition_duration_ms, transition_duration_ms,
"{context}: unexpected transition duration"
);
}
if let Some(active_transition_present) = expected.active_transition_present {
assert_eq!(
actual.engine.active_transition.is_some(),
active_transition_present,
"{context}: unexpected active transition presence"
);
}
if let Some(message_fragment) = expected.event_message_contains.as_deref() {
assert!(
actual
.recent_events
.iter()
.any(|event| event.message.contains(message_fragment)),
"{context}: missing event containing '{message_fragment}'"
);
}
for expected_parameter in &expected.scalar_parameters {
assert!(
actual.active_scene.parameters.iter().any(|parameter| {
parameter.key == expected_parameter.key
&& parameter.value == SceneParameterValue::Scalar(expected_parameter.value)
}),
"{context}: missing scalar parameter '{}'={}",
expected_parameter.key,
expected_parameter.value
);
}
}
fn golden_trace_paths() -> Vec<PathBuf> {
let mut paths = fs::read_dir(golden_trace_dir())
.expect("golden trace directory must exist")
.map(|entry| entry.expect("golden trace entry").path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
.collect::<Vec<_>>();
paths.sort();
paths
}
fn golden_trace_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden_traces")
}
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}