use dampen_core::binding::UiBindable;
use dampen_core::parser::error::ParseError;
use dampen_core::state::AppState;
use serde::{Serialize, de::DeserializeOwned};
use std::collections::HashMap;
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
#[derive(Clone)]
struct ParsedDocumentCache {
document: dampen_core::ir::DampenDocument,
cached_at: Instant,
}
pub struct HotReloadContext<M> {
last_model_snapshot: Option<String>,
last_reload_timestamp: Instant,
reload_count: usize,
error: Option<String>,
parse_cache: HashMap<u64, ParsedDocumentCache>,
max_cache_size: usize,
cache_hits: AtomicUsize,
cache_misses: AtomicUsize,
_marker: PhantomData<M>,
}
impl<M: UiBindable> HotReloadContext<M> {
pub fn new() -> Self {
Self {
last_model_snapshot: None,
last_reload_timestamp: Instant::now(),
reload_count: 0,
error: None,
parse_cache: HashMap::new(),
max_cache_size: 10,
cache_hits: AtomicUsize::new(0),
cache_misses: AtomicUsize::new(0),
_marker: PhantomData,
}
}
pub fn with_cache_size(cache_size: usize) -> Self {
Self {
last_model_snapshot: None,
last_reload_timestamp: Instant::now(),
reload_count: 0,
error: None,
parse_cache: HashMap::new(),
max_cache_size: cache_size,
cache_hits: AtomicUsize::new(0),
cache_misses: AtomicUsize::new(0),
_marker: PhantomData,
}
}
fn compute_content_hash(xml_source: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
xml_source.hash(&mut hasher);
hasher.finish()
}
fn get_cached_document(&self, xml_source: &str) -> Option<dampen_core::ir::DampenDocument> {
let content_hash = Self::compute_content_hash(xml_source);
self.parse_cache
.get(&content_hash)
.map(|entry| entry.document.clone())
.inspect(|_| {
self.cache_hits.fetch_add(1, Ordering::Relaxed);
})
.or_else(|| {
self.cache_misses.fetch_add(1, Ordering::Relaxed);
None
})
}
fn cache_document(&mut self, xml_source: &str, document: dampen_core::ir::DampenDocument) {
if self.parse_cache.len() >= self.max_cache_size
&& let Some(oldest_key) = self
.parse_cache
.iter()
.min_by_key(|(_, entry)| entry.cached_at)
.map(|(key, _)| *key)
{
self.parse_cache.remove(&oldest_key);
}
let content_hash = Self::compute_content_hash(xml_source);
self.parse_cache.insert(
content_hash,
ParsedDocumentCache {
document,
cached_at: Instant::now(),
},
);
}
pub fn clear_cache(&mut self) {
self.parse_cache.clear();
}
pub fn cache_stats(&self) -> (usize, usize) {
(self.parse_cache.len(), self.max_cache_size)
}
pub fn performance_metrics(&self) -> ReloadPerformanceMetrics {
ReloadPerformanceMetrics {
reload_count: self.reload_count,
last_reload_latency: self.last_reload_latency(),
cache_hit_rate: self.calculate_cache_hit_rate(),
cache_size: self.parse_cache.len(),
}
}
fn calculate_cache_hit_rate(&self) -> f64 {
let hits = self.cache_hits.load(Ordering::Relaxed);
let misses = self.cache_misses.load(Ordering::Relaxed);
let total = hits.saturating_add(misses);
if total == 0 {
0.0
} else {
hits as f64 / total as f64
}
}
pub fn snapshot_model(&mut self, model: &M) -> Result<(), String>
where
M: Serialize,
{
match serde_json::to_string(model) {
Ok(json) => {
self.last_model_snapshot = Some(json);
Ok(())
}
Err(e) => Err(format!("Failed to serialize model: {}", e)),
}
}
pub fn restore_model(&self) -> Result<M, String>
where
M: DeserializeOwned,
{
match &self.last_model_snapshot {
Some(json) => serde_json::from_str(json)
.map_err(|e| format!("Failed to deserialize model: {}", e)),
None => Err("No model snapshot available".to_string()),
}
}
pub fn record_reload(&mut self, success: bool) {
self.reload_count += 1;
self.last_reload_timestamp = Instant::now();
if !success {
self.error = Some("Reload failed".to_string());
} else {
self.error = None;
}
}
pub fn record_reload_with_timing(&mut self, success: bool, elapsed: Duration) {
self.reload_count += 1;
self.last_reload_timestamp = Instant::now();
if !success {
self.error = Some("Reload failed".to_string());
} else {
self.error = None;
}
if success && elapsed.as_millis() > 300 {
eprintln!(
"Warning: Hot-reload took {}ms (target: <300ms)",
elapsed.as_millis()
);
}
}
pub fn last_reload_latency(&self) -> Duration {
self.last_reload_timestamp.elapsed()
}
}
impl<M: UiBindable> Default for HotReloadContext<M> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy)]
pub struct ReloadPerformanceMetrics {
pub reload_count: usize,
pub last_reload_latency: Duration,
pub cache_hit_rate: f64,
pub cache_size: usize,
}
impl ReloadPerformanceMetrics {
pub fn meets_target(&self) -> bool {
self.last_reload_latency.as_millis() < 300
}
pub fn latency_ms(&self) -> u128 {
self.last_reload_latency.as_millis()
}
}
#[derive(Debug)]
pub enum ReloadResult<M: UiBindable> {
Success(AppState<M>),
ParseError(ParseError),
ValidationError(Vec<String>),
StateRestoreWarning(AppState<M>, String),
}
pub fn attempt_hot_reload<M, F>(
xml_source: &str,
current_state: &AppState<M>,
context: &mut HotReloadContext<M>,
create_handlers: F,
) -> ReloadResult<M>
where
M: UiBindable + Serialize + DeserializeOwned + Default,
F: FnOnce() -> dampen_core::handler::HandlerRegistry,
{
let reload_start = Instant::now();
if let Err(e) = context.snapshot_model(¤t_state.model) {
eprintln!("Warning: Failed to snapshot model: {}", e);
}
let new_document = if let Some(cached_doc) = context.get_cached_document(xml_source) {
cached_doc
} else {
match dampen_core::parser::parse(xml_source) {
Ok(doc) => {
context.cache_document(xml_source, doc.clone());
doc
}
Err(err) => {
context.record_reload(false);
return ReloadResult::ParseError(err);
}
}
};
let new_handlers = create_handlers();
if let Err(missing_handlers) = validate_handlers(&new_document, &new_handlers) {
context.record_reload(false);
let error_messages: Vec<String> = missing_handlers
.iter()
.map(|h| format!("Handler '{}' is referenced but not registered", h))
.collect();
return ReloadResult::ValidationError(error_messages);
}
let restored_model = match context.restore_model() {
Ok(model) => {
model
}
Err(e) => {
eprintln!("Warning: Failed to restore model ({}), using default", e);
let new_state = AppState::with_all(new_document, M::default(), new_handlers);
context.record_reload(true);
return ReloadResult::StateRestoreWarning(new_state, e);
}
};
let new_state = AppState::with_all(new_document, restored_model, new_handlers);
let elapsed = reload_start.elapsed();
context.record_reload_with_timing(true, elapsed);
ReloadResult::Success(new_state)
}
pub async fn attempt_hot_reload_async<M, F>(
xml_source: Arc<String>,
current_state: &AppState<M>,
context: &mut HotReloadContext<M>,
create_handlers: F,
) -> ReloadResult<M>
where
M: UiBindable + Serialize + DeserializeOwned + Default + Send + 'static,
F: FnOnce() -> dampen_core::handler::HandlerRegistry + Send + 'static,
{
let reload_start = Instant::now();
if let Err(e) = context.snapshot_model(¤t_state.model) {
eprintln!("Warning: Failed to snapshot model: {}", e);
}
let model_snapshot = context.last_model_snapshot.clone();
let new_document = if let Some(cached_doc) = context.get_cached_document(&xml_source) {
cached_doc
} else {
let xml_for_parse = Arc::clone(&xml_source);
let parse_result =
tokio::task::spawn_blocking(move || dampen_core::parser::parse(&xml_for_parse)).await;
match parse_result {
Ok(Ok(doc)) => {
context.cache_document(&xml_source, doc.clone());
doc
}
Ok(Err(err)) => {
context.record_reload(false);
return ReloadResult::ParseError(err);
}
Err(join_err) => {
context.record_reload(false);
let error = ParseError {
kind: dampen_core::parser::error::ParseErrorKind::XmlSyntax,
span: dampen_core::ir::span::Span::default(),
message: format!("Async parsing failed: {}", join_err),
suggestion: Some(
"Check if the XML file is accessible and not corrupted".to_string(),
),
};
return ReloadResult::ParseError(error);
}
}
};
let new_handlers = create_handlers();
if let Err(missing_handlers) = validate_handlers(&new_document, &new_handlers) {
context.record_reload(false);
let error_messages: Vec<String> = missing_handlers
.iter()
.map(|h| format!("Handler '{}' is referenced but not registered", h))
.collect();
return ReloadResult::ValidationError(error_messages);
}
let restored_model = match model_snapshot {
Some(json) => match serde_json::from_str::<M>(&json) {
Ok(model) => model,
Err(e) => {
eprintln!("Warning: Failed to restore model ({}), using default", e);
let new_state = AppState::with_all(new_document, M::default(), new_handlers);
context.record_reload(true);
return ReloadResult::StateRestoreWarning(
new_state,
format!("Failed to deserialize model: {}", e),
);
}
},
None => {
eprintln!("Warning: No model snapshot available, using default");
let new_state = AppState::with_all(new_document, M::default(), new_handlers);
context.record_reload(true);
return ReloadResult::StateRestoreWarning(
new_state,
"No model snapshot available".to_string(),
);
}
};
let new_state = AppState::with_all(new_document, restored_model, new_handlers);
let elapsed = reload_start.elapsed();
context.record_reload_with_timing(true, elapsed);
ReloadResult::Success(new_state)
}
fn collect_handler_names(document: &dampen_core::ir::DampenDocument) -> Vec<String> {
use std::collections::HashSet;
let mut handlers = HashSet::new();
collect_handlers_from_node(&document.root, &mut handlers);
handlers.into_iter().collect()
}
fn collect_handlers_from_node(
node: &dampen_core::ir::node::WidgetNode,
handlers: &mut std::collections::HashSet<String>,
) {
for event in &node.events {
handlers.insert(event.handler.clone());
}
for child in &node.children {
collect_handlers_from_node(child, handlers);
}
}
fn validate_handlers(
document: &dampen_core::ir::DampenDocument,
registry: &dampen_core::handler::HandlerRegistry,
) -> Result<(), Vec<String>> {
let referenced_handlers = collect_handler_names(document);
let mut missing_handlers = Vec::new();
for handler_name in referenced_handlers {
if registry.get(&handler_name).is_none() {
missing_handlers.push(handler_name);
}
}
if missing_handlers.is_empty() {
Ok(())
} else {
Err(missing_handlers)
}
}
#[derive(Debug)]
pub enum ThemeReloadResult {
Success,
ParseError(String),
ValidationError(String),
NoThemeContext,
FileNotFound,
}
pub fn attempt_theme_hot_reload(
theme_path: &std::path::Path,
theme_context: &mut Option<dampen_core::state::ThemeContext>,
) -> ThemeReloadResult {
let theme_context = match theme_context {
Some(ctx) => ctx,
None => return ThemeReloadResult::NoThemeContext,
};
let content = match std::fs::read_to_string(theme_path) {
Ok(c) => c,
Err(e) => return ThemeReloadResult::ParseError(format!("Failed to read file: {}", e)),
};
let new_doc = match dampen_core::parser::theme_parser::parse_theme_document(&content) {
Ok(doc) => doc,
Err(e) => {
return ThemeReloadResult::ParseError(format!(
"Failed to parse theme document: {}",
e.message
));
}
};
if let Err(e) = new_doc.validate() {
return ThemeReloadResult::ValidationError(e.to_string());
}
theme_context.reload(new_doc);
ThemeReloadResult::Success
}
pub fn is_theme_file_path(path: &std::path::Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "theme.dampen")
.unwrap_or(false)
}
pub fn get_theme_dir_from_path(path: &std::path::Path) -> Option<std::path::PathBuf> {
let path = std::fs::canonicalize(path).ok()?;
let theme_file_name = path.file_name()?;
if theme_file_name == "theme.dampen" {
return Some(path.parent()?.to_path_buf());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct TestModel {
count: i32,
name: String,
}
impl UiBindable for TestModel {
fn get_field(&self, _path: &[&str]) -> Option<dampen_core::binding::BindingValue> {
None
}
fn available_fields() -> Vec<String> {
vec![]
}
}
impl Default for TestModel {
fn default() -> Self {
Self {
count: 0,
name: "default".to_string(),
}
}
}
#[test]
fn test_snapshot_model_success() {
let mut context = HotReloadContext::<TestModel>::new();
let model = TestModel {
count: 42,
name: "Alice".to_string(),
};
let result = context.snapshot_model(&model);
assert!(result.is_ok());
assert!(context.last_model_snapshot.is_some());
}
#[test]
fn test_restore_model_success() {
let mut context = HotReloadContext::<TestModel>::new();
let original = TestModel {
count: 42,
name: "Alice".to_string(),
};
context.snapshot_model(&original).unwrap();
let restored = context.restore_model().unwrap();
assert_eq!(restored, original);
}
#[test]
fn test_restore_model_no_snapshot() {
let context = HotReloadContext::<TestModel>::new();
let result = context.restore_model();
assert!(result.is_err());
assert!(result.unwrap_err().contains("No model snapshot"));
}
#[test]
fn test_snapshot_restore_round_trip() {
let mut context = HotReloadContext::<TestModel>::new();
let original = TestModel {
count: 999,
name: "Bob".to_string(),
};
context.snapshot_model(&original).unwrap();
let mut modified = original.clone();
modified.count = 0;
modified.name = "Changed".to_string();
let restored = context.restore_model().unwrap();
assert_eq!(restored, original);
assert_ne!(restored, modified);
}
#[test]
fn test_multiple_snapshots() {
let mut context = HotReloadContext::<TestModel>::new();
let model1 = TestModel {
count: 1,
name: "First".to_string(),
};
context.snapshot_model(&model1).unwrap();
let model2 = TestModel {
count: 2,
name: "Second".to_string(),
};
context.snapshot_model(&model2).unwrap();
let restored = context.restore_model().unwrap();
assert_eq!(restored, model2);
assert_ne!(restored, model1);
}
#[test]
fn test_record_reload() {
let mut context = HotReloadContext::<TestModel>::new();
assert_eq!(context.reload_count, 0);
assert!(context.error.is_none());
context.record_reload(true);
assert_eq!(context.reload_count, 1);
assert!(context.error.is_none());
context.record_reload(false);
assert_eq!(context.reload_count, 2);
assert!(context.error.is_some());
context.record_reload(true);
assert_eq!(context.reload_count, 3);
assert!(context.error.is_none());
}
#[test]
fn test_cache_hit_rate_calculated_correctly() {
use std::sync::atomic::Ordering;
let mut context = HotReloadContext::<TestModel>::new();
assert_eq!(context.calculate_cache_hit_rate(), 0.0);
context.cache_hits.store(3, Ordering::Relaxed);
context.cache_misses.store(2, Ordering::Relaxed);
assert_eq!(context.calculate_cache_hit_rate(), 0.6);
}
#[test]
fn test_cache_hit_rate_zero_division() {
let context = HotReloadContext::<TestModel>::new();
assert_eq!(context.calculate_cache_hit_rate(), 0.0);
}
#[test]
fn test_cache_hit_rate_full_misses() {
use std::sync::atomic::Ordering;
let mut context = HotReloadContext::<TestModel>::new();
context.cache_hits.store(0, Ordering::Relaxed);
context.cache_misses.store(5, Ordering::Relaxed);
assert_eq!(context.calculate_cache_hit_rate(), 0.0);
}
#[test]
fn test_cache_hit_rate_full_hits() {
use std::sync::atomic::Ordering;
let mut context = HotReloadContext::<TestModel>::new();
context.cache_hits.store(5, Ordering::Relaxed);
context.cache_misses.store(0, Ordering::Relaxed);
assert_eq!(context.calculate_cache_hit_rate(), 1.0);
}
#[test]
fn test_attempt_hot_reload_success() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="Version 1" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 42,
name: "Alice".to_string(),
};
let registry_v1 = HandlerRegistry::new();
let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="Version 2" /></column></dampen>"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || HandlerRegistry::new());
match result {
ReloadResult::Success(new_state) => {
assert_eq!(new_state.model.count, 42);
assert_eq!(new_state.model.name, "Alice");
assert_eq!(context.reload_count, 1);
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_attempt_hot_reload_parse_error() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="Version 1" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 10,
name: "Bob".to_string(),
};
let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
let mut context = HotReloadContext::<TestModel>::new();
let xml_invalid = r#"<dampen version="1.1" encoding="utf-8"><column><text value="Broken"#;
let result = attempt_hot_reload(xml_invalid, &state_v1, &mut context, || {
HandlerRegistry::new()
});
match result {
ReloadResult::ParseError(_err) => {
assert_eq!(context.reload_count, 1); }
_ => panic!("Expected ParseError, got {:?}", result),
}
}
#[test]
fn test_attempt_hot_reload_model_restore_failure() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="Version 1" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 99,
name: "Charlie".to_string(),
};
let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
let mut context = HotReloadContext::<TestModel>::new();
context.last_model_snapshot = Some("{ invalid json }".to_string());
let xml_v2 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="Version 2" /></column></dampen>"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || HandlerRegistry::new());
match result {
ReloadResult::Success(new_state) => {
assert_eq!(new_state.model.count, 99);
assert_eq!(new_state.model.name, "Charlie");
assert_eq!(context.reload_count, 1);
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_attempt_hot_reload_preserves_model_across_multiple_reloads() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="V1" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 100,
name: "Dave".to_string(),
};
let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="V2" /></column></dampen>"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || HandlerRegistry::new());
let state_v2 = match result {
ReloadResult::Success(s) => s,
_ => panic!("First reload failed"),
};
assert_eq!(state_v2.model.count, 100);
assert_eq!(state_v2.model.name, "Dave");
let xml_v3 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="V3" /></column></dampen>"#;
let result = attempt_hot_reload(xml_v3, &state_v2, &mut context, || HandlerRegistry::new());
let state_v3 = match result {
ReloadResult::Success(s) => s,
_ => panic!("Second reload failed"),
};
assert_eq!(state_v3.model.count, 100);
assert_eq!(state_v3.model.name, "Dave");
assert_eq!(context.reload_count, 2);
}
#[test]
fn test_attempt_hot_reload_with_handler_registry() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><button label="Click" on_click="test" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 5,
name: "Eve".to_string(),
};
let registry_v1 = HandlerRegistry::new();
registry_v1.register_simple("test", |_model| {
});
let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"<dampen version="1.1" encoding="utf-8"><column><button label="Click Me" on_click="test2" /></column></dampen>"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
let registry = HandlerRegistry::new();
registry.register_simple("test2", |_model| {
});
registry
});
match result {
ReloadResult::Success(new_state) => {
assert_eq!(new_state.model.count, 5);
assert_eq!(new_state.model.name, "Eve");
assert!(new_state.handler_registry.get("test2").is_some());
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_collect_handler_names() {
use dampen_core::parser;
let xml = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="Click" on_click="handle_click" />
<text_input placeholder="Type" on_input="handle_input" />
<button label="Submit" on_click="handle_submit" />
</column>
</dampen>
"#;
let doc = parser::parse(xml).unwrap();
let handlers = collect_handler_names(&doc);
assert_eq!(handlers.len(), 3);
assert!(handlers.contains(&"handle_click".to_string()));
assert!(handlers.contains(&"handle_input".to_string()));
assert!(handlers.contains(&"handle_submit".to_string()));
}
#[test]
fn test_collect_handler_names_nested() {
use dampen_core::parser;
let xml = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<row>
<button label="A" on_click="handler_a" />
</row>
<row>
<button label="B" on_click="handler_b" />
<column>
<button label="C" on_click="handler_c" />
</column>
</row>
</column>
</dampen>
"#;
let doc = parser::parse(xml).unwrap();
let handlers = collect_handler_names(&doc);
assert_eq!(handlers.len(), 3);
assert!(handlers.contains(&"handler_a".to_string()));
assert!(handlers.contains(&"handler_b".to_string()));
assert!(handlers.contains(&"handler_c".to_string()));
}
#[test]
fn test_collect_handler_names_duplicates() {
use dampen_core::parser;
let xml = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="1" on_click="same_handler" />
<button label="2" on_click="same_handler" />
<button label="3" on_click="same_handler" />
</column>
</dampen>
"#;
let doc = parser::parse(xml).unwrap();
let handlers = collect_handler_names(&doc);
assert_eq!(handlers.len(), 1);
assert!(handlers.contains(&"same_handler".to_string()));
}
#[test]
fn test_validate_handlers_all_present() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="Click" on_click="test_handler" />
</column>
</dampen>
"#;
let doc = parser::parse(xml).unwrap();
let registry = HandlerRegistry::new();
registry.register_simple("test_handler", |_model| {});
let result = validate_handlers(&doc, ®istry);
assert!(result.is_ok());
}
#[test]
fn test_validate_handlers_missing() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="Click" on_click="missing_handler" />
</column>
</dampen>
"#;
let doc = parser::parse(xml).unwrap();
let registry = HandlerRegistry::new();
let result = validate_handlers(&doc, ®istry);
assert!(result.is_err());
let missing = result.unwrap_err();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "missing_handler");
}
#[test]
fn test_validate_handlers_multiple_missing() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="A" on_click="handler_a" />
<button label="B" on_click="handler_b" />
<button label="C" on_click="handler_c" />
</column>
</dampen>
"#;
let doc = parser::parse(xml).unwrap();
let registry = HandlerRegistry::new();
registry.register_simple("handler_b", |_model| {});
let result = validate_handlers(&doc, ®istry);
assert!(result.is_err());
let missing = result.unwrap_err();
assert_eq!(missing.len(), 2);
assert!(missing.contains(&"handler_a".to_string()));
assert!(missing.contains(&"handler_c".to_string()));
}
#[test]
fn test_attempt_hot_reload_validation_error() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="V1" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 10,
name: "Test".to_string(),
};
let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="Click" on_click="unregistered_handler" />
</column>
</dampen>
"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
HandlerRegistry::new() });
match result {
ReloadResult::ValidationError(errors) => {
assert!(!errors.is_empty());
assert!(errors[0].contains("unregistered_handler"));
assert_eq!(context.reload_count, 1); }
_ => panic!("Expected ValidationError, got {:?}", result),
}
}
#[test]
fn test_attempt_hot_reload_validation_success() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><text value="V1" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 20,
name: "Valid".to_string(),
};
let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="Click" on_click="registered_handler" />
</column>
</dampen>
"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
let registry = HandlerRegistry::new();
registry.register_simple("registered_handler", |_model| {});
registry
});
match result {
ReloadResult::Success(new_state) => {
assert_eq!(new_state.model.count, 20);
assert_eq!(new_state.model.name, "Valid");
assert_eq!(context.reload_count, 1);
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_handler_registry_complete_replacement() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="Old" on_click="old_handler" />
</column>
</dampen>
"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 1,
name: "Initial".to_string(),
};
let registry_v1 = HandlerRegistry::new();
registry_v1.register_simple("old_handler", |_model| {});
let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
assert!(state_v1.handler_registry.get("old_handler").is_some());
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"
<dampen version="1.1" encoding="utf-8">
<column>
<button label="New" on_click="new_handler" />
<button label="Another" on_click="another_handler" />
</column>
</dampen>
"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
let registry = HandlerRegistry::new();
registry.register_simple("new_handler", |_model| {});
registry.register_simple("another_handler", |_model| {});
registry
});
match result {
ReloadResult::Success(new_state) => {
assert_eq!(new_state.model.count, 1);
assert_eq!(new_state.model.name, "Initial");
assert!(new_state.handler_registry.get("old_handler").is_none());
assert!(new_state.handler_registry.get("new_handler").is_some());
assert!(new_state.handler_registry.get("another_handler").is_some());
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_handler_registry_rebuild_before_validation() {
use dampen_core::handler::HandlerRegistry;
use dampen_core::parser;
let xml_v1 = r#"<dampen version="1.1" encoding="utf-8"><column><button on_click="handler_a" label="A" /></column></dampen>"#;
let doc_v1 = parser::parse(xml_v1).unwrap();
let model_v1 = TestModel {
count: 100,
name: "Test".to_string(),
};
let registry_v1 = HandlerRegistry::new();
registry_v1.register_simple("handler_a", |_model| {});
let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
let mut context = HotReloadContext::<TestModel>::new();
let xml_v2 = r#"<dampen version="1.1" encoding="utf-8"><column><button on_click="handler_b" label="B" /></column></dampen>"#;
let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
let registry = HandlerRegistry::new();
registry.register_simple("handler_b", |_model| {}); registry
});
match result {
ReloadResult::Success(new_state) => {
assert_eq!(new_state.model.count, 100);
assert!(new_state.handler_registry.get("handler_b").is_some());
assert!(new_state.handler_registry.get("handler_a").is_none());
}
_ => panic!(
"Expected Success (registry rebuilt before validation), got {:?}",
result
),
}
}
}
#[cfg(test)]
mod theme_reload_tests {
use super::*;
use dampen_core::ir::style::Color;
use dampen_core::ir::theme::{SpacingScale, Theme, ThemeDocument, ThemePalette, Typography};
use tempfile::TempDir;
fn create_test_palette(primary: &str) -> ThemePalette {
ThemePalette {
primary: Some(Color::from_hex(primary).unwrap()),
secondary: Some(Color::from_hex("#2ecc71").unwrap()),
success: Some(Color::from_hex("#27ae60").unwrap()),
warning: Some(Color::from_hex("#f39c12").unwrap()),
danger: Some(Color::from_hex("#e74c3c").unwrap()),
background: Some(Color::from_hex("#ecf0f1").unwrap()),
surface: Some(Color::from_hex("#ffffff").unwrap()),
text: Some(Color::from_hex("#2c3e50").unwrap()),
text_secondary: Some(Color::from_hex("#7f8c8d").unwrap()),
}
}
fn create_test_theme(name: &str, primary: &str) -> Theme {
Theme {
name: name.to_string(),
palette: create_test_palette(primary),
typography: Typography {
font_family: Some("sans-serif".to_string()),
font_size_base: Some(16.0),
font_size_small: Some(12.0),
font_size_large: Some(24.0),
font_weight: dampen_core::ir::theme::FontWeight::Normal,
line_height: Some(1.5),
},
spacing: SpacingScale { unit: Some(8.0) },
base_styles: std::collections::HashMap::new(),
extends: None,
}
}
fn create_test_document() -> ThemeDocument {
ThemeDocument {
themes: std::collections::HashMap::from([
("light".to_string(), create_test_theme("light", "#3498db")),
("dark".to_string(), create_test_theme("dark", "#5dade2")),
]),
default_theme: Some("light".to_string()),
follow_system: true,
}
}
fn create_test_theme_context() -> dampen_core::state::ThemeContext {
let doc = create_test_document();
dampen_core::state::ThemeContext::from_document(doc, None).unwrap()
}
#[test]
fn test_attempt_theme_hot_reload_success() {
let temp_dir = TempDir::new().unwrap();
let theme_dir = temp_dir.path().join("src/ui/theme");
std::fs::create_dir_all(&theme_dir).unwrap();
let theme_content = r##"
<dampen version="1.1" encoding="utf-8">
<themes>
<theme name="light">
<palette
primary="#111111"
secondary="#2ecc71"
success="#27ae60"
warning="#f39c12"
danger="#e74c3c"
background="#ecf0f1"
surface="#ffffff"
text="#2c3e50"
text_secondary="#7f8c8d" />
</theme>
</themes>
<default_theme name="light" />
</dampen>
"##;
let theme_path = theme_dir.join("theme.dampen");
std::fs::write(&theme_path, theme_content).unwrap();
let mut theme_ctx = Some(create_test_theme_context());
let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
match result {
ThemeReloadResult::Success => {
let ctx = theme_ctx.unwrap();
assert_eq!(ctx.active_name(), "light");
assert_eq!(
ctx.active().palette.primary,
Some(Color::from_hex("#111111").unwrap())
);
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_attempt_theme_hot_reload_parse_error() {
let temp_dir = TempDir::new().unwrap();
let theme_dir = temp_dir.path().join("src/ui/theme");
std::fs::create_dir_all(&theme_dir).unwrap();
let theme_path = theme_dir.join("theme.dampen");
std::fs::write(&theme_path, "invalid xml").unwrap();
let mut theme_ctx = Some(create_test_theme_context());
let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
match result {
ThemeReloadResult::ParseError(_) => {}
_ => panic!("Expected ParseError, got {:?}", result),
}
}
#[test]
fn test_attempt_theme_hot_reload_no_theme_context() {
let temp_dir = TempDir::new().unwrap();
let theme_dir = temp_dir.path().join("src/ui/theme");
std::fs::create_dir_all(&theme_dir).unwrap();
let theme_path = theme_dir.join("theme.dampen");
std::fs::write(
&theme_path,
r#"<dampen version="1.1" encoding="utf-8"><themes></themes></dampen>"#,
)
.unwrap();
let mut theme_ctx: Option<dampen_core::state::ThemeContext> = None;
let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
match result {
ThemeReloadResult::NoThemeContext => {}
_ => panic!("Expected NoThemeContext, got {:?}", result),
}
}
#[test]
fn test_attempt_theme_hot_reload_preserves_active_theme() {
let temp_dir = TempDir::new().unwrap();
let theme_dir = temp_dir.path().join("src/ui/theme");
std::fs::create_dir_all(&theme_dir).unwrap();
let theme_content = r##"
<dampen version="1.1" encoding="utf-8">
<themes>
<theme name="new_theme">
<palette
primary="#ff0000"
secondary="#2ecc71"
success="#27ae60"
warning="#f39c12"
danger="#e74c3c"
background="#ecf0f1"
surface="#ffffff"
text="#2c3e50"
text_secondary="#7f8c8d" />
</theme>
<theme name="dark">
<palette
primary="#5dade2"
secondary="#52be80"
success="#27ae60"
warning="#f39c12"
danger="#ec7063"
background="#2c3e50"
surface="#34495e"
text="#ecf0f1"
text_secondary="#95a5a6" />
</theme>
</themes>
<default_theme name="new_theme" />
</dampen>
"##;
let theme_path = theme_dir.join("theme.dampen");
std::fs::write(&theme_path, theme_content).unwrap();
let mut theme_ctx = Some(create_test_theme_context());
theme_ctx.as_mut().unwrap().set_theme("dark").unwrap();
assert_eq!(theme_ctx.as_ref().unwrap().active_name(), "dark");
let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
match result {
ThemeReloadResult::Success => {
let ctx = theme_ctx.unwrap();
assert_eq!(ctx.active_name(), "dark");
}
_ => panic!("Expected Success, got {:?}", result),
}
}
#[test]
fn test_is_theme_file_path() {
assert!(is_theme_file_path(&std::path::PathBuf::from(
"src/ui/theme/theme.dampen"
)));
assert!(!is_theme_file_path(&std::path::PathBuf::from(
"src/ui/window.dampen"
)));
assert!(is_theme_file_path(&std::path::PathBuf::from(
"/some/path/theme.dampen"
)));
}
#[test]
fn test_get_theme_dir_from_path() {
let temp_dir = TempDir::new().unwrap();
let theme_dir = temp_dir.path().join("src/ui/theme");
std::fs::create_dir_all(&theme_dir).unwrap();
let theme_path = theme_dir.join("theme.dampen");
std::fs::write(
&theme_path,
"<dampen version=\"1.0\"><themes></themes></dampen>",
)
.unwrap();
let result = get_theme_dir_from_path(&theme_path);
assert!(result.is_some());
assert_eq!(result.unwrap(), theme_dir);
}
}