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; /// 标准能力、实例隔离和专属钩子全用假网关验证,不操作真实灯具。 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(() => 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(() => controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default)); await Assert.ThrowsAsync(() => 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(() => 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(() => 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(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_color_temperature", 2200, null, default)); await Assert.ThrowsAsync(() => 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(() => 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(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, LightB, default)); device.Entities.RemoveAt(0); await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default)); device = Device(); device.Entities[0].Domain = "switch"; await Assert.ThrowsAsync(() => 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 { [LightA] = State() }; await Assert.ThrowsAsync(() => 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(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default)); await Assert.ThrowsAsync(() => 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(() => 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(() => 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()); Assert.Equal(6500, json["max_color_temp_kelvin"]!.Value()); Assert.Equal("color_temp", json["supported_color_modes"]![0]!.Value()); Assert.Equal("阅读", json["effect_list"]![0]!.Value()); 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 StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput()); public Task StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput()); public Task CancelAsync(CancellationToken ct) { Cancels++; return Task.FromResult(new HomeAssistantLightTransitionOutput()); } } }