729 lines
34 KiB
Rust
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);
|
|
}
|
|
|
|
}
|