using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Devices.Curtain; using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Scenes; using Microsoft.Extensions.Options; using System.Text.Json; using Xunit; namespace Admin.NET.Test.Health; /// 只使用内存网关,不启动后端、不连接 HA、不触碰实际设备。 public sealed class ClimateCurtainIntegrationTests { private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain"; private const string RoomTemperatureId = "sensor.test_room_temperature"; private static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId, Temperature = RoomTemperatureId } }; private static DeviceActionRunner Runner(Gateway gateway) => new(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 }); private static ClimateModule Climate(Gateway gateway) => new(Runner(gateway), Options.Create(Settings())); private static CurtainModule Curtain(Gateway gateway) => new(Runner(gateway), Options.Create(Settings())); private static BedPresenceClimateHeatAction HeatAutomation(Gateway gateway) { var runner = Runner(gateway); var options = Options.Create(Settings()); return new(new ClimateModule(runner, options), new IndoorEnvironmentReader(options, TimeProvider.System), runner, options); } private static HomeAssistantState RoomTemperature(string celsius = "20", int ageMinutes = 0) => new() { EntityId = RoomTemperatureId, State = celsius, LastReported = DateTimeOffset.UtcNow.AddMinutes(-ageMinutes), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) }; private static HomeAssistantState ClimateState(string state = "off", int features = 393, double minimum = 5, double maximum = 35, double step = .5) => new() { EntityId = ClimateId, State = state, Attributes = JsonSerializer.SerializeToElement(new Dictionary { ["hvac_modes"] = new[] { "cool", "heat", "fan_only", "off" }, ["supported_features"] = features, ["temperature"] = 23m, ["current_temperature"] = 24.1m, ["temperature_unit"] = "°C", ["min_temp"] = minimum, ["max_temp"] = maximum, ["target_temp_step"] = step, ["fan_modes"] = new[] { "自动", "低风", "中风", "高风" }, ["fan_mode"] = "低风" }) }; private static HomeAssistantState CurtainState(string state = "open", int features = 15) => new() { EntityId = CurtainId, State = state, Attributes = JsonSerializer.SerializeToElement(new { supported_features = features, current_position = 40, device_class = "curtain" }) }; private static PersonalizedSceneConfig Scene(string mode = "heat", int target = 24) => new() { ClimateAdvice = new() { Mode = mode, RecommendedMinimumCelsius = 22, RecommendedMaximumCelsius = 25, TargetTemperatureCelsius = target, Reason = "测试室温建议" } }; [Fact] public async Task Bed_presence_heat_sends_each_command_once_and_confirms_heat_and_22_degrees() { var gateway = new Gateway(ClimateState(), RoomTemperature()); var action = HeatAutomation(gateway); Assert.Equal("target-not-configured", await action.ExecuteAsync("climate.other", default)); Assert.Equal("readback-confirmed", await action.ExecuteAsync(ClimateId, default)); Assert.Collection(gateway.Calls, mode => { Assert.Equal("set_hvac_mode", mode.Service); Assert.Equal("heat", mode.Data!["hvac_mode"]); }, temperature => { Assert.Equal("set_temperature", temperature.Service); Assert.Equal(22m, temperature.Data!["temperature"]); }); Assert.Equal("heat", gateway.States[ClimateId].State); Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature")); } [Theory] [InlineData("22", 0, "off", 5, "temperature-not-low")] [InlineData("27", 0, "off", 5, "temperature-not-low")] [InlineData("20", 31, "off", 5, "temperature-stale")] [InlineData("NaN", 0, "off", 5, "temperature-invalid")] [InlineData(null, 0, "off", 5, "temperature-unavailable")] [InlineData("20", 0, "cool", 5, "climate-not-off")] [InlineData("20", 0, "unavailable", 5, "climate-unavailable")] [InlineData("20", 0, "off", 24, "climate-heat-unsupported")] public async Task Bed_presence_heat_checks_room_power_and_target_before_sending( string? roomTemperature, int ageMinutes, string climateState, int minimum, string result) { var gateway = new Gateway(ClimateState(climateState, minimum: minimum)); if (roomTemperature is not null) gateway.States[RoomTemperatureId] = RoomTemperature(roomTemperature, ageMinutes); Assert.Equal(result, await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); Assert.Empty(gateway.Calls); } [Fact] public async Task Bed_presence_heat_leaves_the_temperature_alone_when_heat_mode_cannot_be_confirmed() { var gateway = new Gateway(ClimateState(), RoomTemperature()) { ApplyCommands = false }; Assert.Equal("climate-heat-mode-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service); } [Fact] public async Task Bed_presence_heat_does_not_set_temperature_after_the_user_changes_the_mode() { var gateway = new Gateway(ClimateState(), RoomTemperature()); gateway.BeforeRead = read => { if (read == 3) gateway.States[ClimateId] = ClimateState("off"); }; Assert.Equal("climate-heat-state-changed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service); } [Fact] public async Task Bed_presence_heat_requires_both_mode_and_target_in_the_final_readback() { var gateway = new Gateway(ClimateState(), RoomTemperature()) { ModeAfterTemperature = "off" }; Assert.Equal("climate-heat-target-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default)); Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature")); Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service)); } [Theory] [InlineData(5)] [InlineData(23.5)] [InlineData(35)] public async Task TemperatureUsesActualBoundsHalfDegreeAndAttributeReadback(double value) { var gateway = new Gateway(ClimateState()); var module = Climate(gateway); await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_temperature", value), default); Assert.Equal("off", gateway.States[ClimateId].State); // 顶层不是温度,但属性读回成功。 Assert.Equal(value, gateway.States[ClimateId].GetDoubleAttribute("temperature")); Assert.Equal("set_temperature", Assert.Single(gateway.Calls).Service); } [Theory] [InlineData(4.5)] [InlineData(35.5)] [InlineData(23.25)] public async Task InvalidTemperatureNeverSends(double value) { var gateway = new Gateway(ClimateState()); var module = Climate(gateway); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_temperature", value), default)); Assert.Empty(gateway.Calls); } [Theory] [InlineData("NaN")] [InlineData("Infinity")] [InlineData("not-a-number")] public void NonfiniteOrMalformedTemperatureIsRejected(string value) => Assert.Throws(() => Climate(new(ClimateState())) .BuildParameterCommand("climate_temperature", value)); [Fact] public async Task ActualModeAndFanOptionsAreValidatedAndReadBack() { var gateway = new Gateway(ClimateState()); var module = Climate(gateway); await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_mode", "cool"), default); await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_fan_mode", "中风"), default); Assert.Equal("cool", gateway.States[ClimateId].State); Assert.Equal("中风", gateway.States[ClimateId].GetStringAttribute("fan_mode")); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_mode", "auto"), default)); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_fan_mode", "超强风"), default)); Assert.Equal(2, gateway.Calls.Count); } [Fact] public async Task FreshCapabilityValidationCannotBeBypassedBySendingCommandDirectly() { var gateway = new Gateway(ClimateState()); var runner = Runner(gateway); var module = new ClimateModule(runner, Options.Create(Settings())); var command = module.BuildParameterCommand("climate_temperature", 25); gateway.States[ClimateId] = ClimateState(features: 392); // 构造命令后设备失去目标温度能力。 await Assert.ThrowsAsync(() => runner.SendAsync(command, default)); Assert.Empty(gateway.Calls); } [Fact] public async Task MissingTemperatureCapabilitiesAndWrongUnitDoNotUseGuessedDefaults() { var gateway = new Gateway(ClimateState()); var module = Climate(gateway); foreach (var attribute in new[] { "min_temp", "max_temp", "target_temp_step", "temperature_unit" }) { var attributes = ClimateState().Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone()); attributes.Remove(attribute); gateway.States[ClimateId] = new() { EntityId = ClimateId, State = "off", Attributes = JsonSerializer.SerializeToElement(attributes) }; await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_temperature", 24), default)); } Assert.Empty(gateway.Calls); } [Fact] public async Task UnchangedAttributeFailsConfirmationWithoutRepeatedWrites() { var gateway = new Gateway(ClimateState()) { ApplyCommands = false }; var module = Climate(gateway); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_temperature", 25), default)); Assert.Single(gateway.Calls); Assert.Equal(4, gateway.Reads); // 发送前能力校验一次,随后三轮只读确认。 } [Fact] public async Task UnavailableEntityCannotConfirmEvenIfCachedAttributeMatches() { var gateway = new Gateway(ClimateState()) { BecomeUnavailableAfterSend = true }; var module = Climate(gateway); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_temperature", 24), default)); Assert.Single(gateway.Calls); } [Theory] [InlineData("unavailable")] [InlineData("unknown")] public async Task OfflineClimateDoesNotSendEvenWhenModeIsKnown(string state) { var gateway = new Gateway(ClimateState(state)); var module = Climate(gateway); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("climate_mode", "heat"), default)); Assert.Empty(gateway.Calls); } [Fact] public async Task SceneUsesPreparedTargetAndConfirmsBothModeAndTemperature() { var gateway = new Gateway(ClimateState()); var module = Climate(gateway); var config = Scene(); var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); await module.ExecuteSleepAsync(context, config, "balanced", default); Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius); Assert.Equal("heat", gateway.States[ClimateId].State); Assert.Equal(24, gateway.States[ClimateId].GetDoubleAttribute("temperature")); Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service)); Assert.All(context.Results, result => Assert.Equal("Succeeded", result.Status)); } [Fact] public async Task SceneDeterministicallyFitsPreparedTargetToActualIntersection() { var gateway = new Gateway(ClimateState(minimum: 24, maximum: 25)); var module = Climate(gateway); var config = Scene(target: 22); var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); await module.ExecuteSleepAsync(context, config, "balanced", default); Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius); await module.ExecuteSleepAsync(context, config, "balanced", default); Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius); } [Theory] [InlineData("off")] [InlineData("unavailable")] public async Task ComfortableOrUnknownRoomTemperatureLeavesExistingClimateUntouched(string mode) { var gateway = new Gateway(ClimateState("cool")); var module = Climate(gateway); var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); await module.ExecuteSleepAsync(context, Scene(mode), "balanced", default); Assert.Empty(gateway.Calls); Assert.Equal("cool", gateway.States[ClimateId].State); Assert.Equal("Skipped", Assert.Single(context.Results).Status); } [Fact] public async Task SceneNeverStartsWhenRecommendationAndDeviceHaveNoTemperatureIntersection() { var gateway = new Gateway(ClimateState(minimum: 30)); var module = Climate(gateway); var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); await Assert.ThrowsAsync(() => module.ExecuteSleepAsync(context, Scene(), "balanced", default)); Assert.Empty(gateway.Calls); } [Fact] public async Task UnsupportedSuggestedModeIsFailureNotFakeSuccessfulScene() { var gateway = new Gateway(ClimateState()); var attributes = gateway.States[ClimateId].Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone()); attributes["hvac_modes"] = new[] { "cool", "off" }; gateway.States[ClimateId].Attributes = JsonSerializer.SerializeToElement(attributes); var runner = Runner(gateway); var coordinator = new HealthSceneCoordinator(new([Climate(gateway)]), runner, gateway, new FakeLight(), new FakeMusic()); var result = await coordinator.ExecuteAsync("sleep", Scene(), "balanced", default); Assert.Equal("Failed", result.Status); Assert.Contains(result.Actions, action => action.Status == "Failed" && action.Message.Contains("不支持")); Assert.Empty(gateway.Calls); } [Fact] public async Task SceneStopsBeforeTemperatureWriteIfModeWasNotConfirmed() { var gateway = new Gateway(ClimateState()) { ApplyCommands = false }; var module = Climate(gateway); var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); await module.ExecuteSleepAsync(context, Scene(), "balanced", default); Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service); Assert.Equal("Failed", Assert.Single(context.Results).Status); } [Fact] public async Task AllOffClosesClimateButDoesNotMoveCurtain() { var gateway = new Gateway(ClimateState("heat"), CurtainState()); var runner = Runner(gateway); var modules = new DeviceModuleRegistry([Climate(gateway), Curtain(gateway)]); var coordinator = new HealthSceneCoordinator(modules, runner, gateway, new FakeLight(), new FakeMusic()); var output = await coordinator.ExecuteAsync("all-off", null, "", default); Assert.Equal("Succeeded", output.Status); Assert.Equal("off", gateway.States[ClimateId].State); Assert.Equal(ClimateId, Assert.Single(gateway.Calls).EntityId); Assert.Equal("turn_off", gateway.Calls[0].Service); } [Fact] public async Task UnsupportedPowerFeaturesRejectManualPowerCommands() { var gateway = new Gateway(ClimateState(features: 1)); var module = Climate(gateway); await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default)); await Assert.ThrowsAsync(() => module.SetPowerAsync(false, default)); Assert.Empty(gateway.Calls); } [Theory] [InlineData("open", "open_cover")] [InlineData("stop", "stop_cover")] [InlineData("close", "close_cover")] public async Task CurtainSendsOneSupportedMotionCommandWithoutClaimingArrival(string action, string service) { var gateway = new Gateway(CurtainState()); var module = Curtain(gateway); var command = module.BuildParameterCommand("curtain_action", action); await module.ExecuteParameterAsync(command, default); Assert.Null(command.ExpectedState); Assert.Equal(service, Assert.Single(gateway.Calls).Service); Assert.Equal(1, gateway.Reads); } [Fact] public async Task CurtainPositionIsIntegerPercentAndRequiresPositionFeature() { var gateway = new Gateway(CurtainState()); var module = Curtain(gateway); await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 0), default); await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 100), default); Assert.Equal(new[] { 0, 100 }, gateway.Calls.Select(command => (int)command.Data!["position"]!)); Assert.Throws(() => module.BuildParameterCommand("curtain_position", 100.5)); gateway.States[CurtainId] = CurtainState(features: 11); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("curtain_position", 50), default)); Assert.Equal(2, gateway.Calls.Count); } [Theory] [InlineData("unavailable")] [InlineData("unknown")] public async Task OfflineCurtainRejectsCommandsAndCannotPretendOpen(string state) { var gateway = new Gateway(CurtainState(state)); var module = Curtain(gateway); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("curtain_action", "open"), default)); Assert.Empty(gateway.Calls); } [Fact] public async Task CurtainStopIsDisabledWhenDeviceOnlySupportsOpenAndClose() { var gateway = new Gateway(CurtainState(features: 3)); var module = Curtain(gateway); await Assert.ThrowsAsync(() => module.ExecuteParameterAsync( module.BuildParameterCommand("curtain_action", "stop"), default)); Assert.Empty(gateway.Calls); } [Fact] public async Task CurtainHasNoPowerAndNoAutomaticSceneMotion() { var gateway = new Gateway(CurtainState()); var module = Curtain(gateway); await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default)); var context = new DeviceSceneContext(Runner(gateway), gateway.States, []); await module.ExecuteSleepAsync(context, Scene(), "balanced", default); Assert.Equal("Skipped", Assert.Single(context.Results).Status); Assert.Empty(module.GetShutdownCommands()); Assert.Empty(gateway.Calls); } [Fact] public async Task UnconfiguredModulesRemainSkippedAndAbsentFromFallbackDevices() { var gateway = new Gateway(); var runner = Runner(gateway); var options = Options.Create(new HomeAssistantOptions()); var climate = new ClimateModule(runner, options); var curtain = new CurtainModule(runner, options); var context = new DeviceSceneContext(runner, gateway.States, []); await climate.ExecuteSleepAsync(context, Scene(), "balanced", default); await curtain.ExecuteSleepAsync(context, Scene(), "balanced", default); Assert.All(context.Results, result => Assert.Equal("Skipped", result.Status)); Assert.Empty(climate.GetShutdownCommands()); Assert.Empty(new HomeAssistantDeviceCatalog(options, new([climate, curtain])).BuildDiscoveredDevices(null, gateway.States)); Assert.Empty(gateway.Calls); } [Fact] public void RegistryBindsEachDeviceAndSafeAttributesExposeActualCapabilities() { var gateway = new Gateway(ClimateState(), CurtainState("unavailable")); var options = Options.Create(Settings()); var catalog = new HomeAssistantDeviceCatalog(options, new([Climate(gateway), Curtain(gateway)])); var registry = new HomeAssistantRegistries { Devices = [new() { Id = "ac", Name = "空调" }, new() { Id = "curtain", Name = "窗帘" }], Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }, new() { EntityId = CurtainId, DeviceId = "curtain" }] }; var devices = catalog.BuildDiscoveredDevices(registry, gateway.States); Assert.Equal("climate", devices[0].ModuleKey); Assert.Equal(ClimateId, devices[0].PrimaryControlEntityId); Assert.Equal("curtain", devices[1].ModuleKey); Assert.Equal(CurtainId, devices[1].PrimaryControlEntityId); Assert.False(devices[1].Online); Assert.Contains("target_temp_step", devices[0].Entities[0].Attributes!.Keys); Assert.Contains("current_temperature", devices[0].Entities[0].Attributes!.Keys); Assert.Contains("fan_modes", devices[0].Entities[0].Attributes!.Keys); Assert.Contains("current_position", devices[1].Entities[0].Attributes!.Keys); } [Fact] public async Task CancellationBeforeSendPreventsEveryNewDeviceWrite() { var gateway = new Gateway(ClimateState(), CurtainState()); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var climate = Climate(gateway); var curtain = Curtain(gateway); await Assert.ThrowsAnyAsync(() => climate.ExecuteParameterAsync( climate.BuildParameterCommand("climate_mode", "heat"), cancellation.Token)); await Assert.ThrowsAnyAsync(() => curtain.ExecuteParameterAsync( curtain.BuildParameterCommand("curtain_action", "open"), cancellation.Token)); Assert.Empty(gateway.Calls); } private sealed class Gateway(params HomeAssistantState[] states) : IHomeAssistantGateway { public Dictionary States { get; } = states.ToDictionary(state => state.EntityId, StringComparer.OrdinalIgnoreCase); public List Calls { get; } = []; public int Reads { get; private set; } public bool ApplyCommands { get; set; } = true; public bool BecomeUnavailableAfterSend { get; set; } public string? ModeAfterTemperature { get; set; } public Action? BeforeRead { get; set; } public Task> GetStatesAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Reads++; BeforeRead?.Invoke(Reads); return Task.FromResult>(States.Values.ToArray()); } public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var entityId = (string)data["entity_id"]!; Calls.Add(new(service, entityId, domain, service, new Dictionary(data))); if (!ApplyCommands) return Task.CompletedTask; var previous = States[entityId]; var attributes = previous.Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone()); var current = service switch { "set_hvac_mode" => (string)data["hvac_mode"]!, "set_temperature" when ModeAfterTemperature is not null => ModeAfterTemperature, "turn_on" => "heat", "turn_off" => "off", _ => previous.State }; if (data.TryGetValue("temperature", out var temperature)) attributes["temperature"] = temperature; if (data.TryGetValue("fan_mode", out var fan)) attributes["fan_mode"] = fan; States[entityId] = new() { EntityId = entityId, State = BecomeUnavailableAfterSend ? "unavailable" : current, Attributes = JsonSerializer.SerializeToElement(attributes) }; return Task.CompletedTask; } } }