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.
169 lines
4.9 KiB
Rust
169 lines
4.9 KiB
Rust
mod support;
|
|
|
|
use application::ConfigService;
|
|
use domain::{
|
|
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
|
Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout,
|
|
LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
|
};
|
|
use std::time::Duration;
|
|
use support::{InMemoryConfigRepository, InMemoryEventPublisher};
|
|
|
|
#[tokio::test]
|
|
async fn create_widget_persists_and_emits_event() {
|
|
let repo = InMemoryConfigRepository::new();
|
|
let events = InMemoryEventPublisher::new();
|
|
let service = ConfigService::new(&repo, &events);
|
|
|
|
let config = WidgetConfig::new(
|
|
1,
|
|
"weather".into(),
|
|
DisplayHint::new(DisplayHintKind::IconValue),
|
|
1,
|
|
vec![KeyMapping {
|
|
source_path: "$.temp".into(),
|
|
target_key: "temperature".into(),
|
|
}],
|
|
);
|
|
|
|
service.create_widget(config).await.unwrap();
|
|
|
|
let stored = repo.get_widget(1).await.unwrap();
|
|
assert!(stored.is_some());
|
|
|
|
let emitted = events.emitted();
|
|
assert_eq!(emitted.len(), 1);
|
|
assert!(matches!(emitted[0], DomainEvent::WidgetCreated { id: 1 }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_data_source_rejects_invalid() {
|
|
let repo = InMemoryConfigRepository::new();
|
|
let events = InMemoryEventPublisher::new();
|
|
let service = ConfigService::new(&repo, &events);
|
|
|
|
let source = DataSource {
|
|
id: 1,
|
|
name: "bad".into(),
|
|
source_type: DataSourceType::HttpJson,
|
|
poll_interval: Duration::from_secs(60),
|
|
config: DataSourceConfig::External {
|
|
url: None,
|
|
headers: vec![],
|
|
api_key: None,
|
|
},
|
|
};
|
|
|
|
let result = service.create_data_source(source).await;
|
|
assert!(result.is_err());
|
|
assert!(events.emitted().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_data_source_persists_valid_and_emits_event() {
|
|
let repo = InMemoryConfigRepository::new();
|
|
let events = InMemoryEventPublisher::new();
|
|
let service = ConfigService::new(&repo, &events);
|
|
|
|
let source = DataSource {
|
|
id: 1,
|
|
name: "weather".into(),
|
|
source_type: DataSourceType::Weather,
|
|
poll_interval: Duration::from_secs(300),
|
|
config: DataSourceConfig::External {
|
|
url: Some("https://api.weather.com".into()),
|
|
headers: vec![],
|
|
api_key: None,
|
|
},
|
|
};
|
|
|
|
service.create_data_source(source).await.unwrap();
|
|
|
|
let stored = repo.get_data_source(1).await.unwrap();
|
|
assert!(stored.is_some());
|
|
|
|
let emitted = events.emitted();
|
|
assert_eq!(emitted.len(), 1);
|
|
assert!(matches!(emitted[0], DomainEvent::DataSourceAdded { id: 1 }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_layout_persists_and_emits_event() {
|
|
let repo = InMemoryConfigRepository::new();
|
|
let events = InMemoryEventPublisher::new();
|
|
let service = ConfigService::new(&repo, &events);
|
|
|
|
let 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::Flex(1),
|
|
node: LayoutNode::Leaf(2),
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
|
|
service.update_layout(layout.clone()).await.unwrap();
|
|
|
|
let stored = repo.get_layout().await.unwrap();
|
|
assert_eq!(stored, Some(layout));
|
|
|
|
assert_eq!(events.emitted().len(), 1);
|
|
assert!(matches!(
|
|
events.emitted()[0],
|
|
DomainEvent::LayoutChanged { .. }
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_preset_replaces_active_layout() {
|
|
let repo = InMemoryConfigRepository::new();
|
|
let events = InMemoryEventPublisher::new();
|
|
let service = ConfigService::new(&repo, &events);
|
|
|
|
let preset_layout = Layout {
|
|
root: LayoutNode::Container(ContainerNode {
|
|
direction: Direction::Column,
|
|
gap: 0,
|
|
padding: 0,
|
|
justify_content: JustifyContent::Start,
|
|
align_items: AlignItems::Stretch,
|
|
children: vec![LayoutChild {
|
|
sizing: Sizing::Flex(1),
|
|
node: LayoutNode::Leaf(5),
|
|
}],
|
|
}),
|
|
};
|
|
|
|
let preset = LayoutPreset {
|
|
id: 1,
|
|
name: "vertical".into(),
|
|
layout: preset_layout.clone(),
|
|
};
|
|
|
|
repo.save_preset(&preset).await.unwrap();
|
|
|
|
service.load_preset(1).await.unwrap();
|
|
|
|
let stored = repo.get_layout().await.unwrap();
|
|
assert_eq!(stored, Some(preset_layout));
|
|
|
|
let emitted = events.emitted();
|
|
assert_eq!(emitted.len(), 2);
|
|
assert!(matches!(
|
|
emitted[0],
|
|
DomainEvent::LayoutPresetLoaded { id: 1 }
|
|
));
|
|
assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. }));
|
|
}
|