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.
432 lines
17 KiB
Rust
432 lines
17 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 app_js = send_http_request(server.local_addr(), "GET", "/app.js", 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("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 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"]["nodes"].as_array().map(Vec::len), Some(6));
|
|
|
|
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 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());
|
|
|
|
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..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" {
|
|
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"))
|
|
}
|