Files
gree-controller/src/engine/tests.rs
T
2026-09-01 13:05:44 +02:00

729 lines
34 KiB
Rust

#[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<u32>, 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, 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 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 { light: Some(false), turbo: Some(true), ..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 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<Utc>) -> 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 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);
}
}