Die gemeinsame Plattform ist jetzt softwareseitig deutlich vollständiger. Der Host-Core hat mit [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) eine echte Runtime-Bibliothek und Persistenz für aktive Szene, Runtime-Presets, Runtime-Gruppen und kreative Varianten bekommen; die Simulation in [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>) liefert jetzt typisierte Command-Ergebnisse, saubere Fehlercodes und persistiert nach data/runtime_state.json. Dazu kommt das generische External-Show-Control-Interface in [external_control.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/external_control.rs>), damit spätere Adapter nur auf definierte Commands und Snapshot-/Preset-/Parameter-Flächen zugreifen.

Die API v1 ist als Produktgrenze geschärft in [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>): getrennte Modelle für `state`, `preview`, `snapshot`, `command response`, `event stream` und stabile Fehlerobjekte mit echten Codes statt generischem Fallback. Dazu kamen `GET /api/v1/state` und `GET /api/v1/preview`, neue persistenzbezogene Commands wie `save_preset`, `save_creative_snapshot`, `recall_creative_snapshot`, `set_transition_style` und `upsert_group`, plus serverseitige Durchreichung der echten Fehlercodes. Die kreative Web-UI in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>) nutzt jetzt genau diese API für Preset-Speichern/Überschreiben, Varianten, Transition-Style, filterbaren Eventfeed und klarere Preview-Darstellung, ohne Parallelarchitektur.

Die Doku ist auf den neuen Stand gezogen in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>), [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>). Verifiziert habe ich `cargo check -q` und `cargo test -q`; dabei laufen die erweiterten Contract- und Persistenztests in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>) sowie neue Core-Tests in [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) und [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>). Nicht separat verifiziert habe ich einen echten Browserlauf der Web-UI; die JS-Datei wurde hier nicht mit `node` geprüft, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
2026-04-17 12:34:03 +02:00
parent a37a3c5cbe
commit 8e19f535ae
20 changed files with 2399 additions and 459 deletions

View File

