Files
Infinity_Vis_Rust/crates/infinity_host_api/tests/contract.rs

919 lines
35 KiB
Rust

use infinity_config::ProjectConfig;
use infinity_host::{HostApiPort, SimulationHostService};
use infinity_host_api::HostApiServer;
use serde_json::Value;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
.expect("project config must parse")
}
fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(SimulationHostService::new(sample_project()));
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
fn start_server_with_runtime_state(path: &PathBuf) -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(
SimulationHostService::try_new_with_persistence(sample_project(), path)
.expect("persistent service must initialize"),
);
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
struct HttpResponse {
status_code: u16,
headers: HashMap<String, String>,
body: String,
}
#[test]
fn root_and_web_assets_target_the_versioned_api_contract() {
let server = start_server();
let html = send_http_request(server.local_addr(), "GET", "/", None);
let technical_html = send_http_request(server.local_addr(), "GET", "/technical", None);
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
let technical_js = send_http_request(server.local_addr(), "GET", "/technical.js", None);
let styles = send_http_request(server.local_addr(), "GET", "/styles.css", None);
assert_eq!(html.status_code, 200);
assert!(html
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/html"));
assert!(html.body.contains("Mapping Settings"));
assert!(html.body.contains("<h2>Preview</h2>"));
assert!(html.body.contains("Creative Snapshots"));
assert!(html.body.contains("Selected Tile"));
assert!(html.body.contains("Utilities"));
assert!(html.body.contains("View &amp; Output"));
assert!(html.body.contains("Pending Transition"));
assert!(html.body.contains("Trigger Transition"));
assert!(html.body.contains("session-scope-label"));
assert!(html.body.contains("Fade Go"));
assert!(html.body.contains("Status &amp; Eventfeed"));
assert!(html.body.contains("Mapping Settings"));
assert!(!html.body.contains("<h2>Groups</h2>"));
assert!(html.body.contains("work-mode-select"));
assert!(html.body.contains("LEDs Only"));
assert!(!html.body.contains("preview-mode-select"));
assert_eq!(technical_html.status_code, 200);
assert!(technical_html
.body
.contains("Infinity Vis Mapping Settings"));
assert!(technical_html.body.contains("Backend &amp; Output"));
assert!(technical_html.body.contains("Node / IP Discovery"));
assert!(technical_html.body.contains("Discover / Scan"));
assert!(technical_html.body.contains("Node Targets"));
assert!(technical_html.body.contains("Panel Mapping"));
assert!(technical_html.body.contains("DDP (WLED)"));
assert!(technical_html.body.contains("Preview Only"));
assert!(technical_html.body.contains("Creative Surface"));
assert_eq!(app_js.status_code, 200);
assert!(app_js.body.contains("/api/v1/state"));
assert!(app_js.body.contains("/api/v1/preview"));
assert!(app_js.body.contains("save_preset"));
assert!(app_js.body.contains("delete_preset"));
assert!(app_js.body.contains("save_creative_snapshot"));
assert!(app_js.body.contains("show_control_session_required"));
assert!(app_js.body.contains("trigger_transition"));
assert!(app_js.body.contains("trigger_panel_test"));
assert!(app_js.body.contains("commitState"));
assert!(app_js.body.contains("Center Pulse"));
assert!(app_js.body.contains("Checkerd"));
assert!(app_js.body.contains("Wave Line"));
assert!(app_js.body.contains("tile-led"));
assert!(app_js.body.contains("show_event"));
assert!(app_js.body.contains("test_edit"));
assert!(app_js.body.contains("direct_mode_active"));
assert!(!app_js.body.contains("Tile Colors"));
assert!(!app_js.body.contains("Technical"));
assert_eq!(technical_js.status_code, 200);
assert!(technical_js.body.contains("/api/v1/state"));
assert!(technical_js.body.contains("set_output_backend_mode"));
assert!(technical_js.body.contains("set_live_output_enabled"));
assert!(technical_js.body.contains("set_output_fps"));
assert!(technical_js.body.contains("set_node_reserved_ip"));
assert!(technical_js.body.contains("update_panel_mapping"));
assert!(technical_js.body.contains("/api/v1/discovery/scan"));
assert!(technical_js.body.contains("runDiscoveryScan"));
assert!(technical_js.body.contains("DDP (WLED)"));
assert_eq!(styles.status_code, 200);
assert!(styles
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/css"));
assert!(styles.body.contains(".preview-grid"));
assert!(styles.body.contains(".tile-led-ring"));
assert!(styles.body.contains(".technical-workspace"));
assert!(styles.body.contains(".technical-table"));
server.shutdown();
}
#[test]
fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() {
let server = start_server();
let html = send_http_request(server.local_addr(), "GET", "/", None);
let mut stream = open_websocket(server.local_addr());
assert_eq!(html.status_code, 200);
assert!(html.body.contains("Mapping Settings"));
assert!(html.body.contains("<h2>Preview</h2>"));
assert!(html.body.contains("connection-pill"));
assert!(html.body.contains("preview-grid"));
assert!(html.body.contains("workspace-stage"));
let first_frame = read_websocket_text_frame(&mut stream);
let second_frame = read_websocket_text_frame(&mut stream);
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(first_payload["message"]["type"], "snapshot");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
let server = start_server();
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
assert_eq!(state.status_code, 200);
assert_eq!(state_body["api_version"], "v1");
assert!(state_body.get("state").is_some());
assert!(state_body.get("preview").is_none());
assert_eq!(
state_body["state"]["technical"]["backend_mode"],
"preview_only"
);
assert_eq!(state_body["state"]["technical"]["output_enabled"], false);
assert_eq!(state_body["state"]["technical"]["output_fps"], 40);
assert_eq!(
state_body["state"]["nodes"].as_array().map(Vec::len),
Some(6)
);
assert!(state_body["state"]["nodes"]
.as_array()
.expect("nodes array")
.iter()
.all(|node| node["connection"] == "offline"
&& node["error_status"] == "preview only - live output disabled"));
assert!(state_body["state"]["panels"]
.as_array()
.expect("panels array")
.iter()
.all(|panel| panel["connection"] == "offline"
&& panel["driver_kind"] == "pending_validation"));
assert_eq!(preview.status_code, 200);
assert_eq!(preview_body["api_version"], "v1");
assert!(preview_body.get("preview").is_some());
assert!(preview_body.get("state").is_none());
assert_eq!(
preview_body["preview"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert_eq!(snapshot.status_code, 200);
assert_eq!(snapshot_body["api_version"], "v1");
assert!(snapshot_body.get("state").is_some());
assert!(snapshot_body.get("preview").is_some());
server.shutdown();
}
#[test]
fn technical_surface_commands_update_backend_node_targets_and_panel_mapping() {
let server = start_server();
let responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_output_backend_mode","payload":{"mode":"ddp_wled"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":true}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_output_fps","payload":{"output_fps":55}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_node_reserved_ip","payload":{"node_id":"node-01","reserved_ip":"192.168.40.151"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"update_panel_mapping","payload":{"node_id":"node-01","panel_position":"top","physical_output_name":"GPIO 18","driver_kind":"gpio","driver_reference":"GPIO18","led_count":120,"direction":"reverse","color_order":"rgb","enabled":false}}}"#,
),
];
for response in responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(state_body["state"]["technical"]["backend_mode"], "ddp_wled");
assert_eq!(state_body["state"]["technical"]["output_enabled"], true);
assert_eq!(state_body["state"]["technical"]["output_fps"], 55);
assert_eq!(
state_body["state"]["technical"]["live_status"],
"DDP (WLED) armed - 0/6 nodes online"
);
assert!(state_body["state"]["nodes"]
.as_array()
.expect("nodes array")
.iter()
.any(|node| node["node_id"] == "node-01"
&& node["reserved_ip"] == "192.168.40.151"
&& node["error_status"] == "ddp (wled) output enabled - no live client connected"));
assert!(state_body["state"]["panels"]
.as_array()
.expect("panels array")
.iter()
.any(|panel| panel["node_id"] == "node-01"
&& panel["panel_position"] == "top"
&& panel["physical_output_name"] == "GPIO 18"
&& panel["driver_kind"] == "gpio"
&& panel["driver_reference"] == "GPIO18"
&& panel["led_count"] == 120
&& panel["direction"] == "reverse"
&& panel["color_order"] == "rgb"
&& panel["enabled"] == false
&& panel["error_status"] == "output disabled"));
server.shutdown();
}
#[test]
fn discovery_scan_endpoint_returns_structured_results_and_rejects_invalid_subnets() {
let server = start_server();
let invalid = send_http_request(
server.local_addr(),
"POST",
"/api/v1/discovery/scan",
Some(r#"{"subnet":"192.168.40.0"}"#),
);
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid subnet json");
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["error"]["code"], "invalid_subnet_cidr");
let valid = send_http_request(
server.local_addr(),
"POST",
"/api/v1/discovery/scan",
Some(r#"{"subnet":"192.168.40.0/30"}"#),
);
let valid_body: Value = serde_json::from_str(&valid.body).expect("valid subnet json");
assert_eq!(valid.status_code, 200);
assert_eq!(valid_body["api_version"], "v1");
assert_eq!(valid_body["subnet"], "192.168.40.0/30");
assert_eq!(valid_body["scanned_hosts"], 2);
assert!(valid_body["results"].is_array());
if let Some(first) = valid_body["results"]
.as_array()
.and_then(|items| items.first())
{
assert!(first.get("ip").is_some());
assert!(first.get("reachable").is_some());
assert!(first.get("detected_type").is_some());
}
server.shutdown();
}
#[test]
fn command_flow_updates_group_parameters_transition_and_blackout() {
let server = start_server();
let responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"top_panels"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"speed","value":{"kind":"scalar","value":2.25}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":320}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"gradient"}}}"#,
),
];
for response in responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(
state_body["state"]["global"]["selected_group"],
"top_panels"
);
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
assert_eq!(
state_body["state"]["active_scene"]["pattern_id"],
"gradient"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "speed" && parameter["value"]["value"] == 2.25));
let blackout = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
);
let blackout_body: Value = serde_json::from_str(&blackout.body).expect("blackout json");
assert_eq!(blackout.status_code, 200);
assert_eq!(blackout_body["command_type"], "set_blackout");
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
server.shutdown();
}
#[test]
fn presets_and_creative_snapshots_persist_across_restart() {
let runtime_state_path = unique_runtime_state_path("persistence");
let server = start_server_with_runtime_state(&runtime_state_path);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"bottom_panels"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.93}}}}"#,
);
let save_preset = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"user_noise_floor","overwrite":false}}}"#,
);
let save_snapshot = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"variant_floor","label":"Variant Floor","overwrite":false}}}"#,
);
assert_eq!(save_preset.status_code, 200);
assert_eq!(save_snapshot.status_code, 200);
server.shutdown();
let restarted = start_server_with_runtime_state(&runtime_state_path);
let catalog = send_http_request(restarted.local_addr(), "GET", "/api/v1/catalog", None);
let state = send_http_request(restarted.local_addr(), "GET", "/api/v1/state", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "user_noise_floor"
&& preset["source"] == "runtime_user"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(
state_body["state"]["active_scene"]["target_group"],
"bottom_panels"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.93));
restarted.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn runtime_presets_can_be_deleted_but_builtin_presets_are_protected() {
let server = start_server();
let save = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"runtime_delete_me","overwrite":false}}}"#,
);
assert_eq!(save.status_code, 200);
let delete_runtime = send_command_json(
server.local_addr(),
r#"{"command":{"type":"delete_preset","payload":{"preset_id":"runtime_delete_me"}}}"#,
);
assert_eq!(delete_runtime.status_code, 200);
let catalog_after_delete =
send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let catalog_body: Value =
serde_json::from_str(&catalog_after_delete.body).expect("catalog json");
assert!(!catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "runtime_delete_me"));
let delete_builtin = send_command_json(
server.local_addr(),
r#"{"command":{"type":"delete_preset","payload":{"preset_id":"ocean_gradient"}}}"#,
);
let delete_builtin_body: Value =
serde_json::from_str(&delete_builtin.body).expect("delete builtin json");
assert_eq!(delete_builtin.status_code, 400);
assert_eq!(
delete_builtin_body["error"]["code"],
"preset_delete_forbidden"
);
server.shutdown();
}
#[test]
fn show_control_flows_cover_runtime_group_preset_snapshot_transition_blackout_and_eventfeed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
let _ = read_websocket_text_frame(&mut stream);
let _ = read_websocket_text_frame(&mut stream);
let flow_responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"upsert_group","payload":{"group_id":"focus_pair","tags":["runtime","focus"],"members":[{"node_id":"node-a","panel_position":"top"},{"node_id":"node-a","panel_position":"middle"}],"overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"focus_pair"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":480}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.67}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.81}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"focus_noise","overwrite":true}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_preset","payload":{"preset_id":"focus_noise"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"focus_variant","label":"Focus Variant","overwrite":false}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"pulse"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"focus_variant"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
),
];
for response in flow_responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(
state_body["state"]["global"]["selected_group"],
"focus_pair"
);
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 480);
assert_eq!(state_body["state"]["global"]["blackout"], true);
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(
state_body["state"]["active_scene"]["target_group"],
"focus_pair"
);
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.81));
assert!(catalog_body["groups"]
.as_array()
.expect("group array")
.iter()
.any(|group| group["group_id"] == "focus_pair" && group["source"] == "runtime_user"));
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "focus_noise"
&& preset["source"] == "runtime_user"
&& preset["transition_style"] == "chase"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "focus_variant"
&& snapshot["label"] == "Focus Variant"));
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
let mut event_messages = Vec::new();
for _ in 0..24 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
if let Some(message) = payload["message"]["payload"]["message"].as_str() {
event_messages.push(message.to_string());
}
}
}
assert!(event_messages
.iter()
.any(|message| message.contains("group saved: focus_pair")));
assert!(event_messages
.iter()
.any(|message| message.contains("preset overwritten: focus_noise")));
assert!(event_messages
.iter()
.any(|message| message.contains("creative snapshot recalled: focus_variant")));
assert!(event_messages
.iter()
.any(|message| message.contains("global blackout enabled")));
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
fn invalid_runtime_state_file_falls_back_without_blocking_server_start() {
let runtime_state_path = unique_runtime_state_path("invalid_runtime");
std::fs::write(&runtime_state_path, "{ broken").expect("invalid runtime state should write");
let server = start_server_with_runtime_state(&runtime_state_path);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let mut stream = open_websocket(server.local_addr());
assert_eq!(state.status_code, 200);
assert_eq!(
state_body["state"]["active_scene"]["pattern_id"],
"solid_color"
);
let _ = read_websocket_text_frame(&mut stream);
let _ = read_websocket_text_frame(&mut stream);
let mut saw_recovery_warning = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event"
&& payload["message"]["payload"]["code"] == "runtime_state_parse_failed"
{
saw_recovery_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
break;
}
}
assert!(
saw_recovery_warning,
"expected recovery warning event after invalid runtime state"
);
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
let first_frame = read_websocket_text_frame(&mut stream);
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
assert_eq!(first_payload["message"]["type"], "snapshot");
let second_frame = read_websocket_text_frame(&mut stream);
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview");
let invalid = send_command_json(
server.local_addr(),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"does_not_exist"}}}"#,
);
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid json");
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
let mut saw_warning = false;
for _ in 0..12 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event"
&& payload["message"]["payload"]["code"] == "unknown_creative_snapshot"
{
saw_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
assert_eq!(
payload["message"]["payload"]["code"],
"unknown_creative_snapshot"
);
assert!(payload["message"]["payload"]["message"]
.as_str()
.expect("event message")
.contains("does_not_exist"));
break;
}
}
assert!(saw_warning, "expected warning event after failed command");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
#[ignore = "longer load-oriented sequence for platform hardening"]
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
let server = start_server();
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
let groups = [
None,
Some("top_panels"),
Some("middle_panels"),
Some("bottom_panels"),
];
for index in 0..80 {
let pattern = patterns[index % patterns.len()];
let group = groups[index % groups.len()];
let brightness = ((index % 10) as f32) / 10.0;
let speed = 0.5 + (index % 6) as f32 * 0.25;
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_pattern","payload":{{"pattern_id":"{pattern}"}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_master_brightness","payload":{{"value":{brightness}}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_scene_parameter","payload":{{"key":"speed","value":{{"kind":"scalar","value":{speed}}}}}}}}}"#
),
);
let group_json = match group {
Some(group_id) => format!(r#""{group_id}""#),
None => "null".to_string(),
};
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_group","payload":{{"group_id":{group_json}}}}}}}"#
),
);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(
state_body["state"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert_eq!(
preview_body["preview"]["panels"].as_array().map(Vec::len),
Some(18)
);
assert!(
catalog_body["patterns"]
.as_array()
.map(Vec::len)
.unwrap_or_default()
>= 5
);
}
server.shutdown();
}
fn send_command_json(addr: SocketAddr, body: &str) -> HttpResponse {
send_http_request(addr, "POST", "/api/v1/command", Some(body))
}
fn send_http_request(
addr: SocketAddr,
method: &str,
path: &str,
body: Option<&str>,
) -> HttpResponse {
let body = body.unwrap_or("");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.as_bytes().len(),
body,
host = addr
);
let mut stream = TcpStream::connect(addr).expect("http connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("read timeout");
stream.write_all(request.as_bytes()).expect("write request");
stream.shutdown(Shutdown::Write).expect("shutdown write");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).expect("read response");
parse_http_response(&raw)
}
fn parse_http_response(raw: &[u8]) -> HttpResponse {
let delimiter = raw
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("http header delimiter");
let header_text = String::from_utf8(raw[..delimiter].to_vec()).expect("header utf8");
let body = String::from_utf8(raw[delimiter + 4..].to_vec()).expect("body utf8");
let mut lines = header_text.lines();
let status_line = lines.next().expect("status line");
let status_code = status_line
.split_whitespace()
.nth(1)
.expect("status code")
.parse::<u16>()
.expect("valid status code");
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
.collect::<HashMap<_, _>>();
HttpResponse {
status_code,
headers,
body,
}
}
fn open_websocket(addr: SocketAddr) -> TcpStream {
let mut stream = TcpStream::connect(addr).expect("websocket connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("read timeout");
let request = format!(
"GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
host = addr
);
stream
.write_all(request.as_bytes())
.expect("write handshake");
let header = read_until_header_end(&mut stream);
let header_text = String::from_utf8(header).expect("handshake utf8");
assert!(header_text.starts_with("HTTP/1.1 101"));
assert!(header_text.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
stream
}
fn read_until_header_end(stream: &mut TcpStream) -> Vec<u8> {
let mut buffer = Vec::new();
loop {
let mut byte = [0u8; 1];
let read = stream.read(&mut byte).expect("read handshake");
assert!(read > 0, "unexpected eof while reading handshake");
buffer.push(byte[0]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
let end = buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("header end")
+ 4;
return buffer[..end].to_vec();
}
}
}
fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
let mut header = [0u8; 2];
stream.read_exact(&mut header).expect("frame header");
assert_eq!(header[0] & 0x0f, 0x1, "expected text frame");
let payload_len = match header[1] & 0x7f {
len @ 0..=125 => len as usize,
126 => {
let mut extended = [0u8; 2];
stream.read_exact(&mut extended).expect("extended payload");
u16::from_be_bytes(extended) as usize
}
127 => {
let mut extended = [0u8; 8];
stream.read_exact(&mut extended).expect("extended payload");
u64::from_be_bytes(extended) as usize
}
_ => unreachable!("masked length bit should already be stripped"),
};
let mut payload = vec![0u8; payload_len];
stream.read_exact(&mut payload).expect("frame payload");
String::from_utf8(payload).expect("frame utf8")
}
fn unique_runtime_state_path(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis();
std::env::temp_dir().join(format!("infinity_vis_{label}_{millis}.json"))
}