1
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class ClimateAdviceBindingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("", "15", "heat", true)]
|
||||
[InlineData("climate.room", "15", "heat", false)]
|
||||
[InlineData("climate.room", "28", "cool", false)]
|
||||
[InlineData("", "24", "off", false)]
|
||||
public void AdviceUsesBindingNotPowerStateToReportPendingIntegration(
|
||||
string climate, string temperature, string mode, bool pending)
|
||||
{
|
||||
var options = Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
Entities = new() { Climate = climate, Temperature = "sensor.room" }
|
||||
});
|
||||
var gateway = new FakeHaGateway();
|
||||
var states = new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["sensor.room"] = new()
|
||||
{
|
||||
EntityId = "sensor.room", State = temperature,
|
||||
LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
}
|
||||
};
|
||||
var service = new MattressComfortService(gateway,
|
||||
new IndoorEnvironmentReader(options, TimeProvider.System), options);
|
||||
var output = service.Get(null, states);
|
||||
Assert.Equal(mode, output.ClimateAdvice.Mode);
|
||||
Assert.Equal(pending, output.ClimateAdvice.PendingIntegration);
|
||||
Assert.Null(output.ClimateAdvice.TargetTemperatureCelsius);
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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.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;
|
||||
|
||||
/// <summary>只使用内存网关,不启动后端、不连接 HA、不触碰实际设备。</summary>
|
||||
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 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 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<string, object?>
|
||||
{
|
||||
["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 = "测试室温建议" }
|
||||
};
|
||||
|
||||
[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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => module.ExecuteParameterAsync(
|
||||
module.BuildParameterCommand("climate_mode", "auto"), default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => module.BuildParameterCommand("curtain_position", 100.5));
|
||||
gateway.States[CurtainId] = CurtainState(features: 11);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<OperationCanceledException>(() => climate.ExecuteParameterAsync(
|
||||
climate.BuildParameterCommand("climate_mode", "heat"), cancellation.Token));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => curtain.ExecuteParameterAsync(
|
||||
curtain.BuildParameterCommand("curtain_action", "open"), cancellation.Token));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
private sealed class Gateway(params HomeAssistantState[] states) : IHomeAssistantGateway
|
||||
{
|
||||
public Dictionary<string, HomeAssistantState> States { get; } = states.ToDictionary(state => state.EntityId, StringComparer.OrdinalIgnoreCase);
|
||||
public List<DeviceCommand> Calls { get; } = [];
|
||||
public int Reads { get; private set; }
|
||||
public bool ApplyCommands { get; set; } = true;
|
||||
public bool BecomeUnavailableAfterSend { get; set; }
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested(); Reads++;
|
||||
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
|
||||
}
|
||||
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var entityId = (string)data["entity_id"]!;
|
||||
Calls.Add(new(service, entityId, domain, service, new Dictionary<string, object?>(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"]!, "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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Audio;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class DeviceArchitectureTests
|
||||
{
|
||||
private static DeviceActionRunner Runner(FakeHaGateway gateway)
|
||||
=> new(gateway, TimeProvider.System, new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero });
|
||||
private static HomeAssistantOptions Configuration() => new()
|
||||
{
|
||||
Entities = new()
|
||||
{
|
||||
AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep",
|
||||
Temperature = "sensor.room_temperature", MattressLeftMode = "select.left_mode", MattressRightMode = "select.right_mode",
|
||||
MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat",
|
||||
MattressLeftMassagePart = "select.left_massage", MattressRightMassagePart = "select.right_massage",
|
||||
MattressMassageLevel = "select.massage_level", MattressFirmness = new() { ["left_shoulder"] = "select.left_shoulder" }
|
||||
}, SleepScene = new() { EnableMattress = true }
|
||||
};
|
||||
private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new()
|
||||
{
|
||||
EntityId = "sensor.room_temperature", State = value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastReported = observed, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(18.4, "很软")]
|
||||
[InlineData(18.5, "稍软")]
|
||||
[InlineData(24.0, "稍硬")]
|
||||
[InlineData(28.0, "很硬")]
|
||||
[InlineData(31.1, "很硬")]
|
||||
[InlineData(32.0, "最硬")]
|
||||
public void BmiUsesStableFirmnessBoundaries(double bmi, string expected)
|
||||
=> Assert.Equal(expected, BmiFirmnessPolicy.Firmness((decimal)bmi));
|
||||
|
||||
[Fact]
|
||||
public void MissingOrInvalidBodyDataNeverProducesFirmness()
|
||||
{
|
||||
Assert.Null(BmiFirmnessPolicy.Calculate(null, 80));
|
||||
Assert.Null(BmiFirmnessPolicy.Calculate(0, 80));
|
||||
Assert.Null(BmiFirmnessPolicy.Firmness(null));
|
||||
Assert.Equal(31.1m, BmiFirmnessPolicy.Calculate(180, 100.8m));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(21.9, "heat")]
|
||||
[InlineData(22.0, "off")]
|
||||
[InlineData(23.5, "off")]
|
||||
[InlineData(25.0, "off")]
|
||||
[InlineData(25.1, "cool")]
|
||||
public void RoomRangeIncludesBothBoundaries(double temperature, string expected)
|
||||
{
|
||||
var advice = RoomTemperaturePolicy.Evaluate(new(temperature, DateTimeOffset.UtcNow, true, ""), new(22, 25));
|
||||
Assert.Equal(expected, advice.Mode);
|
||||
Assert.Null(advice.TargetTemperatureCelsius);
|
||||
RoomTemperaturePolicy.PickTargetForSession(advice);
|
||||
if (expected is "heat" or "cool")
|
||||
Assert.InRange(advice.TargetTemperatureCelsius!.Value, 22, 25);
|
||||
else Assert.Null(advice.TargetTemperatureCelsius);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(31)]
|
||||
public void MissingOrStaleIndoorTimestampIsNotUsable(int ageMinutes)
|
||||
{
|
||||
var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
|
||||
var state = Temperature(10, ageMinutes == 0 ? null : DateTimeOffset.UtcNow.AddMinutes(-ageMinutes));
|
||||
var sample = reader.Read(new Dictionary<string, HomeAssistantState> { [state.EntityId] = state });
|
||||
Assert.False(sample.Available);
|
||||
Assert.Equal("unavailable", RoomTemperaturePolicy.Evaluate(sample, new(22, 25)).Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshIndoorDataAndConstitutionRangeAreUsed()
|
||||
{
|
||||
var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
|
||||
var state = Temperature(23, DateTimeOffset.UtcNow);
|
||||
var sample = reader.Read(new Dictionary<string, HomeAssistantState> { [state.EntityId] = state });
|
||||
Assert.True(sample.Available);
|
||||
Assert.Equal("heat", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("yang_deficient")).Mode);
|
||||
Assert.Equal("off", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("balanced")).Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewDeviceWorksThroughRegistryAndSceneWithoutCoreBranches()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
|
||||
var runner = Runner(gateway);
|
||||
var module = new SyntheticDeviceModule(runner);
|
||||
var registry = new DeviceModuleRegistry([module]);
|
||||
Assert.Same(module, registry.ForParameter("synthetic_value"));
|
||||
await registry.ForParameter("synthetic_value").ExecuteParameterAsync(module.BuildParameterCommand("synthetic_value", 4), default);
|
||||
var coordinator = new HealthSceneCoordinator(registry, runner, gateway, new FakeLight(), new FakeMusic());
|
||||
var output = await coordinator.ExecuteAsync("sleep", new(), "balanced", default);
|
||||
Assert.Equal("Succeeded", output.Status);
|
||||
Assert.Contains(gateway.Calls, command => command.EntityId == "number.synthetic" && (int)command.Data!["value"]! == 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateParameterRegistrationsFailEarly()
|
||||
{
|
||||
var runner = Runner(new());
|
||||
Assert.Throws<InvalidOperationException>(() => new DeviceModuleRegistry(
|
||||
[new SyntheticDeviceModule(runner), new SyntheticDeviceModule(runner, key: "other")]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceParametersValidateRangesAndTrustedEntity()
|
||||
{
|
||||
var runner = Runner(new());
|
||||
var light = new LightingModule(runner, Options.Create(Configuration()), new FakeLight(), new LightOffDispatcher([]));
|
||||
Assert.Throws<InvalidOperationException>(() => light.BuildParameterCommand("color_temp", 6500));
|
||||
Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId);
|
||||
Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service);
|
||||
var mattress = new MattressModule(runner, Options.Create(Configuration()));
|
||||
Assert.Equal("全身", mattress.BuildParameterCommand("mattress_massage_right_part", "全身").Data!["option"]);
|
||||
Assert.Equal("轻柔", mattress.BuildParameterCommand("mattress_massage_level", "轻柔").Data!["option"]);
|
||||
Assert.Throws<InvalidOperationException>(() => mattress.BuildParameterCommand("mattress_right_mode", "随意模式"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryAcceptsOtherBrandsAndFiltersDisabledEntities()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var registry = new DeviceModuleRegistry([]);
|
||||
var catalog = new HomeAssistantDeviceCatalog(Options.Create(Configuration()), registry);
|
||||
var haRegistry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "tuya-device", Name = "窗帘", Manufacturer = "Tuya" }],
|
||||
Entities =
|
||||
[
|
||||
new() { DeviceId = "tuya-device", EntityId = "cover.curtain", Platform = "tuya" },
|
||||
new() { DeviceId = "tuya-device", EntityId = "switch.disabled", DisabledBy = "user" }
|
||||
]
|
||||
};
|
||||
var devices = catalog.BuildDiscoveredDevices(haRegistry, new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["cover.curtain"] = new() { EntityId = "cover.curtain", State = "open" }
|
||||
});
|
||||
Assert.Single(devices);
|
||||
Assert.Equal("Curtain", devices[0].Category);
|
||||
Assert.True(devices[0].Online);
|
||||
Assert.Single(devices[0].Entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallbackOnlyContainsConfiguredModulesAndIncludesMattressEntities()
|
||||
{
|
||||
var runner = Runner(new());
|
||||
var options = Options.Create(Configuration());
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
|
||||
var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary<string, HomeAssistantState>()));
|
||||
Assert.Equal("mattress", device.ModuleKey);
|
||||
Assert.Contains(device.Entities, entity => entity.EntityId == "select.right_massage");
|
||||
Assert.Null(device.PrimaryControlEntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownContinuesAfterAnIndividualDeviceFailure()
|
||||
{
|
||||
var gateway = new FakeHaGateway { FailEntity = "switch.bad" };
|
||||
var runner = Runner(gateway);
|
||||
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner, twoOff: true)]),
|
||||
runner, gateway, new FakeLight(), new FakeMusic());
|
||||
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
|
||||
Assert.Equal("Partial", output.Status);
|
||||
Assert.Contains(output.Actions, action => action.EntityId == "switch.bad" && action.Status == "Failed");
|
||||
Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownStillSendsWhenInitialStateReadFails()
|
||||
{
|
||||
var gateway = new FakeHaGateway { ReadsToFail = 1 };
|
||||
var runner = Runner(gateway);
|
||||
var music = new FakeMusic();
|
||||
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]),
|
||||
runner, gateway, new FakeLight(), music);
|
||||
var output = await coordinator.ExecuteAsync("all-off", null, "", default);
|
||||
Assert.True(music.StopCount > 0);
|
||||
Assert.Contains(gateway.Calls, command => command.EntityId == "switch.synthetic" && command.Service == "turn_off");
|
||||
Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllOffCancelsOldStartBeforeItCanSendDelayedCommands()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = Runner(gateway);
|
||||
var module = new SyntheticDeviceModule(runner, block: true);
|
||||
var coordinator = new HealthSceneCoordinator(new([module]), runner, gateway, new FakeLight(), new FakeMusic());
|
||||
var start = coordinator.ExecuteAsync("sleep", new(), "balanced", default);
|
||||
await module.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
var off = await coordinator.ExecuteAsync("all-off", null, "", default);
|
||||
Assert.Equal("Cancelled", (await start).Status);
|
||||
Assert.False(module.ContinuedAfterWait);
|
||||
Assert.Equal("Succeeded", off.Status);
|
||||
Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualLightOffUsesSceneLinkageToStopBothMassageSides()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = Runner(gateway);
|
||||
var options = Options.Create(Configuration());
|
||||
var mattress = new MattressModule(runner, options);
|
||||
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([mattress]));
|
||||
await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
|
||||
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
||||
Assert.Equal("无", gateway.States["select.right_massage"].State);
|
||||
Assert.Equal("无", gateway.States["select.left_massage"].State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownButtonIsUsableButUnknownSensorIsNot()
|
||||
{
|
||||
Assert.True(DeviceActionRunner.IsAvailable(new() { EntityId = "button.release", State = "unknown" }));
|
||||
Assert.False(DeviceActionRunner.IsAvailable(new() { EntityId = "sensor.temperature", State = "unknown" }));
|
||||
Assert.Null(new HomeAssistantState().GetDoubleAttribute("brightness"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModuleRegistrationResolvesWithoutCircularOrScopedDependencies()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(); services.AddOptions(); services.AddHttpClient();
|
||||
services.AddHealthDevices();
|
||||
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
|
||||
Assert.NotNull(provider.GetRequiredService<HealthSceneCoordinator>());
|
||||
Assert.Equal(2, provider.GetServices<IHostedService>().Count());
|
||||
Assert.Contains(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShortPlaylistInheritsFadeVolumeAndStopsAtZero()
|
||||
{
|
||||
var time = new ManualTestTimeProvider();
|
||||
var audio = new FakeAudioGateway();
|
||||
var options = Options.Create(new TcmMusicOptions
|
||||
{
|
||||
StartVolume = 70, FadeStepSeconds = 1, // 本版固定规则不能被历史设置覆盖。
|
||||
Tracks = new() { ["balanced"] =
|
||||
[
|
||||
new() { Title = "A", Url = "https://example.invalid/a.mp3", DurationSeconds = 20 },
|
||||
new() { Title = "B", Url = "https://example.invalid/b.mp3", DurationSeconds = 20 }
|
||||
] }
|
||||
});
|
||||
var music = new TcmMusicTransitionService(audio, options, NullLogger<TcmMusicTransitionService>.Instance, time);
|
||||
await music.StartForConstitutionAsync("balanced", default);
|
||||
Assert.Equal(50, music.GetStatus().VolumePercent);
|
||||
for (var step = 1; step <= 5; step++)
|
||||
{
|
||||
time.Advance(TimeSpan.FromSeconds(5));
|
||||
var expected = 50 - step;
|
||||
await WaitUntilAsync(() => music.GetStatus().VolumePercent == expected);
|
||||
}
|
||||
Assert.Equal(2, audio.Tracks.Count);
|
||||
Assert.Equal(new[] { 50, 49, 48, 47, 46, 45 }, audio.Volumes.ToArray());
|
||||
time.Advance(TimeSpan.FromSeconds(225));
|
||||
await WaitUntilAsync(() => !music.GetStatus().Running);
|
||||
Assert.Equal(0, music.GetStatus().VolumePercent);
|
||||
Assert.True(audio.Stops > 0);
|
||||
await music.StopMusicAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualVolumeTakesOwnershipAndEveryRestartBeginsAtFifty()
|
||||
{
|
||||
var time = new ManualTestTimeProvider();
|
||||
var audio = new FakeAudioGateway();
|
||||
var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
|
||||
{
|
||||
Tracks = new() { ["balanced"] = [new() { Url = "https://example.invalid/a.mp3", DurationSeconds = 20 }] }
|
||||
}), NullLogger<TcmMusicTransitionService>.Instance, time);
|
||||
await music.StartForConstitutionAsync("balanced", default);
|
||||
await music.SetManualVolumeAsync(30, default);
|
||||
time.Advance(TimeSpan.FromSeconds(50));
|
||||
Assert.False(music.GetStatus().Running);
|
||||
Assert.Equal(30, music.GetStatus().VolumePercent);
|
||||
await music.StartForConstitutionAsync("balanced", default);
|
||||
Assert.Equal(50, music.GetStatus().VolumePercent);
|
||||
await music.StopMusicAsync(default);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (!predicate()) await Task.Delay(5, timeout.Token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
internal sealed class FakeHaGateway : IHomeAssistantGateway
|
||||
{
|
||||
public ConcurrentDictionary<string, HomeAssistantState> States { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ConcurrentQueue<DeviceCommand> Calls { get; } = new();
|
||||
public string? FailEntity { get; set; }
|
||||
public int ReadsToFail { get; set; }
|
||||
public TimeProvider Time { get; set; } = TimeProvider.System;
|
||||
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (ReadsToFail > 0) { ReadsToFail--; throw new IOException("模拟状态读取失败"); }
|
||||
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
|
||||
}
|
||||
|
||||
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var entity = (string)data["entity_id"]!;
|
||||
Calls.Enqueue(new(service, entity, domain, service, new Dictionary<string, object?>(data)));
|
||||
if (entity == FailEntity) throw new IOException("模拟设备命令失败");
|
||||
var state = service switch
|
||||
{
|
||||
"turn_on" => "on", "turn_off" => "off",
|
||||
"select_option" => data["option"]!.ToString()!,
|
||||
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
|
||||
};
|
||||
var attributes = new Dictionary<string, object?>();
|
||||
if (States.TryGetValue(entity, out var previous) && previous.Attributes.ValueKind == JsonValueKind.Object)
|
||||
foreach (var property in previous.Attributes.EnumerateObject()) attributes[property.Name] = property.Value.Clone();
|
||||
if (data.TryGetValue("brightness_pct", out var brightness))
|
||||
attributes["brightness"] = Convert.ToDouble(brightness) / 100 * 255;
|
||||
States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(),
|
||||
Attributes = JsonSerializer.SerializeToElement(attributes) };
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeMusic : ITcmMusicRuntime
|
||||
{
|
||||
public int StopCount { get; private set; }
|
||||
public TcmMusicTransitionOutput GetStatus() => new();
|
||||
public bool HasPlayableTracks(string code) => true;
|
||||
public Task StartForConstitutionAsync(string code, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task PlayTestMusicAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task SetManualVolumeAsync(int volume, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task StopMusicAsync(CancellationToken ct) { StopCount++; return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
internal sealed class FakeLight : ILightTransitionRuntime
|
||||
{
|
||||
public HomeAssistantLightTransitionOutput GetStatus() => new();
|
||||
public Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
||||
public Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
||||
public Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
||||
}
|
||||
|
||||
internal sealed class SyntheticDeviceModule : HealthDeviceModule
|
||||
{
|
||||
private readonly bool _block;
|
||||
private readonly bool _twoOff;
|
||||
private readonly string _key;
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public bool ContinuedAfterWait { get; private set; }
|
||||
public SyntheticDeviceModule(DeviceActionRunner runner, bool block = false, bool twoOff = false, string key = "synthetic")
|
||||
: base(runner) => (_block, _twoOff, _key) = (block, twoOff, key);
|
||||
public override string Key => _key;
|
||||
public override string Category => nameof(HealthDeviceCategory.Other);
|
||||
public override string DisplayName => "测试新增家具";
|
||||
public override string PrimaryEntityId => "switch.synthetic";
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters =>
|
||||
[DeviceValues.Number("synthetic_value", "number.synthetic", 0, 5, "测试参数")];
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken ct)
|
||||
{
|
||||
Started.TrySetResult();
|
||||
if (_block) await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
||||
ContinuedAfterWait = true;
|
||||
await context.RunAsync(BuildParameterCommand("synthetic_value", 3), ct);
|
||||
}
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
{
|
||||
if (_twoOff) yield return new("失败设备关闭", "switch.bad", "switch", "turn_off", ExpectedState: "off");
|
||||
yield return new("测试家具关闭", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeAudioGateway : IAudioPlaybackGateway
|
||||
{
|
||||
public bool IsConfigured => true;
|
||||
public ConcurrentQueue<int> Volumes { get; } = new();
|
||||
public ConcurrentQueue<string> Tracks { get; } = new();
|
||||
public int Stops;
|
||||
public Task PlayAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task PlayUrlAsync(string url, CancellationToken ct = default) { Tracks.Enqueue(url); return Task.CompletedTask; }
|
||||
public Task StopAsync(CancellationToken ct = default) { Interlocked.Increment(ref Stops); return Task.CompletedTask; }
|
||||
public Task SetVolumeAsync(int volume, CancellationToken ct = default) { Volumes.Enqueue(volume); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
/// <summary>只推进测试时间,不等待真实 250 秒,也不依赖额外测试包。</summary>
|
||||
internal sealed class ManualTestTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<ManualTimer> _timers = new();
|
||||
private readonly DateTimeOffset _origin = DateTimeOffset.UtcNow;
|
||||
private long _ticks;
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
public override long GetTimestamp() { lock (_lock) return _ticks; }
|
||||
public override DateTimeOffset GetUtcNow() { lock (_lock) return _origin.AddTicks(_ticks); }
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ManualTimer(this, callback, state);
|
||||
lock (_lock) { _timers.Add(timer); timer.Change(dueTime, period); }
|
||||
return timer;
|
||||
}
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
List<ManualTimer> due;
|
||||
lock (_lock)
|
||||
{
|
||||
_ticks += duration.Ticks;
|
||||
due = _timers.Where(timer => !timer.Disposed && timer.Due <= _ticks).ToList();
|
||||
foreach (var timer in due) timer.Due = timer.Period > 0 ? _ticks + timer.Period : long.MaxValue;
|
||||
}
|
||||
foreach (var timer in due) timer.Callback(timer.State);
|
||||
}
|
||||
private sealed class ManualTimer : ITimer
|
||||
{
|
||||
private readonly ManualTestTimeProvider _owner;
|
||||
public TimerCallback Callback { get; }
|
||||
public object? State { get; }
|
||||
public bool Disposed;
|
||||
public long Due;
|
||||
public long Period;
|
||||
public ManualTimer(ManualTestTimeProvider owner, TimerCallback callback, object? state)
|
||||
=> (_owner, Callback, State) = (owner, callback, state);
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
lock (_owner._lock)
|
||||
{
|
||||
if (Disposed) return false;
|
||||
Due = dueTime == Timeout.InfiniteTimeSpan ? long.MaxValue : _owner._ticks + dueTime.Ticks;
|
||||
Period = period == Timeout.InfiniteTimeSpan ? 0 : period.Ticks;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public void Dispose() { lock (_owner._lock) { Disposed = true; _owner._timers.Remove(this); } }
|
||||
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using SystemJson = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
/// <summary>检查 HA JSON 到 API JSON 的完整转换,不连接 HA、不发送家具指令。</summary>
|
||||
public sealed class HomeAssistantAttributeSerializationTests
|
||||
{
|
||||
private const string ClimateId = "climate.test_room";
|
||||
private static HomeAssistantState State(string state = "off") => new()
|
||||
{
|
||||
EntityId = ClimateId,
|
||||
State = state,
|
||||
Attributes = SystemJson.SerializeToElement(new
|
||||
{
|
||||
hvac_modes = new[] { "cool", "heat", "fan_only", "off" },
|
||||
fan_modes = new[] { "自动", "低风", "中风", "高风" },
|
||||
swing_modes = new[] { "off", "vertical" },
|
||||
options = new[] { "智能适配", "轻柔", "全身" },
|
||||
current_temperature = 24.9,
|
||||
temperature = 23,
|
||||
temperature_unit = "°C",
|
||||
min_temp = 5,
|
||||
max_temp = 35,
|
||||
target_temp_step = 0.5,
|
||||
supported_features = 393,
|
||||
fan_mode = "低风"
|
||||
})
|
||||
};
|
||||
|
||||
private static string SerializeApi(object value) => JsonConvert.SerializeObject(value, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
DateFormatString = "yyyy-MM-dd HH:mm:ss"
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("hvac_modes")]
|
||||
[InlineData("fan_modes")]
|
||||
[InlineData("swing_modes")]
|
||||
[InlineData("options")]
|
||||
public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key)
|
||||
{
|
||||
var state = State();
|
||||
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!;
|
||||
var json = JObject.Parse(SerializeApi(safe));
|
||||
var values = Assert.IsType<JArray>(json[key]);
|
||||
Assert.All(values, value => Assert.Equal(JTokenType.String, value.Type));
|
||||
Assert.Equal(state.Attributes.GetProperty(key).EnumerateArray().Select(value => value.GetString()),
|
||||
values.Select(value => value.Value<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DiscoveredClimateAndParameterReadingsKeepCapabilitiesAcrossApiSerialization(bool useSystemJson)
|
||||
{
|
||||
var state = State();
|
||||
var states = new Dictionary<string, HomeAssistantState> { [ClimateId] = state };
|
||||
var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = ClimateId } });
|
||||
var module = new ClimateModule(new DeviceActionRunner(new NoWritesGateway(), TimeProvider.System, new()), options);
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([module]));
|
||||
var devices = catalog.BuildDiscoveredDevices(new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "ac", Name = "空调" }],
|
||||
Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }]
|
||||
}, states);
|
||||
var status = new HomeAssistantStatusOutput
|
||||
{
|
||||
Connected = true,
|
||||
Devices = devices,
|
||||
ControlParameters = module.Parameters.ToDictionary(parameter => parameter.Key, parameter =>
|
||||
new HomeAssistantParameterStateOutput
|
||||
{
|
||||
EntityId = parameter.EntityId,
|
||||
State = state.State,
|
||||
Available = true,
|
||||
Attributes = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)
|
||||
})
|
||||
};
|
||||
var wire = JObject.Parse(useSystemJson
|
||||
? SystemJson.Serialize(status, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
|
||||
: SerializeApi(status));
|
||||
var device = wire["devices"]![0]!;
|
||||
Assert.Equal("climate", device["moduleKey"]!.Value<string>());
|
||||
Assert.True(device["online"]!.Value<bool>());
|
||||
Assert.Equal(ClimateId, device["primaryControlEntityId"]!.Value<string>());
|
||||
var entity = device["entities"]![0]!;
|
||||
Assert.True(entity["available"]!.Value<bool>());
|
||||
Assert.Equal("off", entity["state"]!.Value<string>()); // 关闭不等于不可用。
|
||||
foreach (var reading in new[] { entity, wire["controlParameters"]!["climate_mode"]!,
|
||||
wire["controlParameters"]!["climate_temperature"]!, wire["controlParameters"]!["climate_fan_mode"]! })
|
||||
{
|
||||
var attributes = reading["attributes"]!;
|
||||
Assert.Equal(new[] { "cool", "heat", "fan_only", "off" }, attributes["hvac_modes"]!.Values<string>());
|
||||
Assert.Equal(new[] { "自动", "低风", "中风", "高风" }, attributes["fan_modes"]!.Values<string>());
|
||||
Assert.Equal(24.9, attributes["current_temperature"]!.Value<double>());
|
||||
Assert.Equal(23, attributes["temperature"]!.Value<int>());
|
||||
Assert.Equal("°C", attributes["temperature_unit"]!.Value<string>());
|
||||
Assert.Equal(0.5, attributes["target_temp_step"]!.Value<double>());
|
||||
Assert.Equal(393, attributes["supported_features"]!.Value<int>());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ConversionPreservesJsonTypesAndDoesNotExposeUnapprovedRootAttributes(bool useSystemJson)
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}],
|
||||
"temperature":null,"is_volume_muted":false,"volume_level":0.5,
|
||||
"access_token":"private","unapproved_attribute":{"secret":"private"}}
|
||||
""");
|
||||
var state = new HomeAssistantState { Attributes = document.RootElement };
|
||||
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!;
|
||||
var wire = JObject.Parse(useSystemJson ? SystemJson.Serialize(safe) : SerializeApi(safe));
|
||||
var expected = JObject.Parse("""
|
||||
{"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}],
|
||||
"temperature":null,"is_volume_muted":false,"volume_level":0.5}
|
||||
""");
|
||||
Assert.True(JToken.DeepEquals(expected, wire), wire.ToString());
|
||||
}
|
||||
|
||||
private sealed class NoWritesGateway : IHomeAssistantGateway
|
||||
{
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Serialization tests must not read a device");
|
||||
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("Serialization tests must not control a device");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class HomeAssistantTemperatureUnitTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("°C")]
|
||||
[InlineData("°F")]
|
||||
public async Task NativeClimateWithoutUnitUsesHaUnitSystemWithoutGuessing(string unit)
|
||||
{
|
||||
var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature":23,"min_temp":5,"max_temp":35,"target_temp_step":0.5,"supported_features":393}}]""",
|
||||
unit);
|
||||
var states = await Client(handler).GetStatesAsync();
|
||||
var state = Assert.Single(states);
|
||||
Assert.Equal(unit, state.GetStringAttribute("temperature_unit"));
|
||||
Assert.Equal(23d, state.GetDoubleAttribute("temperature"));
|
||||
Assert.Equal(393d, state.GetDoubleAttribute("supported_features"));
|
||||
Assert.Equal(new[] { "/api/states", "/api/config" }, handler.Paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitDeviceUnitIsNotOverwritten()
|
||||
{
|
||||
var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature_unit":"°F","temperature":73}}]""", "°C");
|
||||
var state = Assert.Single(await Client(handler).GetStatesAsync());
|
||||
Assert.Equal("°F", state.GetStringAttribute("temperature_unit"));
|
||||
Assert.Equal(new[] { "/api/states" }, handler.Paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingConfigKeepsUnitUnknownAndDoesNotHideOtherDevices()
|
||||
{
|
||||
var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature":23}},{"entity_id":"switch.room","state":"on","attributes":{}}]""",
|
||||
"°C", HttpStatusCode.ServiceUnavailable);
|
||||
var states = await Client(handler).GetStatesAsync();
|
||||
Assert.Equal(2, states.Count);
|
||||
Assert.Null(states[0].GetStringAttribute("temperature_unit"));
|
||||
Assert.Equal("on", states[1].State);
|
||||
Assert.Equal(23d, states[0].GetDoubleAttribute("temperature"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OtherDevicesDoNotRequireUnitProbe()
|
||||
{
|
||||
var handler = new UnitHandler("""[{"entity_id":"cover.room","state":"unavailable","attributes":{"supported_features":15}}]""", "°C");
|
||||
Assert.Single(await Client(handler).GetStatesAsync());
|
||||
Assert.Equal(new[] { "/api/states" }, handler.Paths);
|
||||
}
|
||||
|
||||
private static HomeAssistantClient Client(UnitHandler handler) => new(new HttpClient(handler),
|
||||
Options.Create(new HomeAssistantOptions { BaseUrl = "https://ha.example.invalid", AccessToken = "fake-test-token" }));
|
||||
|
||||
private sealed class UnitHandler(string states, string unit, HttpStatusCode configStatus = HttpStatusCode.OK) : HttpMessageHandler
|
||||
{
|
||||
public List<string> Paths { get; } = [];
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
Paths.Add(path);
|
||||
var content = path == "/api/states" ? states : System.Text.Json.JsonSerializer.Serialize(new { unit_system = new { temperature = unit } });
|
||||
return Task.FromResult(new HttpResponseMessage(path == "/api/states" ? HttpStatusCode.OK : configStatus)
|
||||
{ Content = new StringContent(content, Encoding.UTF8, "application/json") });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Outlet;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
/// <summary>仅内存假网关,不连接 HA、不使实际插座通电。</summary>
|
||||
public sealed class OutletIntegrationTests
|
||||
{
|
||||
private const string MainId = "switch.test_outlet_power", ServiceId = "switch.test_outlet_service";
|
||||
private static HomeAssistantState State(string id, string value) => new() { EntityId = id, State = value };
|
||||
private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System,
|
||||
new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
|
||||
private static IOptions<HomeAssistantOptions> Settings(string id = MainId) => Options.Create(new HomeAssistantOptions
|
||||
{ Entities = new() { Outlet = id } });
|
||||
private static HomeAssistantRegistries Registry(params string[] entityIds) => new()
|
||||
{
|
||||
Devices = [new() { Id = "outlet-device", Name = "红灯插座", Model = "ym001.plug.ymcz" }],
|
||||
Entities = entityIds.Select(id => new HomeAssistantEntityRegistryItem
|
||||
{ DeviceId = "outlet-device", EntityId = id }).ToList()
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "off", "turn_on", "on")]
|
||||
[InlineData(false, "on", "turn_off", "off")]
|
||||
public async Task ManualPowerUsesOnlyTrustedPrimaryAndConfirmsReadback(bool turnOn, string current, string service, string expected)
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[MainId] = State(MainId, current);
|
||||
gateway.States[ServiceId] = State(ServiceId, "off");
|
||||
var module = new OutletModule(Runner(gateway), Settings());
|
||||
await module.SetPowerAsync(turnOn, default);
|
||||
var call = Assert.Single(gateway.Calls);
|
||||
Assert.Equal("switch", call.Domain);
|
||||
Assert.Equal(service, call.Service);
|
||||
Assert.Equal(MainId, call.EntityId);
|
||||
Assert.Equal(MainId, call.Data!["entity_id"]);
|
||||
Assert.Single(call.Data);
|
||||
Assert.Equal(expected, gateway.States[MainId].State);
|
||||
Assert.Equal("off", gateway.States[ServiceId].State);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unavailable")]
|
||||
[InlineData("unknown")]
|
||||
[InlineData("")]
|
||||
[InlineData("idle")]
|
||||
public async Task UnconfirmedPrimaryNeverBorrowsServiceSwitchAvailability(string state)
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[MainId] = State(MainId, state);
|
||||
gateway.States[ServiceId] = State(ServiceId, "on");
|
||||
var module = new OutletModule(Runner(gateway), Settings());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(false, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingPrimaryAndReadFailuresNeverSendPowerCommands()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[ServiceId] = State(ServiceId, "on");
|
||||
var module = new OutletModule(Runner(gateway), Settings());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
||||
gateway.States[MainId] = State(MainId, "off");
|
||||
gateway.ReadsToFail = 1;
|
||||
await Assert.ThrowsAsync<IOException>(() => module.SetPowerAsync(true, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("button.toggle")]
|
||||
[InlineData("number.protection")]
|
||||
public async Task InvalidBindingCannotFallBackToToggleOrEngineeringParameters(string id)
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var module = new OutletModule(Runner(gateway), Settings(id));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
||||
Assert.Throws<InvalidOperationException>(() => module.BuildParameterCommand("outlet_service", true));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationPreventsManualPower()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[MainId] = State(MainId, "off");
|
||||
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
|
||||
var module = new OutletModule(Runner(gateway), Settings());
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => module.SetPowerAsync(true, cancellation.Token));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedButUnconfirmedCommandIsNotSuccessAndDoesNotRepeatEnergizing()
|
||||
{
|
||||
var gateway = new NonApplyingGateway();
|
||||
var module = new OutletModule(Runner(gateway), Settings());
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => module.SetPowerAsync(true, default));
|
||||
Assert.Contains("未确认目标状态", error.Message);
|
||||
Assert.Equal(1, gateway.Writes);
|
||||
Assert.Equal(4, gateway.Reads); // 发送前校验 + 三轮回读,不再次发送。
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverySelectsExplicitPowerEvenWhenServiceSwitchComesFirst()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[MainId] = State(MainId, "off"); gateway.States[ServiceId] = State(ServiceId, "on");
|
||||
var options = Settings();
|
||||
var module = new OutletModule(Runner(gateway), options);
|
||||
var modules = new DeviceModuleRegistry([module]);
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, modules);
|
||||
var item = Assert.Single(catalog.BuildDiscoveredDevices(Registry(ServiceId, "button.toggle", MainId), gateway.States));
|
||||
Assert.Equal("outlet", item.ModuleKey);
|
||||
Assert.Equal("Outlet", item.Category);
|
||||
Assert.Equal(MainId, item.PrimaryControlEntityId);
|
||||
Assert.True(item.Online);
|
||||
Assert.Equal("off", item.Entities.Single(entity => entity.EntityId == item.PrimaryControlEntityId).State);
|
||||
Assert.Null(modules.ForEntities([ServiceId, "button.toggle"]));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnconfiguredOutletHasNoGuessedPrimaryOrFallbackDevice()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[ServiceId] = State(ServiceId, "on");
|
||||
var options = Settings("");
|
||||
var catalog = new HomeAssistantDeviceCatalog(options,
|
||||
new DeviceModuleRegistry([new OutletModule(Runner(gateway), options)]));
|
||||
var item = Assert.Single(catalog.BuildDiscoveredDevices(Registry(ServiceId), gateway.States));
|
||||
Assert.Equal("", item.ModuleKey);
|
||||
Assert.Null(item.PrimaryControlEntityId);
|
||||
Assert.Empty(catalog.BuildDiscoveredDevices(null, gateway.States));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SleepAndAllOffDoNotChangeOutletPowerOrExposeScenePreset()
|
||||
{
|
||||
var gateway = new FakeHaGateway(); gateway.States[MainId] = State(MainId, "on");
|
||||
var runner = Runner(gateway);
|
||||
var module = new OutletModule(runner, Settings());
|
||||
var coordinator = new HealthSceneCoordinator(new DeviceModuleRegistry([module]), runner, gateway, new FakeLight(), new FakeMusic());
|
||||
await coordinator.ExecuteAsync("sleep", new PersonalizedSceneConfig(), "balanced", default);
|
||||
await coordinator.ExecuteAsync("all-off", null, "", default);
|
||||
Assert.Empty(module.GetShutdownCommands());
|
||||
Assert.Null(module.BuildPreview(new(), "balanced"));
|
||||
Assert.Empty(gateway.Calls);
|
||||
Assert.Equal("on", gateway.States[MainId].State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutletIsRegisteredAsItsOwnModuleWithoutHostedAutomation()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
|
||||
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
|
||||
var module = provider.GetRequiredService<OutletModule>();
|
||||
Assert.Same(module, provider.GetRequiredService<DeviceModuleRegistry>().Modules.Single(item => item.Key == "outlet"));
|
||||
Assert.Empty(module.Parameters);
|
||||
}
|
||||
|
||||
private sealed class NonApplyingGateway : IHomeAssistantGateway
|
||||
{
|
||||
public int Writes { get; private set; }
|
||||
public int Reads { get; private set; }
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested(); Reads++;
|
||||
return Task.FromResult<IReadOnlyList<HomeAssistantState>>([State(MainId, "off")]);
|
||||
}
|
||||
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested(); Writes++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Integrations.Tuya;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class SafetyAndReservedWorkflowTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 10, 8, 0, 0, 0, TimeSpan.Zero);
|
||||
private static SleepObservation Reading(DateTimeOffset at, BedPresence presence = BedPresence.Occupied,
|
||||
SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence);
|
||||
private static HomeAssistantOptions Configuration() => new()
|
||||
{
|
||||
Entities = new() { AmbientLight = "light.bedroom", MattressRightMassagePart = "select.right",
|
||||
MattressLeftMassagePart = "select.left", MattressLeftHeatingLevel = "number.heat" }
|
||||
};
|
||||
private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new()
|
||||
{
|
||||
EntityId = "light.bedroom", State = state, LastUpdated = updated,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { brightness })
|
||||
};
|
||||
private static DeviceCommand Massage() => new("按摩", "select.right", "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "全身" }) { Intent = DeviceCommandIntent.StartMassage };
|
||||
|
||||
[Fact]
|
||||
public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork()
|
||||
{
|
||||
var observation = await new ReservedJe01Source().ReadAsync(default);
|
||||
Assert.Equal(BedPresence.Unknown, observation.Presence);
|
||||
Assert.Equal(SleepDataAvailability.Unconfigured, observation.Availability);
|
||||
var clip = new GuidanceCatalog().InitialRelaxation;
|
||||
Assert.False(clip.Playable);
|
||||
Assert.False((await new ReservedGuidancePlayer().PlayAsync(clip, 50, default)).Completed);
|
||||
var tuya = new ReservedTuyaGateway();
|
||||
Assert.False(tuya.IsConfigured);
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => tuya.WriteAsync(new("fake", "switch", true), default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultWorkflowNeverPlansBedOrDeviceActions()
|
||||
{
|
||||
var workflow = new ReservedSleepWorkflow(new(), new());
|
||||
Assert.Empty(workflow.Start());
|
||||
Assert.Empty(workflow.Observe(Reading(Now), Now.AddMinutes(1)));
|
||||
Assert.Empty(workflow.Tick(Now.AddMinutes(10)));
|
||||
Assert.Equal(ReservedSleepPhase.Disabled, workflow.Phase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BedPresence.Unknown, SleepDataAvailability.Available, 0)]
|
||||
[InlineData(BedPresence.Empty, SleepDataAvailability.Available, 0)]
|
||||
[InlineData(BedPresence.Occupied, SleepDataAvailability.Offline, 0)]
|
||||
[InlineData(BedPresence.Occupied, SleepDataAvailability.Stale, 0)]
|
||||
[InlineData(BedPresence.Occupied, SleepDataAvailability.Available, 121)]
|
||||
public void UnknownOfflineOrStaleInputResetsContinuousBedWait(BedPresence presence, SleepDataAvailability availability, int age)
|
||||
{
|
||||
var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new());
|
||||
Assert.Equal(SleepWorkflowActionKind.PrepareEnvironment, Assert.Single(workflow.Start()).Kind);
|
||||
Assert.Empty(workflow.Observe(Reading(Now), Now));
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29 - age), presence, availability), Now.AddSeconds(29)));
|
||||
Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase);
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)));
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59)));
|
||||
Assert.Equal(2, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaceholderCompletionCannotStartMusicOrLightFade()
|
||||
{
|
||||
var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new());
|
||||
workflow.Start(); workflow.Observe(Reading(Now), Now);
|
||||
workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
|
||||
Assert.Empty(workflow.GuidanceCompleted("initial-relaxation", true, Now.AddSeconds(40)));
|
||||
Assert.Empty(workflow.Tick(Now.AddMinutes(5)));
|
||||
Assert.Equal(ReservedSleepPhase.Guiding, workflow.Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealFutureGuideRestartsAtFiftyAndReminderDoesNotRestartCompletedLampFade()
|
||||
{
|
||||
var clip = new GuidanceClip("guide", "测试", "https://example.invalid/guide.mp3", 20);
|
||||
var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new(clip));
|
||||
workflow.Start(); workflow.Observe(Reading(Now), Now);
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29)));
|
||||
var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
|
||||
Assert.Contains(actions, action => action.Kind == SleepWorkflowActionKind.StartGentleRightMassage);
|
||||
Assert.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent);
|
||||
Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50)));
|
||||
var music = Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddSeconds(50)));
|
||||
Assert.Equal(SleepWorkflowActionKind.RestartMusicAtFifty, music.Kind);
|
||||
Assert.Equal(50, music.VolumePercent);
|
||||
Assert.Empty(workflow.Tick(Now.AddSeconds(79)));
|
||||
Assert.Equal(SleepWorkflowActionKind.StartLightFade, Assert.Single(workflow.Tick(Now.AddSeconds(80))).Kind);
|
||||
Assert.Empty(workflow.Tick(Now.AddSeconds(81)));
|
||||
Assert.Single(workflow.RequestReminder(clip));
|
||||
Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddMinutes(6)));
|
||||
Assert.Empty(workflow.Tick(Now.AddMinutes(7)));
|
||||
workflow.Stop();
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddMinutes(8)), Now.AddMinutes(8)));
|
||||
Assert.Empty(workflow.RequestReminder(clip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameAnomalyCooldownOnlyStartsAfterActualDelivery()
|
||||
{
|
||||
var gate = new AnomalyReminderGate(new());
|
||||
Assert.True(gate.CanRemind("restless", Now));
|
||||
Assert.True(gate.CanRemind("restless", Now.AddMinutes(1)));
|
||||
gate.MarkDelivered("restless", Now);
|
||||
Assert.False(gate.CanRemind("RESTLESS", Now.AddMinutes(29)));
|
||||
Assert.True(gate.CanRemind("restless", Now.AddMinutes(30)));
|
||||
Assert.True(gate.CanRemind("another", Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("off", 255)]
|
||||
[InlineData("on", 0)]
|
||||
[InlineData("unavailable", 255)]
|
||||
public async Task OffOrUnconfirmedLampBlocksMassage(string state, double brightness)
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States["light.bedroom"] = Light(state, brightness, Now);
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownLampOffRejectsOlderCloudOnCacheButAllowsFreshOnReading()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var state = new SceneLightSafetyState(); state.MarkOff(Now);
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), state);
|
||||
gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(-1));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => guard.ValidateAsync(Massage(), default));
|
||||
gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(1));
|
||||
await guard.ValidateAsync(Massage(), default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateMassageAfterAnOffCommandCannotUndoShutdown()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
|
||||
gateway.States["light.bedroom"] = Light("on", 100, DateTimeOffset.UtcNow);
|
||||
await runner.SendAsync(new("关灯", "light.bedroom", "light", "turn_off")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
|
||||
Assert.DoesNotContain(gateway.Calls, command => command.EntityId == "select.right");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NaturalFadeToZeroStopsBothMassageSides()
|
||||
{
|
||||
var time = new ManualTestTimeProvider();
|
||||
var gateway = new FakeHaGateway { Time = time };
|
||||
gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow());
|
||||
var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero });
|
||||
var mattress = new MattressModule(runner, Options.Create(Configuration()));
|
||||
var safety = new SceneLightSafetyState();
|
||||
var light = new HomeAssistantLightTransitionService(gateway, new([mattress]), Options.Create(Configuration()),
|
||||
NullLogger<HomeAssistantLightTransitionService>.Instance, safety, time);
|
||||
await light.StartSleepAsync(2700, default);
|
||||
for (var step = 1; step <= 50; step++)
|
||||
{
|
||||
time.Advance(TimeSpan.FromSeconds(5));
|
||||
var expected = 50 - step;
|
||||
await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running));
|
||||
}
|
||||
await WaitUntilAsync(() => gateway.States.GetValueOrDefault("select.left")?.State == "无");
|
||||
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
||||
Assert.Equal("无", gateway.States["select.right"].State);
|
||||
Assert.NotNull(safety.OffAt);
|
||||
await light.StopAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TaskCancellationAloneCannotCountAsDeviceSceneSuccess()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
|
||||
var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), new FakeMusic());
|
||||
Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManuallyEnabledHeatIsIncludedEvenIfAutomaticMattressStartIsDisabled()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var module = new MattressModule(new(gateway, TimeProvider.System, new()), Options.Create(Configuration()));
|
||||
Assert.Contains(module.GetShutdownCommands(), command => command.EntityId == "number.heat" && command.ExpectedState == "0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized()
|
||||
{
|
||||
var advice = RoomTemperaturePolicy.Evaluate(new(15, Now, true, ""), new(22, 25));
|
||||
Assert.Null(advice.TargetTemperatureCelsius);
|
||||
RoomTemperaturePolicy.PickTargetForSession(advice);
|
||||
var initial = advice.TargetTemperatureCelsius;
|
||||
for (var i = 0; i < 20; i++) RoomTemperaturePolicy.PickTargetForSession(advice);
|
||||
Assert.Equal(initial, advice.TargetTemperatureCelsius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayTimeoutIsFailureNotUserCancellationSoOtherDevicesCanContinue()
|
||||
{
|
||||
var ha = new HomeAssistantClient(new HttpClient(new TimedOutHandler()), Options.Create(new HomeAssistantOptions
|
||||
{ Enabled = true, BaseUrl = "https://example.invalid", AccessToken = "fake-test-token" }));
|
||||
await Assert.ThrowsAsync<TimeoutException>(() => ha.GetStatesAsync());
|
||||
var audio = new MiAirXClient(new HttpClient(new TimedOutHandler()), Options.Create(new MiAirXOptions
|
||||
{ Enabled = true, BaseUrl = "https://example.invalid", DeviceId = "fake-test-device", RequireWebPassword = false }));
|
||||
await Assert.ThrowsAsync<TimeoutException>(() => audio.StopAsync());
|
||||
using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ha.GetStatesAsync(cancelled.Token));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => audio.StopAsync(cancelled.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnintegratedCurtainNeverSendsOrReportsSuccessfulControl()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
|
||||
var module = new Evergreen.Health.Application.Health.Devices.Curtain.CurtainModule(runner,
|
||||
Options.Create(new HomeAssistantOptions()));
|
||||
var context = new DeviceSceneContext(runner, new Dictionary<string, HomeAssistantState>(), []);
|
||||
await module.ExecuteSleepAsync(context, new(), "balanced", default);
|
||||
Assert.Equal("Skipped", Assert.Single(context.Results).Status);
|
||||
Assert.Empty(gateway.Calls);
|
||||
Assert.Empty(module.GetShutdownCommands());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllOffAlsoCancelsOldPreparationBeforeAnyDeviceCanStart()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero });
|
||||
var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]), runner, gateway, new FakeLight(), new FakeMusic());
|
||||
var preparing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var start = coordinator.ExecutePreparedSleepAsync(async token =>
|
||||
{
|
||||
preparing.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
return new(new(), "balanced");
|
||||
}, default);
|
||||
await preparing.Task;
|
||||
var off = await coordinator.ExecuteAsync("all-off", null, "", default);
|
||||
Assert.Equal("Cancelled", (await start).Status);
|
||||
Assert.Equal("Succeeded", off.Status);
|
||||
Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingConstitutionInPreparationDoesNotExecuteOrCountCancellationAsSuccess()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
|
||||
var music = new FakeMusic();
|
||||
var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), music);
|
||||
var output = await coordinator.ExecutePreparedSleepAsync(_ => throw new ScenePreparationException("先完成四诊"), default);
|
||||
Assert.False(output.Success);
|
||||
Assert.Equal("AssessmentRequired", output.Status);
|
||||
Assert.Equal("先完成四诊", output.Message);
|
||||
Assert.Empty(gateway.Calls);
|
||||
Assert.Equal(0, music.StopCount);
|
||||
}
|
||||
|
||||
private sealed class TimedOutHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromException<HttpResponseMessage>(new TaskCanceledException("模拟网络超时"));
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (!predicate()) await Task.Delay(5, timeout.Token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class SystemServiceFilteringTests
|
||||
{
|
||||
private static HomeAssistantDeviceCatalog Catalog() => new(Options.Create(new HomeAssistantOptions()), new DeviceModuleRegistry([]));
|
||||
|
||||
[Fact]
|
||||
public void RegistryDeserializationRetainsSystemServiceMarker()
|
||||
{
|
||||
var device = JsonSerializer.Deserialize<HomeAssistantDeviceRegistryItem>(
|
||||
"""{"id":"system-sun","name":"Sun","entry_type":"service"}""");
|
||||
Assert.NotNull(device);
|
||||
Assert.Equal("service", device.EntryType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemServicesAreExcludedFromListAndAllDeviceCounts()
|
||||
{
|
||||
var registry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "sun", Name = "Sun", EntryType = "service" },
|
||||
new() { Id = "backup", Name = "Backup", EntryType = "service" }],
|
||||
Entities = [new() { DeviceId = "sun", EntityId = "sensor.sun_next_rising", Platform = "sun" },
|
||||
new() { DeviceId = "backup", EntityId = "sensor.backup_backup_manager_state", Platform = "backup" }]
|
||||
};
|
||||
var states = new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["sensor.sun_next_rising"] = new() { EntityId = "sensor.sun_next_rising", State = "2026-10-10T06:00:00Z" },
|
||||
["sensor.backup_backup_manager_state"] = new() { EntityId = "sensor.backup_backup_manager_state", State = "idle" }
|
||||
};
|
||||
for (var i = 0; i < 9; i++)
|
||||
{
|
||||
var deviceId = $"furniture-{i}";
|
||||
var entityId = i == 0 ? "cover.curtain" : $"switch.furniture_{i}";
|
||||
registry.Devices.Add(new() { Id = deviceId, Name = $"家具{i}" });
|
||||
registry.Entities.Add(new() { DeviceId = deviceId, EntityId = entityId, Platform = "xiaomi_home" });
|
||||
states[entityId] = new() { EntityId = entityId, State = i == 0 ? "unavailable" : "on" };
|
||||
}
|
||||
var devices = Catalog().BuildDiscoveredDevices(registry, states);
|
||||
Assert.Equal(9, devices.Count);
|
||||
Assert.DoesNotContain(devices, device => device.DeviceId is "sun" or "backup");
|
||||
Assert.Contains(devices, device => device.DeviceId == "furniture-0" && !device.Online);
|
||||
var summary = HomeAssistantDeviceCatalog.BuildSummary(devices);
|
||||
Assert.Equal(9, summary.TotalDevices);
|
||||
Assert.Equal(8, summary.OnlineDevices);
|
||||
Assert.Equal(1, summary.OfflineDevices);
|
||||
Assert.Equal(9, summary.TotalEntities);
|
||||
Assert.Equal(8, summary.AvailableEntities);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("service")]
|
||||
[InlineData("SERVICE")]
|
||||
public void RenamedSystemServiceStillDoesNotBecomeFurniture(string entryType)
|
||||
{
|
||||
var registry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "service", Name = "系统服务", NameByUser = "我的灯", EntryType = entryType }],
|
||||
Entities = [new() { DeviceId = "service", EntityId = "sensor.service" }]
|
||||
};
|
||||
Assert.Empty(Catalog().BuildDiscoveredDevices(registry, new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["sensor.service"] = new() { EntityId = "sensor.service", State = "on" }
|
||||
}));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Sun", null)]
|
||||
[InlineData("Backup", "")]
|
||||
public void PhysicalDevicesAreNotHiddenJustBecauseTheyShareServiceNames(string name, string? entryType)
|
||||
{
|
||||
var registry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "physical", Name = name, EntryType = entryType, Manufacturer = "Other brand" }],
|
||||
Entities = [new() { DeviceId = "physical", EntityId = "sensor.physical", Platform = "other_integration" }]
|
||||
};
|
||||
var device = Assert.Single(Catalog().BuildDiscoveredDevices(registry, new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["sensor.physical"] = new() { EntityId = "sensor.physical", State = "20" }
|
||||
}));
|
||||
Assert.Equal(name, device.Name);
|
||||
Assert.True(device.Online);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidServiceOnlyRegistryDoesNotCreateFallbackFurniture()
|
||||
{
|
||||
var options = Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
Entities = new() { MattressLeftHeatingLevel = "number.heating" }
|
||||
});
|
||||
var runner = new DeviceActionRunner(new FakeHaGateway(), TimeProvider.System, new());
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
|
||||
var registry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }],
|
||||
Entities = [new() { DeviceId = "backup", EntityId = "sensor.backup", Platform = "backup" }]
|
||||
};
|
||||
var states = new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["sensor.backup"] = new() { EntityId = "sensor.backup", State = "idle" },
|
||||
["number.heating"] = new() { EntityId = "number.heating", State = "0" }
|
||||
};
|
||||
Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
|
||||
Assert.Equal("mattress", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user