- per-source poll intervals: spawn task per source with own interval,
manager re-checks sources every 30s for add/remove
- initial screen update on TCP connect: send layout + widget states
- client tracking: ClientRegistry port, GET /api/clients, dashboard list
- webhook adapter: POST /api/webhook/{source_id} feeds data into projection
- widget preview: GET /api/widgets/{id}/preview returns current state
- serve SPA from Axum: ServeDir + index.html fallback via KFRAME_SPA_DIR
- layout builder delete confirmation with AlertDialog
- form validation: required fields disable save button
- guide page at /guide
- fix architecture: ClientDto to api-types, ClientRegistry + WidgetStateReader
ports in domain, DataProjection has internal Mutex, no adapter cross-deps
- ESP32: full screen clear on layout change (stale pixel fix)
212 lines
5.9 KiB
Rust
212 lines
5.9 KiB
Rust
use application::DataProjection;
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use config_memory::MemoryConfigStore;
|
|
use http_api::{AppState, router};
|
|
use std::sync::Arc;
|
|
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
|
|
use tower::ServiceExt;
|
|
|
|
fn test_app() -> axum::Router {
|
|
let state = AppState {
|
|
config: Arc::new(MemoryConfigStore::new()),
|
|
events: Arc::new(TcpEventBus::new(16)),
|
|
widget_states: Arc::new(DataProjection::new()),
|
|
broadcaster: Arc::new(TcpBroadcaster::new(16)),
|
|
clients: Arc::new(ClientTracker::new()),
|
|
spa_dir: None,
|
|
};
|
|
router(state)
|
|
}
|
|
|
|
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
|
let mut builder = Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header("content-type", "application/json");
|
|
|
|
if let Some(b) = body {
|
|
builder.body(Body::from(b.to_string())).unwrap()
|
|
} else {
|
|
builder.body(Body::empty()).unwrap()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_and_get_widget() {
|
|
let app = test_app();
|
|
|
|
let body = r#"{
|
|
"id": 1,
|
|
"name": "weather",
|
|
"display_hint": "icon_value",
|
|
"data_source_id": 10,
|
|
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
|
|
}"#;
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let resp = app
|
|
.oneshot(json_request("GET", "/api/widgets/1", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(json["name"], "weather");
|
|
assert_eq!(json["display_hint"], "icon_value");
|
|
assert_eq!(json["data_source_id"], 10);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_widgets() {
|
|
let app = test_app();
|
|
|
|
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
|
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
|
|
|
app.clone()
|
|
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
|
|
.await
|
|
.unwrap();
|
|
app.clone()
|
|
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(json_request("GET", "/api/widgets", None))
|
|
.await
|
|
.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(json.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_widget() {
|
|
let app = test_app();
|
|
|
|
let body =
|
|
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
|
app.clone()
|
|
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_request("DELETE", "/api/widgets/1", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
|
|
let resp = app
|
|
.oneshot(json_request("GET", "/api/widgets/1", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_and_get_data_source() {
|
|
let app = test_app();
|
|
|
|
let body = r#"{
|
|
"id": 10,
|
|
"name": "weather_api",
|
|
"source_type": "weather",
|
|
"poll_interval_secs": 300,
|
|
"url": "https://api.openweather.org",
|
|
"api_key": "test-key",
|
|
"headers": []
|
|
}"#;
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let resp = app
|
|
.oneshot(json_request("GET", "/api/data-sources/10", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(json["name"], "weather_api");
|
|
assert_eq!(json["poll_interval_secs"], 300);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_and_get_layout() {
|
|
let app = test_app();
|
|
|
|
let body = r#"{
|
|
"root": {
|
|
"type": "container",
|
|
"direction": "row",
|
|
"gap": 4,
|
|
"padding": 2,
|
|
"children": [
|
|
{"sizing": {"type": "flex", "value": 1}, "node": {"type": "leaf", "widget_id": 1}},
|
|
{"sizing": {"type": "fixed", "value": 80}, "node": {"type": "leaf", "widget_id": 2}}
|
|
]
|
|
}
|
|
}"#;
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_request("PUT", "/api/layout", Some(body)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let resp = app
|
|
.oneshot(json_request("GET", "/api/layout", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(json["root"]["type"], "container");
|
|
assert_eq!(json["root"]["direction"], "row");
|
|
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_nonexistent_returns_404() {
|
|
let app = test_app();
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_request("GET", "/api/widgets/99", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
|
|
let resp = app
|
|
.oneshot(json_request("GET", "/api/data-sources/99", None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|