259 lines
15 KiB
C#
259 lines
15 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.Lighting;
|
|
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.Linq;
|
|
using System.Text.Json;
|
|
using Xunit;
|
|
|
|
namespace Admin.NET.Test.Health;
|
|
|
|
/// <summary>标准能力、实例隔离和专属钩子全用假网关验证,不操作真实灯具。</summary>
|
|
public sealed class StandardLightControlTests
|
|
{
|
|
private const string LightA = "light.test_a", LightB = "light.test_b";
|
|
private static HomeAssistantState State(string id = LightA, string state = "on", string mode = "color_temp",
|
|
int min = 2700, int max = 6500, string effect = "阅读") => new()
|
|
{
|
|
EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new
|
|
{
|
|
supported_color_modes = new[] { mode }, min_color_temp_kelvin = min, max_color_temp_kelvin = max,
|
|
supported_features = 4, effect_list = new[] { effect, "日光" }, brightness = 128, color_temp_kelvin = 3000,
|
|
effect, internal_config = 842138120
|
|
})
|
|
};
|
|
private static HomeAssistantDiscoveredDeviceOutput Device(string id = LightA) => new()
|
|
{
|
|
DeviceId = id, PrimaryControlEntityId = id, Online = true,
|
|
Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }]
|
|
};
|
|
private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System,
|
|
new() { VerificationDelay = TimeSpan.Zero });
|
|
private static DeviceParameterController Controller(FakeHaGateway gateway, params IHealthDeviceModule[] modules)
|
|
=> new(new DeviceModuleRegistry(modules), Runner(gateway), [new StandardLightControl()]);
|
|
|
|
[Theory]
|
|
[InlineData("light_brightness", "0", "turn_off", null)]
|
|
[InlineData("light_brightness", "50", "turn_on", "brightness_pct")]
|
|
[InlineData("light_brightness", "100", "turn_on", "brightness_pct")]
|
|
[InlineData("light_color_temperature", "2700", "turn_on", "color_temp_kelvin")]
|
|
[InlineData("light_color_temperature", "6500", "turn_on", "color_temp_kelvin")]
|
|
[InlineData("light_effect", "阅读", "turn_on", "effect")]
|
|
public async Task NewLightNeedsNoModelModuleAndUsesOnlySupportedStandardServices(string parameter, string value, string service, string? field)
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: "off");
|
|
await Controller(gateway).ExecuteAsync(Device(), gateway.States, parameter, value, LightA, default);
|
|
var call = Assert.Single(gateway.Calls);
|
|
Assert.Equal("light", call.Domain); Assert.Equal(service, call.Service); Assert.Equal(LightA, call.EntityId);
|
|
Assert.Equal(LightA, call.Data!["entity_id"]);
|
|
if (field is null) Assert.Single(call.Data);
|
|
else { Assert.Equal(2, call.Data.Count); Assert.Equal(value, call.Data[field]!.ToString()); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("light_brightness", "-1")]
|
|
[InlineData("light_brightness", "101")]
|
|
[InlineData("light_brightness", "10.5")]
|
|
[InlineData("light_color_temperature", "2200")]
|
|
[InlineData("light_color_temperature", "6501")]
|
|
[InlineData("light_color_temperature", "3000.5")]
|
|
[InlineData("light_effect", "删除所有遥控器")]
|
|
[InlineData("button.press", "anything")]
|
|
public async Task InvalidOrEngineeringParametersDoNotSendAnyCommand(string parameter, string value)
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(
|
|
Device(), gateway.States, parameter, value, null, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("onoff")]
|
|
[InlineData("unknown")]
|
|
public async Task OnOffOnlyOrUnknownModeDoesNotReceiveBrightnessOrColorTemperature(string mode)
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode);
|
|
var controller = Controller(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("brightness")]
|
|
[InlineData("rgb")]
|
|
[InlineData("rgbww")]
|
|
public async Task DimmableLightWithoutColorTemperatureDoesNotInheritAnotherLampsTemperature(string mode)
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode);
|
|
var controller = Controller(gateway);
|
|
await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
|
|
Assert.Single(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConfiguredManualLightsAreDiscoveredIndependentlyWithInventoryFilteringEnabled()
|
|
{
|
|
const string auxiliary = "light.auxiliary", unconfigured = "light.unconfigured";
|
|
var gateway = new FakeHaGateway();
|
|
foreach (var id in new[] { LightA, LightB, auxiliary, unconfigured }) gateway.States[id] = State(id);
|
|
var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions
|
|
{
|
|
OnlyConfiguredDevices = true, ManualLightEntityIds = [LightA, LightB, LightB]
|
|
}), new DeviceModuleRegistry([]));
|
|
var haRegistry = new HomeAssistantRegistries
|
|
{
|
|
Devices = [new() { Id = "a", Name = "顶灯一" }, new() { Id = "b", Name = "顶灯二" }, new() { Id = "other" }],
|
|
Entities = [new() { DeviceId = "a", EntityId = auxiliary }, new() { DeviceId = "a", EntityId = LightA },
|
|
new() { DeviceId = "b", EntityId = LightB }, new() { DeviceId = "other", EntityId = unconfigured }]
|
|
};
|
|
var controller = Controller(gateway);
|
|
foreach (var registry in new[] { haRegistry, null })
|
|
{
|
|
var devices = catalog.BuildDiscoveredDevices(registry, gateway.States);
|
|
Assert.Equal(2, devices.Count);
|
|
var first = Assert.Single(devices, item => item.PrimaryControlEntityId == LightA);
|
|
var second = Assert.Single(devices, item => item.PrimaryControlEntityId == LightB);
|
|
Assert.NotEqual(first.DeviceId, second.DeviceId);
|
|
Assert.All(devices, item =>
|
|
{
|
|
Assert.Equal(nameof(HealthDeviceCategory.Lighting), item.Category);
|
|
Assert.Equal("", item.ModuleKey);
|
|
});
|
|
await controller.ExecuteAsync(first, gateway.States, "light_brightness", 30, LightA, default);
|
|
await controller.ExecuteAsync(second, gateway.States, "light_color_temperature", 3100, LightB, default);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(
|
|
second, gateway.States, "light_brightness", 40, LightA, default));
|
|
}
|
|
Assert.Equal(new[] { LightA, LightB, LightA, LightB }, gateway.Calls.Select(call => call.EntityId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TwoLightsUseIndependentRangesAndEffectLists()
|
|
{
|
|
var gateway = new FakeHaGateway();
|
|
gateway.States[LightA] = State(min: 2200, max: 4500, effect: "阅读");
|
|
gateway.States[LightB] = State(LightB, min: 3000, max: 6000, effect: "助眠");
|
|
var controller = Controller(gateway);
|
|
await controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 2200, null, default);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_color_temperature", 2200, null, default));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "阅读", null, default));
|
|
await controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "助眠", null, default);
|
|
Assert.Equal(new[] { LightA, LightB }, gateway.Calls.Select(call => call.EntityId));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("unknown")]
|
|
[InlineData("unavailable")]
|
|
[InlineData("")]
|
|
public async Task MainLightAvailabilityCannotBeBorrowedFromAnotherEntity(string state)
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: state); gateway.States[LightB] = State(LightB);
|
|
var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true });
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForeignEntityEvenAnotherLightOnSameDeviceCannotOverrideTrustedPrimary()
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB);
|
|
var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true });
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, LightB, default));
|
|
device.Entities.RemoveAt(0);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
|
|
device = Device(); device.Entities[0].Domain = "switch";
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CapabilityChangeAfterRequestSnapshotIsRecheckedBeforeSend()
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: "onoff");
|
|
var snapshot = new Dictionary<string, HomeAssistantState> { [LightA] = State() };
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway).ExecuteAsync(Device(), snapshot, "light_brightness", 50, null, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingRangeOrEffectFlagDoesNotEnableTheseControls()
|
|
{
|
|
var gateway = new FakeHaGateway();
|
|
gateway.States[LightA] = new() { EntityId = LightA, State = "on", Attributes = JsonSerializer.SerializeToElement(new
|
|
{ supported_color_modes = new[] { "color_temp" }, effect_list = new[] { "阅读" }, supported_features = 0 }) };
|
|
var controller = Controller(gateway);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(Device(), gateway.States, "light_effect", "阅读", null, default));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellationPreventsStandardWrite()
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State();
|
|
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Controller(gateway).ExecuteAsync(
|
|
Device(), gateway.States, "light_brightness", 50, null, cancellation.Token));
|
|
Assert.Empty(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoundSceneLightKeepsItsHooksButNewLightDoesNotTriggerThem()
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB);
|
|
var runtime = new CountingLight(); var handler = new CountingOffHandler();
|
|
var module = new LightingModule(Runner(gateway), Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = LightA } }),
|
|
runtime, new LightOffDispatcher([handler]));
|
|
var controller = Controller(gateway, module);
|
|
await controller.ExecuteAsync(Device(LightB), gateway.States, "light_brightness", 0, null, default);
|
|
Assert.Equal(0, runtime.Cancels); Assert.Equal(0, handler.Calls);
|
|
await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 0, null, default);
|
|
Assert.Equal(1, runtime.Cancels); Assert.Equal(1, handler.Calls);
|
|
Assert.Empty(gateway.Calls.Where(call => call.EntityId is not (LightA or LightB)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExistingSpecialParametersStillRouteOnlyToTheirOwningModule()
|
|
{
|
|
var gateway = new FakeHaGateway(); gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
|
|
var module = new SyntheticDeviceModule(Runner(gateway));
|
|
var device = new HomeAssistantDiscoveredDeviceOutput { DeviceId = "special", Entities = [new() { EntityId = "number.synthetic", Domain = "number" }] };
|
|
await Controller(gateway, module).ExecuteAsync(device, gateway.States, "synthetic_value", 3, null, default);
|
|
Assert.Equal("number.synthetic", Assert.Single(gateway.Calls).EntityId);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => Controller(gateway, module).ExecuteAsync(Device(), gateway.States, "synthetic_value", 3, null, default));
|
|
Assert.Single(gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void LightCapabilitiesSurviveActualApiSerializerWithoutExposingPackedInternalFields()
|
|
{
|
|
var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(State())!;
|
|
var json = JObject.Parse(Newtonsoft.Json.JsonConvert.SerializeObject(safe));
|
|
Assert.Equal(2700, json["min_color_temp_kelvin"]!.Value<int>());
|
|
Assert.Equal(6500, json["max_color_temp_kelvin"]!.Value<int>());
|
|
Assert.Equal("color_temp", json["supported_color_modes"]![0]!.Value<string>());
|
|
Assert.Equal("阅读", json["effect_list"]![0]!.Value<string>());
|
|
Assert.Null(json["internal_config"]);
|
|
}
|
|
|
|
private sealed class CountingOffHandler : ILightOffHandler
|
|
{
|
|
public int Calls;
|
|
public Task HandleAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); Calls++; return Task.CompletedTask; }
|
|
}
|
|
private sealed class CountingLight : ILightTransitionRuntime
|
|
{
|
|
public int Cancels;
|
|
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) { Cancels++; return Task.FromResult(new HomeAssistantLightTransitionOutput()); }
|
|
}
|
|
}
|