#[cfg(test)] mod tests { use super::*; use chrono::TimeZone; #[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 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(), }; 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(), } } #[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 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, 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, standby_offset_c: 2.0, smart_fan: true, sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), 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, 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 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 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 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 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 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 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); } }