Files
gree-controller/src/engine/tests.rs
T
2026-10-17 20:00:28 +02:00

1759 lines
64 KiB
Rust

#[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<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(),
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::<Vec<_>>();
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::<Vec<_>>();
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<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 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()));
}
}