188 lines
9.1 KiB
C#
188 lines
9.1 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|