This commit is contained in:
fuenmao
2026-11-12 15:45:52 +08:00
parent 61e25175db
commit 718b138033
30 changed files with 1511 additions and 99 deletions
@@ -3,6 +3,7 @@ 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;
@@ -16,11 +17,25 @@ namespace Admin.NET.Test.Health;
public sealed class ClimateCurtainIntegrationTests
{
private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain";
private static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId } };
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()
{
@@ -43,6 +58,64 @@ public sealed class ClimateCurtainIntegrationTests
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)]
@@ -383,9 +456,12 @@ public sealed class ClimateCurtainIntegrationTests
public int Reads { get; private set; }
public bool ApplyCommands { get; set; } = true;
public bool BecomeUnavailableAfterSend { get; set; }
public string? ModeAfterTemperature { get; set; }
public Action<int>? BeforeRead { get; set; }
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested(); Reads++;
BeforeRead?.Invoke(Reads);
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
}
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
@@ -397,7 +473,14 @@ public sealed class ClimateCurtainIntegrationTests
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"]!, "turn_on" => "heat", "turn_off" => "off", _ => previous.State };
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,