DataSourceConfig refactored to enum: External/Clock/StaticText. Clock generates formatted time via chrono, static text emits configured string. ESP32: connection status indicator (green/red dot bottom-right), per-widget clear before redraw, RenderEvent enum for local + server messages. Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state. Empty mappings passthrough raw source data for internal sources.
235 lines
6.8 KiB
Rust
235 lines
6.8 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::External {
|
|
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));
|
|
match &ds.config {
|
|
DataSourceConfig::External { url, api_key, .. } => {
|
|
assert_eq!(*url, Some("https://api.openweather.org".into()));
|
|
assert_eq!(*api_key, Some("test-key".into()));
|
|
}
|
|
_ => panic!("expected External config"),
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|