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