Files
Evergreen-Health/Admin.NET.Test/Health/DeviceArchitectureTests.cs
T
2026-10-09 16:46:30 +08:00

311 lines
15 KiB
C#

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);
}
}