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; /// 仅内存假网关,不连接 HA、不使实际插座通电。 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 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(() => module.SetPowerAsync(true, default)); await Assert.ThrowsAsync(() => 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(() => module.SetPowerAsync(true, default)); gateway.States[MainId] = State(MainId, "off"); gateway.ReadsToFail = 1; await Assert.ThrowsAsync(() => 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(() => module.SetPowerAsync(true, default)); Assert.Throws(() => 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(() => 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(() => 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(); services.AddSingleton(Settings()); using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); var module = provider.GetRequiredService(); Assert.Same(module, provider.GetRequiredService().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> GetStatesAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Reads++; return Task.FromResult>([State(MainId, "off")]); } public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Writes++; return Task.CompletedTask; } } }