@@ -1,7 +1,8 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{
HostCommand, HostSnapshot, NodeConnectionState, PreviewSource, SceneParameterKind,
SceneParameterValue, SceneTransitionStyle, TestPatternKind,
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
TestPatternKind,
};
use serde::{Deserialize, Serialize};
@@ -15,12 +16,27 @@ pub struct ApiSnapshotResponse {
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStateResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub state: ApiStateSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCatalogResponse {
pub api_version: &'static str,
pub patterns: Vec<ApiPatternCatalogEntry>,
pub presets: Vec<ApiPresetSummary>,
pub groups: Vec<ApiGroupSummary>,
pub creative_snapshots: Vec<ApiCreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -48,6 +64,7 @@ pub struct ApiCommandResponse {
pub accepted: bool,
pub request_id: Option<String>,
pub generated_at_millis: u64,
pub command_type: String,
pub summary: String,
}
@@ -98,6 +115,7 @@ pub enum ApiStreamMessage {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEventNotice {
pub kind: ApiEventKind,
pub code: Option<String>,
pub message: String,
}
@@ -105,6 +123,8 @@ pub struct ApiEventNotice {
#[serde(rename_all = "snake_case")]
pub enum ApiEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -121,6 +141,7 @@ pub struct ApiGlobalState {
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -198,6 +219,8 @@ pub struct ApiPresetSummary {
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -205,6 +228,18 @@ pub struct ApiGroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -277,6 +312,13 @@ pub enum ApiParameterKind {
Text,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiCatalogSource {
BuiltIn,
RuntimeUser,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiLedDirection {
@@ -336,11 +378,38 @@ pub enum ApiCommand {
SetTransitionDurationMs {
duration_ms: u32,
},
SetTransitionStyle {
style: ApiTransitionStyle,
},
TriggerPanelTest {
node_id: String,
panel_position: ApiPanelPosition,
pattern: ApiTestPattern,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<ApiPanelRef>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApiPanelRef {
pub node_id: String,
pub panel_position: ApiPanelPosition,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -362,6 +431,26 @@ impl ApiSnapshotResponse {
}
}
impl ApiStateResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
state: ApiStateSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiPreviewResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
preview: ApiPreviewSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiCatalogResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
@@ -398,6 +487,8 @@ impl ApiCatalogResponse {
pattern_id: preset.pattern_id.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
transition_style: map_transition_style(preset.transition_style),
source: map_catalog_source(preset.source),
})
.collect(),
groups: snapshot
@@ -408,6 +499,21 @@ impl ApiCatalogResponse {
group_id: group.group_id.clone(),
member_count: group.member_count,
tags: group.tags.clone(),
source: map_catalog_source(group.source),
})
.collect(),
creative_snapshots: snapshot
.catalog
.creative_snapshots
.iter()
.map(|snapshot| ApiCreativeSnapshotSummary {
snapshot_id: snapshot.snapshot_id.clone(),
label: snapshot.label.clone(),
pattern_id: snapshot.pattern_id.clone(),
target_group: snapshot.target_group.clone(),
transition_duration_ms: snapshot.transition_duration_ms,
transition_style: map_transition_style(snapshot.transition_style),
saved_at_unix_ms: snapshot.saved_at_unix_ms,
})
.collect(),
}
@@ -446,6 +552,7 @@ impl ApiStateSnapshot {
selected_pattern: snapshot.global.selected_pattern.clone(),
selected_group: snapshot.global.selected_group.clone(),
transition_duration_ms: snapshot.global.transition_duration_ms,
transition_style: map_transition_style(snapshot.global.transition_style),
},
engine: ApiEngineState {
logic_hz: snapshot.engine.logic_hz,
@@ -565,6 +672,9 @@ impl ApiCommandRequest {
ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
}
ApiCommand::SetTransitionStyle { style } => {
Ok(HostCommand::SetTransitionStyle(map_command_transition_style(style)))
}
ApiCommand::TriggerPanelTest {
node_id,
panel_position,
@@ -578,6 +688,42 @@ impl ApiCommandRequest {
ApiTestPattern::WalkingPixel106 => TestPatternKind::WalkingPixel106,
},
}),
ApiCommand::SavePreset {
preset_id,
overwrite,
} => Ok(HostCommand::SavePreset {
preset_id,
overwrite,
}),
ApiCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
} => Ok(HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
}),
ApiCommand::RecallCreativeSnapshot { snapshot_id } => {
Ok(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ApiCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(HostCommand::UpsertGroup {
group_id,
tags,
members: members
.into_iter()
.map(|member| infinity_host::PanelTarget {
node_id: member.node_id,
panel_position: map_command_panel_position(member.panel_position),
})
.collect(),
overwrite,
}),
}
}
@@ -653,6 +799,29 @@ fn map_transition_style(style: SceneTransitionStyle) -> ApiTransitionStyle {
}
}
fn map_command_transition_style(style: ApiTransitionStyle) -> SceneTransitionStyle {
match style {
ApiTransitionStyle::Snap => SceneTransitionStyle::Snap,
ApiTransitionStyle::Crossfade => SceneTransitionStyle::Crossfade,
ApiTransitionStyle::Chase => SceneTransitionStyle::Chase,
}
}
fn map_catalog_source(source: CatalogSource) -> ApiCatalogSource {
match source {
CatalogSource::BuiltIn => ApiCatalogSource::BuiltIn,
CatalogSource::RuntimeUser => ApiCatalogSource::RuntimeUser,
}
}
fn map_event_kind(kind: StatusEventKind) -> ApiEventKind {
match kind {
StatusEventKind::Info => ApiEventKind::Info,
StatusEventKind::Warning => ApiEventKind::Warning,
StatusEventKind::Error => ApiEventKind::Error,
}
}
fn map_parameter_kind(kind: SceneParameterKind) -> ApiParameterKind {
match kind {
SceneParameterKind::Scalar => ApiParameterKind::Scalar,
@@ -678,6 +847,24 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue
}
impl ApiCommand {
pub fn kind_label(&self) -> &'static str {
match self {
Self::SetBlackout { .. } => "set_blackout",
Self::SetMasterBrightness { .. } => "set_master_brightness",
Self::SelectPattern { .. } => "select_pattern",
Self::RecallPreset { .. } => "recall_preset",
Self::SelectGroup { .. } => "select_group",
Self::SetSceneParameter { .. } => "set_scene_parameter",
Self::SetTransitionDurationMs { .. } => "set_transition_duration_ms",
Self::SetTransitionStyle { .. } => "set_transition_style",
Self::TriggerPanelTest { .. } => "trigger_panel_test",
Self::SavePreset { .. } => "save_preset",
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
Self::UpsertGroup { .. } => "upsert_group",
}
}
pub fn summary(&self) -> String {
match self {
Self::SetBlackout { enabled } => {
@@ -700,6 +887,9 @@ impl ApiCommand {
Self::SetTransitionDurationMs { duration_ms } => {
format!("transition duration set to {duration_ms} ms")
}
Self::SetTransitionStyle { style } => {
format!("transition style set to {}", style.label())
}
Self::TriggerPanelTest {
node_id,
panel_position,
@@ -710,6 +900,38 @@ impl ApiCommand {
node_id,
panel_position.label()
),
Self::SavePreset { preset_id, overwrite } => {
if *overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
}
}
Self::SaveCreativeSnapshot {
snapshot_id,
overwrite,
..
} => {
if *overwrite {
format!("creative snapshot overwritten: {snapshot_id}")
} else {
format!("creative snapshot saved: {snapshot_id}")
}
}
Self::RecallCreativeSnapshot { snapshot_id } => {
format!("creative snapshot recalled: {snapshot_id}")
}
Self::UpsertGroup {
group_id,
overwrite,
..
} => {
if *overwrite {
format!("group updated: {group_id}")
} else {
format!("group saved: {group_id}")
}
}
}
}
}
@@ -724,6 +946,16 @@ impl ApiPanelPosition {
}
}
impl ApiTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl ApiTestPattern {
pub fn label(self) -> &'static str {
match self {
@@ -743,3 +975,13 @@ impl ApiErrorResponse {
}
}
}
impl From<infinity_host::StatusEvent> for ApiEventNotice {
fn from(event: infinity_host::StatusEvent) -> Self {
Self {
kind: map_event_kind(event.kind),
code: event.code,
message: event.message,
}
}
}

View File

@@ -11,16 +11,23 @@ struct Cli {
config: PathBuf,
#[arg(long, default_value = "127.0.0.1:9001")]
bind: String,
#[arg(long, default_value = "data/runtime_state.json")]
runtime_state: PathBuf,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let project = load_project(&cli.config)?;
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(project);
let service: Arc<dyn HostApiPort> =
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
let server = HostApiServer::bind(&cli.bind, service)?;
println!("Infinity Vis host API listening on http://{}", server.local_addr());
println!("Web UI available at http://{}/", server.local_addr());
println!(
"Runtime state persistence: {}",
cli.runtime_state.display()
);
loop {
thread::sleep(Duration::from_secs(60));

View File

@@ -1,7 +1,7 @@
use crate::dto::{
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse, ApiEventKind,
ApiEventNotice, ApiGroupListResponse, ApiPresetListResponse, ApiSnapshotResponse,
ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
};
use crate::websocket::{websocket_accept_value, write_text_frame};
use infinity_host::HostApiPort;
@@ -21,6 +21,13 @@ pub struct HostApiServer {
accept_thread: Option<JoinHandle<()>>,
}
#[derive(Debug)]
struct ApiRequestError {
status: u16,
code: String,
message: String,
}
impl HostApiServer {
pub fn bind(bind: &str, service: Arc<dyn HostApiPort>) -> io::Result<Self> {
let listener = TcpListener::bind(bind)?;
@@ -86,6 +93,14 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiSnapshotResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/state") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiStateResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/preview") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiPreviewResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/catalog") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiCatalogResponse::from_snapshot(&snapshot))
@@ -102,9 +117,9 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
Ok(()) => Ok(()),
Err(error) => respond_error(
&mut stream,
400,
"invalid_command",
format!("command request was rejected: {error}"),
error.status,
error.code,
error.message,
),
},
("GET", "/") => respond_text(
@@ -148,16 +163,29 @@ fn handle_command_post(
stream: &mut TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> io::Result<()> {
) -> Result<(), ApiRequestError> {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_request_json".to_string(),
message: format!("command request body could not be parsed: {error}"),
})?;
let request_id = parsed.request_id.clone();
let summary = parsed.summary();
let command_type = parsed.command.kind_label().to_string();
let command = parsed
.into_host_command()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
service.send_command(command);
let snapshot = service.snapshot();
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_command".to_string(),
message: error,
})?;
let outcome = service
.send_command(command)
.map_err(|error| ApiRequestError {
status: 400,
code: error.code,
message: error.message,
})?;
respond_json(
stream,
200,
@@ -165,10 +193,16 @@ fn handle_command_post(
api_version: API_VERSION,
accepted: true,
request_id,
generated_at_millis: snapshot.generated_at_millis,
summary,
generated_at_millis: outcome.generated_at_millis,
command_type,
summary: outcome.summary,
},
)
.map_err(|error| ApiRequestError {
status: 500,
code: "response_write_failed".to_string(),
message: error.to_string(),
})
}
fn handle_websocket(
@@ -223,10 +257,7 @@ fn handle_websocket(
&mut stream,
sequence,
event.at_millis,
ApiStreamMessage::Event(ApiEventNotice {
kind: ApiEventKind::Info,
message: event.message,
}),
ApiStreamMessage::Event(event.into()),
)?;
sequence += 1;
}
@@ -280,6 +311,7 @@ fn respond_text(
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK",
};
let response = format!(

View File

@@ -5,8 +5,9 @@ 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;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
@@ -14,7 +15,15 @@ fn sample_project() -> ProjectConfig {
}
fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
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")
}
@@ -25,118 +34,183 @@ struct HttpResponse {
}
#[test]
fn root_serves_creative_console_shell() {
fn root_and_web_assets_target_the_versioned_api_contract() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/", None);
let html = send_http_request(server.local_addr(), "GET", "/", None);
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
assert_eq!(response.status_code, 200);
assert!(response
assert_eq!(html.status_code, 200);
assert!(html
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/html"));
assert!(response.body.contains("Infinity Vis / Creative Surface"));
assert!(response.body.contains("/app.js"));
assert!(html.body.contains("Preset Capture"));
assert!(html.body.contains("Creative Snapshots"));
assert!(html.body.contains("Event Stream"));
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("save_creative_snapshot"));
server.shutdown();
}
#[test]
fn snapshot_endpoint_is_versioned_and_separates_state_and_preview() {
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse");
assert_eq!(response.status_code, 200);
assert_eq!(body["api_version"], "v1");
assert_eq!(body["state"]["system"]["project_name"], "Infinity Vis");
assert_eq!(body["state"]["nodes"].as_array().map(Vec::len), Some(6));
assert_eq!(body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(body["state"]["active_scene"]["pattern_id"].is_string());
server.shutdown();
}
#[test]
fn catalog_presets_and_groups_endpoints_return_expected_lists() {
let server = start_server();
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let presets = send_http_request(server.local_addr(), "GET", "/api/v1/presets", None);
let groups = send_http_request(server.local_addr(), "GET", "/api/v1/groups", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let preset_body: Value = serde_json::from_str(&presets.body).expect("preset json");
let group_body: Value = serde_json::from_str(&groups.body).expect("group json");
assert_eq!(catalog.status_code, 200);
assert!(catalog_body["patterns"]
.as_array()
.expect("patterns array")
.iter()
.any(|pattern| pattern["pattern_id"] == "walking_pixel"));
assert!(preset_body["presets"]
.as_array()
.expect("presets array")
.iter()
.any(|preset| preset["preset_id"] == "ocean_gradient"));
assert!(group_body["groups"]
.as_array()
.expect("groups array")
.iter()
.any(|group| group["group_id"] == "top_panels"));
server.shutdown();
}
#[test]
fn command_endpoint_applies_state_changes_and_rejects_invalid_payload() {
let server = start_server();
let response = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-blackout",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
);
let response_body: Value =
serde_json::from_str(&response.body).expect("command response must parse");
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!(response.status_code, 200);
assert_eq!(response_body["accepted"], true);
assert_eq!(response_body["request_id"], "contract-blackout");
assert_eq!(snapshot_body["state"]["global"]["blackout"], true);
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"]["nodes"].as_array().map(Vec::len), Some(6));
let invalid = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#),
);
let invalid_body: Value =
serde_json::from_str(&invalid.body).expect("invalid response must parse");
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!(invalid.status_code, 400);
assert_eq!(invalid_body["api_version"], "v1");
assert_eq!(invalid_body["error"]["code"], "invalid_command");
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 websocket_stream_emits_snapshot_preview_and_event_messages() {
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 websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
@@ -148,43 +222,97 @@ fn websocket_stream_emits_snapshot_preview_and_event_messages() {
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = send_http_request(
let invalid = send_command_json(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-event",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
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_event = false;
let mut saw_warning = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws event frame");
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
saw_event = true;
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("blackout"));
.contains("does_not_exist"));
break;
}
}
assert!(saw_event, "expected websocket event after command");
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!(
@@ -293,3 +421,11 @@ fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
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"))
}