Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence, ThemeUpdate broadcast to ESP32 on save and initial connect. Client: render engine uses theme colors, full-screen redraw on theme change. SPA: theme page with color pickers + presets, layout preview with TS port of layout engine, justify/align controls on containers. DisplayHint refactored to struct (kind + h_align + v_align).
230 lines
6.6 KiB
Rust
230 lines
6.6 KiB
Rust
use config_sqlite::SqliteConfigStore;
|
|
use domain::{
|
|
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
|
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
|
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
|
};
|
|
use std::time::Duration;
|
|
|
|
async fn test_store() -> SqliteConfigStore {
|
|
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
|
|
}
|
|
|
|
fn weather_widget() -> WidgetConfig {
|
|
WidgetConfig {
|
|
id: 1,
|
|
name: "weather".into(),
|
|
display_hint: DisplayHint::new(DisplayHintKind::IconValue),
|
|
data_source_id: 10,
|
|
mappings: vec![
|
|
KeyMapping {
|
|
source_path: "$.temp".into(),
|
|
target_key: "temperature".into(),
|
|
},
|
|
KeyMapping {
|
|
source_path: "$.icon".into(),
|
|
target_key: "icon".into(),
|
|
},
|
|
],
|
|
max_data_size: 2048,
|
|
}
|
|
}
|
|
|
|
fn weather_source() -> DataSource {
|
|
DataSource {
|
|
id: 10,
|
|
name: "openweather".into(),
|
|
source_type: DataSourceType::Weather,
|
|
poll_interval: Duration::from_secs(300),
|
|
config: DataSourceConfig {
|
|
url: Some("https://api.openweather.org".into()),
|
|
headers: vec![],
|
|
api_key: Some("test-key".into()),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn test_layout() -> Layout {
|
|
Layout {
|
|
root: LayoutNode::Container(ContainerNode {
|
|
direction: Direction::Row,
|
|
gap: 4,
|
|
padding: 2,
|
|
justify_content: JustifyContent::Start,
|
|
align_items: AlignItems::Stretch,
|
|
children: vec![
|
|
LayoutChild {
|
|
sizing: Sizing::Flex(1),
|
|
node: LayoutNode::Leaf(1),
|
|
},
|
|
LayoutChild {
|
|
sizing: Sizing::Fixed(80),
|
|
node: LayoutNode::Leaf(2),
|
|
},
|
|
],
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_and_retrieve_widget() {
|
|
let store = test_store().await;
|
|
store.save_widget(&weather_widget()).await.unwrap();
|
|
|
|
let w = store.get_widget(1).await.unwrap().unwrap();
|
|
assert_eq!(w.id, 1);
|
|
assert_eq!(w.name, "weather");
|
|
assert_eq!(w.display_hint, DisplayHint::new(DisplayHintKind::IconValue));
|
|
assert_eq!(w.data_source_id, 10);
|
|
assert_eq!(w.mappings.len(), 2);
|
|
assert_eq!(w.mappings[0].source_path, "$.temp");
|
|
assert_eq!(w.max_data_size, 2048);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_nonexistent_widget_returns_none() {
|
|
let store = test_store().await;
|
|
assert!(store.get_widget(99).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_widgets_returns_all() {
|
|
let store = test_store().await;
|
|
store.save_widget(&weather_widget()).await.unwrap();
|
|
store
|
|
.save_widget(&WidgetConfig {
|
|
id: 2,
|
|
name: "portfolio".into(),
|
|
display_hint: DisplayHint::new(DisplayHintKind::KeyValue),
|
|
data_source_id: 20,
|
|
mappings: vec![],
|
|
max_data_size: 1024,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let widgets = store.list_widgets().await.unwrap();
|
|
assert_eq!(widgets.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_widget_removes_it() {
|
|
let store = test_store().await;
|
|
store.save_widget(&weather_widget()).await.unwrap();
|
|
store.delete_widget(1).await.unwrap();
|
|
assert!(store.get_widget(1).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_and_retrieve_data_source() {
|
|
let store = test_store().await;
|
|
store.save_data_source(&weather_source()).await.unwrap();
|
|
|
|
let ds = store.get_data_source(10).await.unwrap().unwrap();
|
|
assert_eq!(ds.id, 10);
|
|
assert_eq!(ds.name, "openweather");
|
|
assert_eq!(ds.source_type, DataSourceType::Weather);
|
|
assert_eq!(ds.poll_interval, Duration::from_secs(300));
|
|
assert_eq!(ds.config.url, Some("https://api.openweather.org".into()));
|
|
assert_eq!(ds.config.api_key, Some("test-key".into()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_and_delete_data_sources() {
|
|
let store = test_store().await;
|
|
store.save_data_source(&weather_source()).await.unwrap();
|
|
assert_eq!(store.list_data_sources().await.unwrap().len(), 1);
|
|
|
|
store.delete_data_source(10).await.unwrap();
|
|
assert!(store.list_data_sources().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_and_retrieve_layout() {
|
|
let store = test_store().await;
|
|
let layout = test_layout();
|
|
store.save_layout(&layout).await.unwrap();
|
|
|
|
let loaded = store.get_layout().await.unwrap().unwrap();
|
|
assert_eq!(loaded, layout);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn layout_starts_as_none() {
|
|
let store = test_store().await;
|
|
assert!(store.get_layout().await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_layout_replaces_previous() {
|
|
let store = test_store().await;
|
|
store.save_layout(&test_layout()).await.unwrap();
|
|
|
|
let new_layout = Layout {
|
|
root: LayoutNode::Leaf(42),
|
|
};
|
|
store.save_layout(&new_layout).await.unwrap();
|
|
|
|
let loaded = store.get_layout().await.unwrap().unwrap();
|
|
assert_eq!(loaded, new_layout);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_and_retrieve_preset() {
|
|
let store = test_store().await;
|
|
let preset = LayoutPreset {
|
|
id: 1,
|
|
name: "dashboard".into(),
|
|
layout: test_layout(),
|
|
};
|
|
store.save_preset(&preset).await.unwrap();
|
|
|
|
let loaded = store.get_preset(1).await.unwrap().unwrap();
|
|
assert_eq!(loaded.id, 1);
|
|
assert_eq!(loaded.name, "dashboard");
|
|
assert_eq!(loaded.layout, test_layout());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_and_delete_presets() {
|
|
let store = test_store().await;
|
|
store
|
|
.save_preset(&LayoutPreset {
|
|
id: 1,
|
|
name: "a".into(),
|
|
layout: test_layout(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.save_preset(&LayoutPreset {
|
|
id: 2,
|
|
name: "b".into(),
|
|
layout: test_layout(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(store.list_presets().await.unwrap().len(), 2);
|
|
|
|
store.delete_preset(1).await.unwrap();
|
|
assert_eq!(store.list_presets().await.unwrap().len(), 1);
|
|
assert!(store.get_preset(1).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_widget_updates_existing() {
|
|
let store = test_store().await;
|
|
store.save_widget(&weather_widget()).await.unwrap();
|
|
|
|
let mut updated = weather_widget();
|
|
updated.name = "updated_weather".into();
|
|
updated.max_data_size = 4096;
|
|
store.save_widget(&updated).await.unwrap();
|
|
|
|
let loaded = store.get_widget(1).await.unwrap().unwrap();
|
|
assert_eq!(loaded.name, "updated_weather");
|
|
assert_eq!(loaded.max_data_size, 4096);
|
|
assert_eq!(store.list_widgets().await.unwrap().len(), 1);
|
|
}
|