#[cfg(test)] mod tests { use super::*; use chrono::TimeZone; #[test] fn heat_coasting_modulates_setpoint_without_power_cycle() { assert!(!heat_coasting_next_state(false, "heat", 19.8, 20.8, 0.9)); assert!(heat_coasting_next_state(false, "heat", 19.9, 20.8, 0.9)); assert!(heat_coasting_next_state(true, "heat", 19.6, 20.8, 0.9)); assert!(!heat_coasting_next_state(true, "heat", 19.5, 20.8, 0.9)); assert!(!heat_coasting_next_state(true, "heat", 21.0, 20.8, 0.0)); assert!(!heat_coasting_next_state(true, "cool", 21.0, 20.8, 0.9)); assert_eq!(round_device_setpoint("heat", false, 20.8 - 5.0), 16.0); } #[test] fn thermostat_target_normalization_preserves_tenths() { assert!((normalize_thermostat_target(24.2) - 24.2).abs() < 1e-9); assert!((normalize_thermostat_target(24.24) - 24.2).abs() < 1e-9); assert!((normalize_thermostat_target(24.25) - 24.3).abs() < 1e-9); assert!((normalize_thermostat_target(24.5) - 24.5).abs() < 1e-9); } #[test] fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() { assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600); assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900); assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120); assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400); } #[test] fn ha_sensor_log_message_identifies_sensor_and_zone() { let message = home_assistant_sensor_log_message( "Home Assistant sensor is stale: 3607s old (limit 3600s)", Some("sensor.igor_temperature"), "Igor", ); assert_eq!( message, "Home Assistant sensor sensor.igor_temperature, zone Igor is stale: 3607s old (limit 3600s)" ); let message = home_assistant_sensor_log_message( "Home Assistant state is not a number", Some("sensor.jan_temperature"), "Jan", ); assert_eq!( message, "Home Assistant state is not a number [sensor: sensor.jan_temperature, zone Jan]" ); } #[test] fn control_plan_semantic_equality_ignores_only_generation_timestamp() { let generated_at = Utc.with_ymd_and_hms(2026, 9, 4, 8, 0, 0).unwrap(); let left = ControlPlan { generated_at: generated_at.clone(), house_mode: "cool".into(), emergency_stop_active: false, emergency_stop_since: None, house_preset: Some("comfort".into()), house_power: true, outdoor_temperature: Some(24.0), control_strategy: "thermostat".into(), night_mode_active: false, night_mode_start: "22:00".into(), night_mode_end: "06:00".into(), night_mode_max_fan_speed: 2, next_events: Vec::new(), zones: Vec::new(), rules: Vec::new(), }; let mut right = left.clone(); right.generated_at = generated_at + chrono::Duration::seconds(30); assert!(control_plans_semantically_equal(&left, &right).unwrap()); right.house_mode = "heat".into(); assert!(!control_plans_semantically_equal(&left, &right).unwrap()); right = left.clone(); right.emergency_stop_active = true; assert!(!control_plans_semantically_equal(&left, &right).unwrap()); } #[test] fn overnight_schedule_works() { let now = Utc .with_ymd_and_hms(2025, 1, 7, 1, 0, 0) .unwrap() .with_timezone(&Local); // Tuesday let item = Schedule { id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true, weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, }; assert!(schedule_active(&item, now)); } fn test_schedule(id: &str, weekdays: Vec, start: &str, end: &str) -> Schedule { Schedule { id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true, weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, } } #[test] fn equal_schedule_times_mean_a_full_day() { let item = test_schedule("full", vec![1], "06:00", "06:00"); let monday_noon = Local .with_ymd_and_hms(2025, 1, 6, 12, 0, 0) .single() .unwrap(); let tuesday_early = Local .with_ymd_and_hms(2025, 1, 7, 5, 59, 0) .single() .unwrap(); let tuesday_after = Local .with_ymd_and_hms(2025, 1, 7, 6, 1, 0) .single() .unwrap(); assert!(schedule_active(&item, monday_noon)); assert!(schedule_active(&item, tuesday_early)); assert!(!schedule_active(&item, tuesday_after)); } #[test] fn schedule_overlap_detection_handles_overnight_ranges() { let daytime = test_schedule("day", vec![1, 2, 3, 4, 5, 6, 7], "06:30", "22:30"); let night = test_schedule("night", vec![1, 2, 3, 4, 5, 6, 7], "22:30", "06:30"); let conflict = test_schedule("conflict", vec![1], "22:00", "23:00"); assert!(!schedules_overlap(&daytime, &night)); assert!(schedules_overlap(&night, &conflict)); } #[test] fn next_schedule_boundary_scans_the_whole_week() { let friday = test_schedule("friday", vec![5], "12:00", "13:00"); let monday = Local .with_ymd_and_hms(2025, 1, 6, 10, 0, 30) .single() .unwrap(); let boundary = next_schedule_boundary_utc("z", &[friday], monday) .unwrap() .with_timezone(&Local); assert_eq!(boundary.weekday(), Weekday::Fri); assert_eq!(boundary.hour(), 12); assert_eq!(boundary.minute(), 0); assert_eq!(boundary.second(), 0); } #[test] fn full_week_schedule_has_no_manual_override_boundary() { let always = test_schedule("always", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00"); let now = Local .with_ymd_and_hms(2025, 1, 6, 10, 0, 30) .single() .unwrap(); assert!(next_schedule_boundary_utc("z", &[always], now).is_none()); } #[test] fn workday_weekend_handoff_has_no_overlaps() { let schedules = vec![ test_schedule("morning", vec![1, 2, 3, 4, 5], "06:30", "08:00"), test_schedule("away", vec![1, 2, 3, 4, 5], "08:00", "16:00"), test_schedule("evening", vec![1, 2, 3, 4, 5], "16:00", "22:30"), test_schedule("sleep", vec![1, 2, 3, 4, 5], "22:30", "06:30"), test_schedule("weekend", vec![6, 7], "08:00", "23:00"), test_schedule("saturday-sleep", vec![6], "23:00", "08:00"), test_schedule("sunday-sleep", vec![7], "23:00", "06:30"), ]; for (index, item) in schedules.iter().enumerate() { for other in schedules.iter().skip(index + 1) { assert!( !schedules_overlap(item, other), "{} overlaps {}", item.name, other.name ); } } } #[test] fn flow_cron_validation_and_matching_cover_ranges_steps_and_sunday_alias() { assert!(cron_expression_valid("*/5 * * * *")); assert!(cron_expression_valid("0,15,30,45 6-18 * * 1-5")); for invalid in [ "* * * *", "*/0 * * * *", "61 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "* * * * 8", "*/100 * * * *", ] { assert!( !cron_expression_valid(invalid), "accepted invalid cron: {invalid}" ); } let monday = Local .with_ymd_and_hms(2026, 9, 7, 10, 15, 0) .single() .unwrap(); assert!(cron_matches("15 10 * * 1", &monday)); assert!(cron_matches("15 10 * * 1-7", &monday)); assert!(!cron_matches("16 10 * * 1", &monday)); let sunday = Local .with_ymd_and_hms(2026, 9, 6, 8, 0, 0) .single() .unwrap(); assert!(cron_matches("0 8 * * 0", &sunday)); assert!(cron_matches("0 8 * * 7", &sunday)); assert!(cron_matches("0 8 * * 1-7", &sunday)); } #[test] fn flow_zone_action_noop_detects_persisted_on_intent_and_real_off_state() { let mut zone = test_zone("device"); zone.inherit_house_mode = false; zone.mode = "heat".into(); zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some(20.0); zone.effective_mode = "heat".into(); let on = DeviceCommand { power: Some(true), mode: Some("heat".into()), target_temperature: Some(20.0), ..Default::default() }; assert!(flow_zone_action_already_satisfied( &zone, false, 0, 0, Some("custom"), &on, None, None )); assert!(!flow_zone_action_already_satisfied( &zone, false, 0, 0, Some("custom"), &on, Some(1.1), Some(5.0) )); zone.heat_anticipation_c = 1.1; zone.standby_offset_c = 5.0; assert!(flow_zone_action_already_satisfied( &zone, false, 0, 0, Some("custom"), &on, Some(1.1), Some(5.0) )); zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; zone.effective_mode = "off".into(); zone.device_setpoint = None; zone.demand = false; let off = DeviceCommand { power: Some(false), mode: Some("heat".into()), ..Default::default() }; assert!(flow_zone_action_already_satisfied( &zone, false, 0, 0, Some("auto"), &off, None, None )); assert!(!flow_zone_action_already_satisfied( &zone, true, 0, 0, Some("auto"), &off, None, None )); } #[test] fn flow_zone_action_noop_keeps_auxiliary_swing_changes_executable() { let mut zone = test_zone("device"); zone.inherit_house_mode = false; zone.mode = "heat".into(); zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some(20.0); let action = DeviceCommand { power: Some(true), mode: Some("heat".into()), target_temperature: Some(20.0), swing_vertical: Some(1), ..Default::default() }; assert!(!flow_zone_action_already_satisfied( &zone, true, 0, 0, Some("custom"), &action, None, None )); assert!(flow_zone_action_already_satisfied( &zone, true, 1, 0, Some("custom"), &action, None, None )); } #[test] fn flow_off_normalizes_concrete_hvac_mode_without_mapping_auto_to_device_auto() { let heat = DeviceCommand { power: Some(false), mode: Some("heat".into()), ..Default::default() }; let cool = DeviceCommand { power: Some(false), mode: Some("cool".into()), ..Default::default() }; let auto = DeviceCommand { power: Some(false), mode: Some("auto".into()), ..Default::default() }; assert_eq!(flow_off_physical_mode(&heat).as_deref(), Some("heat")); assert_eq!(flow_off_physical_mode(&cool).as_deref(), Some("cool")); assert_eq!(flow_off_physical_mode(&auto), None); } #[test] fn flow_owned_thermostat_does_not_receive_global_outdoor_assist() { assert_eq!( thermostat_outdoor_assist_temperature(true, "automation.flow", Some(-5.0)), None ); assert_eq!( thermostat_outdoor_assist_temperature(true, "schedule", Some(-5.0)), Some(-5.0) ); assert_eq!( thermostat_outdoor_assist_temperature(false, "schedule", Some(-5.0)), None ); } #[test] fn flow_timed_gate_requires_continuity_and_resets_on_false() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); assert!(!flow_timed_gate_update(&mut state, true, 30, start)); assert!(!flow_timed_gate_update( &mut state, true, 30, start + chrono::Duration::seconds(29) )); assert!(flow_timed_gate_update( &mut state, true, 30, start + chrono::Duration::seconds(30) )); assert!(!flow_timed_gate_update( &mut state, false, 30, start + chrono::Duration::seconds(31) )); assert!(state.since.is_none()); assert!(!flow_timed_gate_update( &mut state, true, 30, start + chrono::Duration::seconds(32) )); } #[test] fn flow_state_duration_respects_minimum_maximum_and_reset() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); assert_eq!( flow_state_duration_update(&mut state, true, 10, Some(30), start), (false, 0) ); assert_eq!( flow_state_duration_update( &mut state, true, 10, Some(30), start + chrono::Duration::seconds(10) ), (true, 10) ); assert_eq!( flow_state_duration_update( &mut state, true, 10, Some(30), start + chrono::Duration::seconds(30) ), (true, 30) ); assert_eq!( flow_state_duration_update( &mut state, true, 10, Some(30), start + chrono::Duration::seconds(31) ), (false, 31) ); assert_eq!( flow_state_duration_update( &mut state, false, 10, Some(30), start + chrono::Duration::seconds(32) ), (false, 0) ); assert!(state.since.is_none()); } #[test] fn flow_change_gate_latches_until_action_ack_or_input_reset() { let mut state = crate::models::FlowRuntimeNodeState::default(); assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (false, false)); assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (true, true)); assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, true)); state.pending = false; assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, false)); state.pending = true; assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (true, false)); } #[test] fn flow_rate_limit_prunes_rolling_window_and_blocks_at_limit() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); state.samples.push(crate::models::FlowRuntimeSample { at: start, value: 1.0, }); state.samples.push(crate::models::FlowRuntimeSample { at: start + chrono::Duration::seconds(10), value: 1.0, }); assert_eq!( flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(20)), (false, 2) ); assert_eq!( flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(61)), (true, 1) ); } #[test] fn flow_rolling_statistics_require_a_full_window_and_prune_samples() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); assert_eq!(flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), None); assert_eq!( flow_rolling_stat_update( &mut state, 20.0, 60, "mean", start + chrono::Duration::seconds(30) ), None ); assert_eq!( flow_rolling_stat_update( &mut state, 30.0, 60, "mean", start + chrono::Duration::seconds(60) ), Some(20.0) ); assert_eq!( flow_rolling_stat_update( &mut state, 40.0, 60, "median", start + chrono::Duration::seconds(90) ), Some(30.0) ); assert_eq!(state.samples.len(), 3); } #[test] fn flow_oscillation_requires_direction_changes_not_only_large_span() { let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); let bouncing = [20.0, 22.0, 19.0, 23.0, 20.0] .into_iter() .enumerate() .map(|(index, value)| crate::models::FlowRuntimeSample { at: start + chrono::Duration::seconds(index as i64 * 10), value, }) .collect::>(); let monotonic = [20.0, 21.0, 22.0, 23.0, 24.0] .into_iter() .enumerate() .map(|(index, value)| crate::models::FlowRuntimeSample { at: start + chrono::Duration::seconds(index as i64 * 10), value, }) .collect::>(); let (span, changes) = flow_oscillation_metrics(&bouncing).unwrap(); assert!(span >= 4.0); assert!(changes >= 2); let (_, monotonic_changes) = flow_oscillation_metrics(&monotonic).unwrap(); assert_eq!(monotonic_changes, 0); } #[test] fn heating_curve_interpolates_and_clamps_to_endpoints() { let config = json!({ "points": [ {"x": -20.0, "y": 30.0}, {"x": 0.0, "y": 25.0}, {"x": 20.0, "y": 19.0} ] }); assert_eq!(flow_heating_curve_output(&config, -30.0), Some(30.0)); assert_eq!(flow_heating_curve_output(&config, 30.0), Some(19.0)); assert_eq!(flow_heating_curve_output(&config, -10.0), Some(27.5)); assert_eq!(flow_heating_curve_output(&config, 10.0), Some(22.0)); } #[test] fn time_automation_fires_only_once_in_the_same_minute() { let now = Local .with_ymd_and_hms(2025, 1, 6, 10, 15, 40) .single() .unwrap(); let mut item = Automation { id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(), trigger_device_id: None, threshold: None, at_time: Some("10:15".into()), action_device_id: "d".into(), action_group_id: None, action_preset: None, action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30, last_fired_at: None, last_trigger_trace: vec![], action_zone_id: None, action_zone_preset: None, action_zone_heat_anticipation_c: None, action_zone_standby_offset_c: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_value_source_id: None, flow_value_target: None, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(), created_at: Utc::now(), updated_at: Utc::now(), }; assert!(time_automation_due(&item, now.clone())); item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc)); assert!(!time_automation_due(&item, now)); } fn test_zone(source: &str) -> Zone { Zone { id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true, mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1, cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0, heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0, hysteresis: 0.6, separate_hysteresis: false, cool_hysteresis: 0.6, heat_hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, heat_anticipation_c: 0.0, heat_coasting: false, standby_offset_c: 2.0, smart_fan: true, sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), ha_outdoor_entity_id: None, external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None, device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(), } } #[test] fn regulator_zone_event_dedup_ignores_only_updated_at_heartbeat() { let zone = test_zone("device"); let mut heartbeat = zone.clone(); heartbeat.updated_at = zone.updated_at.clone() + chrono::Duration::seconds(5); assert_eq!( zone_event_semantic_value(&zone).unwrap(), zone_event_semantic_value(&heartbeat).unwrap() ); heartbeat.device_temperature = Some(24.5); assert_ne!( zone_event_semantic_value(&zone).unwrap(), zone_event_semantic_value(&heartbeat).unwrap() ); } #[test] fn regulator_zone_plan_invalidation_tracks_only_plan_inputs() { let zone = test_zone("combined"); let mut diagnostics_only = zone.clone(); diagnostics_only.device_temperature = Some(24.0); diagnostics_only.external_temperature = Some(23.0); diagnostics_only.last_action_at = Some(Utc::now()); assert!(!zone_runtime_change_affects_control_plan( &zone, &diagnostics_only )); let mut temperature_changed = zone.clone(); temperature_changed.current_temperature = Some(23.5); assert!(zone_runtime_change_affects_control_plan( &zone, &temperature_changed )); let mut demand_changed = zone.clone(); demand_changed.demand = !zone.demand; assert!(zone_runtime_change_affects_control_plan( &zone, &demand_changed )); } #[test] fn device_poll_heartbeat_does_not_invalidate_control_plan() { let device = Device::simulated_default(); let mut heartbeat = device.clone(); heartbeat.last_seen = Some(Utc::now() + chrono::Duration::seconds(15)); heartbeat.updated_at = Utc::now() + chrono::Duration::seconds(15); heartbeat.response_time_ms = Some(42); heartbeat.current_temperature = Some(25.1); assert!(!device_runtime_change_affects_control_plan( &device, &heartbeat )); let mut power_changed = device.clone(); power_changed.power = !device.power; assert!(device_runtime_change_affects_control_plan( &device, &power_changed )); let mut health_changed = device.clone(); health_changed.communication_failures = 1; assert!(device_runtime_change_affects_control_plan( &device, &health_changed )); } #[test] fn zone_can_use_separate_heating_and_cooling_hysteresis() { let mut zone = test_zone("device"); zone.hysteresis = 0.6; zone.separate_hysteresis = true; zone.cool_hysteresis = 0.4; zone.heat_hysteresis = 1.2; assert_eq!(zone.hysteresis_for_mode("cool"), 0.4); assert_eq!(zone.hysteresis_for_mode("heat"), 1.2); zone.separate_hysteresis = false; assert_eq!(zone.hysteresis_for_mode("cool"), 0.6); assert_eq!(zone.hysteresis_for_mode("heat"), 0.6); } #[test] fn external_device_change_detects_manual_climate_controls() { let zone = test_zone("device"); let before = Device::simulated_default(); let mut after = before.clone(); after.power = !before.power; after.target_temperature = before.target_temperature + 1.0; after.fan_speed = 3; let fields = externally_changed_control_fields(&before, &after, &zone); assert!(fields.iter().any(|field| field == "power")); assert!(fields.iter().any(|field| field == "target_temperature")); assert!(fields.iter().any(|field| field == "fan_speed")); } #[test] fn controller_expected_climate_change_is_recognized() { let mut device = Device::simulated_default(); device.power = true; device.mode = "cool".into(); device.target_temperature = 22.0; let command = DeviceCommand { power: Some(true), mode: Some("cool".into()), target_temperature: Some(22.0), ..Default::default() }; assert!(command_field_matches_device(&command, "power", &device)); assert!(command_field_matches_device(&command, "mode", &device)); assert!(command_field_matches_device( &command, "target_temperature", &device )); device.target_temperature = 25.0; assert!(!command_field_matches_device( &command, "target_temperature", &device )); } #[test] fn conflicting_thermostat_output_fields_are_detected_for_direct_automation() { assert!(!automation_action_conflicts_with_thermostat( &DeviceCommand { power: Some(true), ..Default::default() } )); assert!(!automation_action_conflicts_with_thermostat( &DeviceCommand { target_temperature: Some(22.0), ..Default::default() } )); assert!(automation_action_conflicts_with_thermostat( &DeviceCommand { fan_speed: Some(3), ..Default::default() } )); assert!(automation_action_conflicts_with_thermostat( &DeviceCommand { quiet: Some(true), ..Default::default() } )); assert!(automation_action_conflicts_with_thermostat( &DeviceCommand { mode: Some("fan".into()), ..Default::default() } )); assert!(automation_action_conflicts_with_thermostat( &DeviceCommand { mode: Some("dry".into()), ..Default::default() } )); assert!(!automation_action_conflicts_with_thermostat( &DeviceCommand { mode: Some("heat".into()), ..Default::default() } )); assert!(!automation_action_conflicts_with_thermostat( &DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() } )); assert!(!automation_action_conflicts_with_thermostat( &DeviceCommand { swing_vertical: Some(1), swing_horizontal: Some(0), ..Default::default() } )); } #[test] fn local_thermostat_ownership_blocks_direct_automation() { let mut zone = test_zone("device"); assert!(!device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone), )); zone.local_thermostat_power = Some(true); assert!(device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone), )); zone.local_thermostat_power = Some(false); assert!(device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone), )); } #[test] fn active_temporary_thermostat_blocks_direct_automation() { let now = Utc::now(); let mut zone = test_zone("device"); zone.temporary_quick_thermostat = Some(temporary_session(now)); assert!(device_blocked_by_temporary_thermostat( &zone.device_id, std::slice::from_ref(&zone) )); } #[test] fn local_thermostat_resume_clears_only_local_quick_control_state() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(false); zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15)); zone.manual_preset = Some("comfort".into()); zone.manual_setpoint = Some(23.0); zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1)); zone.device_manual_override = true; assert!(reset_local_thermostat_override(&mut zone)); assert!(zone.local_thermostat_power.is_none()); assert!(zone.local_thermostat_resume_at.is_none()); assert!(zone.manual_preset.is_none()); assert!(zone.manual_setpoint.is_none()); assert!(zone.manual_override_until.is_none()); assert!(zone.device_manual_override); assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15); } fn temporary_session(now: DateTime) -> TemporaryQuickThermostat { TemporaryQuickThermostat { start_kind: "now".into(), finish_kind: "temperature_stable".into(), started_at: now.clone(), activated_at: Some(now), state: "active".into(), active_mode: Some("heat".into()), restore_zone_enabled: Some(true), restore_local_thermostat_power: None, restore_local_thermostat_resume_at: None, restore_local_thermostat_zone_enabled: None, restore_manual_preset: None, restore_manual_setpoint: None, restore_manual_override_until: None, expires_at: None, duration_seconds: None, safety_duration_seconds: None, temperature_target: Some(23.0), temperature_operator: Some("within".into()), tolerance_c: 0.3, hold_seconds: 3600, condition_started_at: None, condition_last_observed_at: None, paused_at: None, deferred_mode: None, deferred_preset: None, deferred_setpoint: None, safety_expires_at: None, } } #[test] fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() { let now = Utc::now(); let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); let mut session = temporary_session(now + chrono::Duration::hours(1)); session.start_kind = "delay".into(); session.activated_at = None; session.state = "scheduled".into(); zone.temporary_quick_thermostat = Some(session); assert!(!temporary_quick_thermostat_is_active(&zone, now)); assert_eq!( temporary_quick_thermostat_wakeup_at(&zone, now), Some(now + chrono::Duration::hours(1)) ); } #[test] fn local_handback_cleanup_does_not_remove_pending_temporary_session() { let now = Utc::now(); let mut zone = test_zone("device"); set_local_thermostat_power(&mut zone, false, now); let mut session = temporary_session(now + chrono::Duration::minutes(30)); session.start_kind = "delay".into(); session.activated_at = None; session.state = "scheduled".into(); zone.temporary_quick_thermostat = Some(session); reset_local_thermostat_override(&mut zone); assert!(zone.temporary_quick_thermostat.is_some()); assert!(zone.local_thermostat_power.is_none()); } #[test] fn temporary_quick_thermostat_restores_previous_zone_enabled_state() { let now = Utc::now(); let mut zone = test_zone("device"); zone.enabled = true; zone.local_thermostat_power = Some(true); let mut session = temporary_session(now); session.restore_zone_enabled = Some(false); zone.temporary_quick_thermostat = Some(session); zone.manual_setpoint = Some(23.0); assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); assert!(!zone.enabled); assert!(zone.local_thermostat_power.is_none()); assert!(zone.temporary_quick_thermostat.is_none()); assert!(zone.manual_setpoint.is_none()); } #[test] fn temporary_quick_thermostat_returns_to_underlying_local_quick_state() { let now = Utc::now(); let mut zone = test_zone("device"); zone.enabled = true; zone.local_thermostat_power = Some(true); zone.local_thermostat_restore_zone_enabled = None; zone.manual_setpoint = Some(24.0); let mut session = temporary_session(now); session.restore_zone_enabled = Some(true); session.restore_local_thermostat_power = Some(true); session.restore_local_thermostat_zone_enabled = Some(false); session.restore_manual_setpoint = Some(22.0); zone.temporary_quick_thermostat = Some(session); zone.manual_setpoint = Some(23.0); assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); assert!(zone.enabled); assert_eq!(zone.local_thermostat_power, Some(true)); assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false)); assert_eq!(zone.manual_setpoint, Some(22.0)); } #[test] fn deferred_non_custom_preset_wins_over_stale_custom_setpoint() { let now = Utc::now(); let mut zone = test_zone("device"); let mut session = temporary_session(now); session.deferred_preset = Some("comfort".into()); session.deferred_setpoint = Some(27.0); zone.temporary_quick_thermostat = Some(session); assert!(finish_temporary_quick_thermostat(&mut zone, &[], "cool")); assert_eq!(zone.manual_preset.as_deref(), Some("comfort")); assert!(zone.manual_setpoint.is_none()); } #[test] fn temporary_session_without_activation_marker_is_never_active() { let now = Utc::now(); let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); let mut session = temporary_session(now); session.activated_at = None; session.state = "scheduled".into(); zone.temporary_quick_thermostat = Some(session); assert!(!temporary_quick_thermostat_is_active(&zone, now)); } #[test] fn temporary_stable_condition_requires_continuous_hold_time() { let now = Utc::now(); let mut zone = test_zone("device"); zone.current_temperature = Some(23.2); let mut session = temporary_session(now.clone()); session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599)); zone.temporary_quick_thermostat = Some(session); assert!(evaluate_temporary_quick_thermostat_condition( &mut zone, now.clone(), Some(now.clone()), 10 ) .is_none()); assert_eq!( evaluate_temporary_quick_thermostat_condition( &mut zone, now + chrono::Duration::seconds(2), Some(now + chrono::Duration::seconds(2)), 10 ), Some("temperature_stable".into()) ); } #[test] fn temporary_stable_condition_resets_when_temperature_leaves_range() { let now = Utc::now(); let mut zone = test_zone("device"); zone.current_temperature = Some(24.0); let mut session = temporary_session(now.clone()); session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30)); zone.temporary_quick_thermostat = Some(session); assert!( evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none() ); assert!(zone .temporary_quick_thermostat .as_ref() .unwrap() .condition_started_at .is_none()); } #[test] fn temporary_stable_condition_resets_after_observation_gap() { let now = Utc::now(); let mut zone = test_zone("device"); zone.current_temperature = Some(23.0); let mut session = temporary_session(now); session.condition_started_at = Some(now - chrono::Duration::hours(1)); session.condition_last_observed_at = Some(now - chrono::Duration::minutes(10)); zone.temporary_quick_thermostat = Some(session); assert!( evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none() ); assert_eq!( zone.temporary_quick_thermostat .as_ref() .unwrap() .condition_started_at, Some(now) ); } #[test] fn delayed_temporary_session_does_not_block_automation_before_start() { let now = Utc::now(); let mut zone = test_zone("device"); let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); session.start_kind = "delay".into(); session.activated_at = None; session.state = "scheduled".into(); session.expires_at = Some(now.clone() + chrono::Duration::hours(3)); zone.temporary_quick_thermostat = Some(session); assert!(!device_blocked_by_local_thermostat( &zone.device_id, std::slice::from_ref(&zone) )); assert_eq!( temporary_quick_thermostat_wakeup_at(&zone, now.clone()), Some(now + chrono::Duration::hours(1)) ); } #[test] fn delayed_temperature_condition_cannot_finish_before_activation() { let now = Utc::now(); let mut zone = test_zone("device"); zone.current_temperature = Some(23.0); let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); session.start_kind = "at".into(); session.activated_at = None; zone.temporary_quick_thermostat = Some(session); assert!( evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none() ); assert!(zone .temporary_quick_thermostat .as_ref() .unwrap() .condition_started_at .is_none()); } #[test] fn temporary_setpoint_keeps_priority_over_active_schedule() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); zone.manual_setpoint = Some(23.0); zone.temporary_quick_thermostat = Some(temporary_session(Utc::now())); let mut schedule = test_schedule("night", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00"); schedule.preset = "custom".into(); schedule.setpoint = 19.0; let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool"); assert_eq!(target, 23.0); } #[test] fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() { let mut zone = test_zone("device"); zone.inherit_house_mode = true; zone.mode = "cool".into(); assert_eq!(effective_zone_mode(&zone, "off"), "off"); zone.local_thermostat_power = Some(true); assert_eq!(effective_zone_mode(&zone, "off"), "cool"); } #[test] fn group_manual_control_can_run_when_house_mode_is_off() { let mut zone = test_zone("device"); zone.inherit_house_mode = true; zone.mode = "cool".into(); zone.control_source = "group:downstairs".into(); assert_eq!(effective_zone_mode(&zone, "off"), "cool"); } #[test] fn legacy_global_power_flag_does_not_take_ownership() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); zone.control_source = "web_thermostat".into(); refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "local_thermostat"); assert_ne!(zone.control_source, "global"); } #[test] fn indefinite_local_off_does_not_create_or_rearm_handback() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(false); zone.local_thermostat_resume_at = None; assert!(!local_thermostat_handback_is_active(&zone)); assert!(!rearm_local_thermostat_resume(&mut zone, Utc::now())); assert_eq!(zone.local_thermostat_power, Some(false)); assert!(zone.local_thermostat_resume_at.is_none()); } #[test] fn timed_local_off_remains_blocking_until_explicit_deadline() { let mut zone = test_zone("device"); let now = Utc::now(); set_local_thermostat_power(&mut zone, false, now); assert!(local_thermostat_handback_is_active(&zone)); assert_eq!(zone.local_thermostat_power, Some(false)); assert!(zone.local_thermostat_resume_at.is_some()); } #[test] fn local_thermostat_off_restarts_backend_handback_deadline() { let mut zone = test_zone("device"); let first = Utc::now(); set_local_thermostat_power(&mut zone, false, first.clone()); let first_deadline = zone.local_thermostat_resume_at.clone().unwrap(); assert_eq!( first_deadline, first.clone() + chrono::Duration::minutes(15) ); let second = first + chrono::Duration::minutes(4); set_local_thermostat_power(&mut zone, true, second.clone()); assert!(zone.local_thermostat_resume_at.is_none()); set_local_thermostat_power(&mut zone, false, second.clone()); assert_eq!( zone.local_thermostat_resume_at, Some(second + chrono::Duration::minutes(15)) ); assert!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline); } #[test] fn manual_device_takeover_suspends_local_handback_until_control_returns() { let mut zone = test_zone("device"); let now = Utc::now(); set_local_thermostat_power(&mut zone, false, now.clone()); assert!(local_thermostat_handback_is_active(&zone)); zone.device_manual_override = true; assert!(!local_thermostat_handback_is_active(&zone)); zone.device_manual_override = false; let returned = now + chrono::Duration::minutes(7); assert!(rearm_local_thermostat_resume(&mut zone, returned.clone())); assert_eq!( zone.local_thermostat_resume_at, Some(returned + chrono::Duration::minutes(15)) ); assert!(local_thermostat_handback_is_active(&zone)); } #[test] fn rounded_gree_setpoint_does_not_create_manual_override() { let zone = test_zone("device"); let mut before = Device::simulated_default(); before.target_temperature = 23.5; let mut after = before.clone(); after.target_temperature = 24.0; assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); } #[test] fn standby_low_to_auto_fan_drift_is_not_manual_override() { let mut zone = test_zone("device"); zone.smart_fan = true; zone.demand = false; let mut before = Device::simulated_default(); before.fan_speed = 1; let mut after = before.clone(); after.fan_speed = 0; assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); } #[test] fn reset_device_manual_override_clears_takeover_state() { let mut zone = test_zone("device"); let device = Device::simulated_default(); zone.device_manual_override = true; zone.device_manual_override_since = Some(Utc::now()); zone.device_manual_override_until = Some(Utc::now()); zone.device_manual_override_fields = vec!["target_temperature".into()]; zone.device_manual_override_baseline = Some((&device).into()); assert!(reset_device_manual_override(&mut zone)); assert!(!zone.device_manual_override); assert!(zone.device_manual_override_since.is_none()); assert!(zone.device_manual_override_until.is_none()); assert!(zone.device_manual_override_fields.is_empty()); assert!(zone.device_manual_override_baseline.is_none()); } #[test] fn restored_manual_device_state_matches_original_takeover_baseline() { let mut zone = test_zone("device"); let baseline = Device::simulated_default(); zone.device_manual_override = true; zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()]; zone.device_manual_override_baseline = Some((&baseline).into()); let mut changed = baseline.clone(); changed.power = !baseline.power; changed.target_temperature = baseline.target_temperature + 2.0; assert!(!manual_override_matches_baseline(&zone, &changed)); let mut returned_off = baseline.clone(); returned_off.target_temperature = baseline.target_temperature + 3.0; assert!(manual_override_matches_baseline(&zone, &returned_off)); assert!(manual_override_matches_baseline(&zone, &baseline)); } #[test] fn device_command_drops_unchanged_fields() { let device = Device::simulated_default(); let command = DeviceCommand { power: Some(false), mode: Some("cool".into()), target_temperature: Some(23.4), fan_speed: Some(3), light: Some(false), ..DeviceCommand::default() }; let changed = command.changed_from(&device); assert_eq!(changed.power, None); assert_eq!(changed.mode, None); assert_eq!(changed.target_temperature, None); assert_eq!(changed.fan_speed, Some(3)); assert_eq!(changed.light, Some(false)); } #[test] fn combined_temperature_prefers_room_sensor_weight() { let zone = test_zone("combined"); let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0)); assert_eq!(value, Some(21.2)); assert_eq!(source, "combined"); assert!(!discrepancy); } #[test] fn combined_temperature_falls_back_on_large_discrepancy() { let zone = test_zone("combined"); let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0)); assert_eq!(value, Some(21.0)); assert_eq!(source, "device_discrepancy_fallback"); assert!(discrepancy); } #[test] fn combined_temperature_falls_back_when_external_is_missing() { let zone = test_zone("combined"); let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None); assert_eq!(value, Some(21.5)); assert_eq!(source, "device_fallback"); assert!(!discrepancy); } #[test] fn seasonal_profiles_resolve_independently() { let zone = test_zone("device"); assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0); assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5); assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0); assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0); } #[test] fn quick_setpoint_keeps_active_preset() { let mut zone = test_zone("device"); zone.manual_setpoint = Some(22.5); let (preset, target) = resolve_zone_target(&zone, None, "cool"); assert_eq!(preset, "comfort"); assert_eq!(target, 22.5); } #[test] fn automatic_thermostat_without_active_intent_stays_idle() { let zone = test_zone("device"); assert!(!zone_has_thermostat_intent_source(&zone, None, Utc::now())); assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now())); } #[test] fn explicit_thermostat_sources_activate_control_without_schedule() { let mut zone = test_zone("device"); zone.manual_setpoint = Some(22.0); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); zone.manual_setpoint = None; zone.manual_preset = Some("sleep".into()); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); zone.manual_preset = None; zone.local_thermostat_power = Some(true); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); zone.local_thermostat_power = None; zone.control_source = "group:upstairs".into(); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); zone.control_source = "automation.device".into(); refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "automation"); assert_eq!(zone.control_source, "automation.device"); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); zone.control_source = "automation.flow".into(); refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "automation"); assert_eq!(zone.control_source, "automation.flow"); assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now())); } #[test] fn local_off_blocks_even_an_active_schedule_until_resume() { let mut zone = test_zone("device"); let schedule = test_schedule("active", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00"); zone.local_thermostat_power = Some(false); assert!(zone_has_thermostat_intent_source( &zone, Some(&schedule), Utc::now() )); assert!(!zone_has_active_thermostat_intent( &zone, Some(&schedule), Utc::now() )); } #[test] fn runtime_target_refresh_has_no_implicit_comfort_activation() { let mut zone = test_zone("device"); zone.inherit_house_mode = false; zone.mode = "cool".into(); zone.effective_setpoint = Some(23.0); refresh_zone_runtime_target(&mut zone, &[], "cool"); assert_eq!(zone.effective_mode, "off"); assert_eq!(zone.effective_setpoint, None); } #[test] fn resuming_local_off_without_schedule_stays_off() { let mut zone = test_zone("device"); zone.inherit_house_mode = false; zone.mode = "cool".into(); zone.demand = true; set_local_thermostat_power(&mut zone, false, Utc::now()); assert!(reset_local_thermostat_override(&mut zone)); refresh_control_ownership(&mut zone); refresh_zone_runtime_target(&mut zone, &[], "cool"); assert!(zone.local_thermostat_power.is_none()); assert_eq!(zone.control_source, "automation"); assert_eq!(zone.effective_mode, "off"); assert_eq!(zone.effective_setpoint, None); assert!(!zone.demand); assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now())); } #[test] fn runtime_target_refresh_applies_manual_profile_immediately() { let mut zone = test_zone("device"); zone.inherit_house_mode = false; zone.mode = "cool".into(); zone.manual_preset = Some("sleep".into()); zone.active_preset = "comfort".into(); zone.effective_setpoint = Some(25.0); refresh_zone_runtime_target(&mut zone, &[], "cool"); assert_eq!(zone.active_preset, "sleep"); assert_eq!(zone.effective_setpoint, Some(24.5)); assert_eq!(zone.effective_mode, "cool"); } #[test] fn legacy_zone_keeps_old_comfort_setpoint() { let mut zone = test_zone("device"); zone.profile_version = 0; zone.setpoint = 22.5; assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5); assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5); } #[test] fn device_setpoint_rounding_preserves_control_direction() { assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0); assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0); assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0); assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0); } #[test] fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() { assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 1.0); assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 1.0); assert_eq!( external_room_sensor_cooling_assist("cool", "device_fallback"), 0.0 ); assert_eq!( external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), 0.0 ); assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0); } #[test] fn external_room_sensor_cooling_keeps_full_step_below_half_degree_target() { let assist = external_room_sensor_cooling_assist("cool", "external"); assert_eq!(round_device_setpoint("cool", true, 23.0 - assist), 22.0); assert_eq!(round_device_setpoint("cool", true, 23.5 - assist), 22.0); assert_eq!(round_device_setpoint("cool", true, 24.0 - assist), 23.0); assert_eq!(round_device_setpoint("cool", true, 24.5 - assist), 23.0); } #[test] fn smart_fan_uses_low_speed_when_zone_is_satisfied() { assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1); assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1); } #[test] fn smart_quiet_follows_satisfied_transition_only_when_supported() { assert_eq!( smart_quiet_command(true, true, true, false, false, false, false, true), Some(true) ); assert_eq!( smart_quiet_command(true, true, false, false, false, false, false, true), None ); assert_eq!( smart_quiet_command(true, true, false, false, true, false, false, true), None ); assert_eq!( smart_quiet_command(true, true, false, true, true, false, false, true), Some(false) ); assert_eq!( smart_quiet_command(true, true, true, true, true, false, false, true), None ); assert_eq!( smart_quiet_command(true, false, true, false, false, false, false, true), None ); assert_eq!( smart_quiet_command(false, true, true, false, false, false, false, true), None ); } #[test] fn night_mode_handles_midnight_and_limits_auto_fan() { let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true, }; assert!(night_mode_active( &settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap() )); assert!(night_mode_active( &settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap() )); assert!(!night_mode_active( &settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap() )); assert_eq!(night_limited_fan_speed(0, 1), 1); assert_eq!(night_limited_fan_speed(3, 1), 1); assert_eq!( smart_quiet_command(false, true, true, true, false, true, true, true), Some(true) ); assert_eq!( smart_quiet_command(false, true, true, true, true, true, false, true), Some(false) ); assert_eq!( smart_quiet_command(true, true, false, false, true, true, false, true), Some(false) ); assert_eq!( native_sleep_command(true, true, true, true, false), Some(true) ); assert_eq!( native_sleep_command(true, false, true, true, true), Some(false) ); assert_eq!( native_sleep_command(false, false, false, true, true), Some(false) ); assert_eq!(native_sleep_command(true, true, true, false, false), None); } #[test] fn gree_outdoor_fallback_uses_median_of_online_units() { let mut a = Device::simulated_default(); a.outdoor_temperature = Some(10.0); let mut b = Device::simulated_default(); b.id = "sim-b".into(); b.outdoor_temperature = Some(12.0); let mut c = Device::simulated_default(); c.id = "sim-c".into(); c.outdoor_temperature = Some(40.0); assert_eq!(gree_outdoor_temperature(&[a, b, c], &[]), Some(12.0)); } #[test] fn gree_outdoor_fallback_counts_multisplit_once() { let mut a = Device::simulated_default(); a.id = "a".into(); a.outdoor_temperature = Some(10.0); let mut b = Device::simulated_default(); b.id = "b".into(); b.outdoor_temperature = Some(10.0); let mut c = Device::simulated_default(); c.id = "c".into(); c.outdoor_temperature = Some(30.0); let now = Utc::now(); let group = DeviceGroup { id: "multi".into(), name: "Multi".into(), kind: crate::models::DeviceGroupKind::Multisplit, device_ids: vec!["a".into(), "b".into()], energy_source: EnergySourcePreference::Auto, energy_device_id: None, ha_energy_entity_id: None, ha_energy_unit: None, ha_energy_device_class: None, ha_energy_state_class: None, outdoor_temperature_device_id: Some("a".into()), created_at: now, updated_at: now, }; assert_eq!(gree_outdoor_temperature(&[a, b, c], &[group]), Some(20.0)); } #[test] fn global_one_shot_queue_does_not_change_zone_ownership() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); zone.control_source = "web_thermostat".into(); refresh_control_ownership(&mut zone); let owner = zone.control_owner.clone(); let source = zone.control_source.clone(); let until = Utc::now() + chrono::Duration::seconds(180); queue_compressor_action( &mut zone, "global_power_on".into(), until, "minimum_off_before_global_start", ); assert_eq!(zone.control_owner, owner); assert_eq!(zone.control_source, source); assert_eq!( zone.compressor_pending_action.as_deref(), Some("global_power_on") ); } #[test] fn compressor_queue_helpers_track_and_clear_pending_intent() { let mut zone = test_zone("device"); let until = Utc::now() + chrono::Duration::seconds(180); let action = compressor_action_id("power_on", "cool", 23.04); assert_eq!(action, "power_on:cool:23.0"); queue_compressor_action( &mut zone, action.clone(), until.clone(), "minimum_off_before_start", ); assert_eq!( zone.compressor_pending_action.as_deref(), Some(action.as_str()) ); assert!(zone.compressor_pending_since.is_some()); assert_eq!(zone.compressor_pending_until, Some(until.clone())); assert_eq!(zone.lockout_until, Some(until)); assert_eq!( zone.lockout_reason.as_deref(), Some("minimum_off_before_start") ); zone.compressor_cancelled_action = Some(action.clone()); clear_compressor_pending(&mut zone, false); assert!(zone.compressor_pending_action.is_none()); assert_eq!( zone.compressor_cancelled_action.as_deref(), Some(action.as_str()) ); rearm_compressor_queue(&mut zone); assert!(zone.compressor_cancelled_action.is_none()); assert!(zone.lockout_until.is_none()); assert!(zone.lockout_reason.is_none()); } #[test] fn outdoor_assist_is_bounded_and_direction_neutral() { let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0); let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0); assert!(cool > 0.0 && cool <= 1.0); assert!(heat > 0.0 && heat <= 1.0); assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0); } #[test] fn whole_house_off_is_indefinite_until_explicit_reenable() { let mut zone = test_zone("device"); zone.local_thermostat_power = Some(true); zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15)); zone.demand = true; zone.demand_since = Some(Utc::now()); assert!(set_house_bulk_thermostat_power(&mut zone, false)); assert_eq!(zone.local_thermostat_power, Some(false)); assert!(zone.local_thermostat_resume_at.is_none()); assert!(zone.local_thermostat_restore_zone_enabled.is_none()); assert!(!zone.demand); assert!(zone.demand_since.is_none()); assert_eq!(zone.effective_mode, "off"); // A normal cycle must see an indefinite local OFF. Global/local/group ON releases it. assert!(set_house_bulk_thermostat_power(&mut zone, true)); assert!(zone.local_thermostat_power.is_none()); assert!(zone.local_thermostat_resume_at.is_none()); assert_eq!(zone.control_source, "house_power"); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); } #[test] fn whole_house_on_creates_automatic_intent_without_local_ownership() { let mut zone = test_zone("device"); zone.local_thermostat_power = None; assert!(set_house_bulk_thermostat_power(&mut zone, true)); assert!(zone.local_thermostat_power.is_none()); assert_eq!(zone.control_source, "house_power"); refresh_control_ownership(&mut zone); assert_eq!(zone.control_owner, "automation"); assert_eq!(zone.control_source, "house_power"); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); } }