using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Lighting; using Evergreen.Health.Application.Health.Devices.Mattress; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Policies; using Evergreen.Health.Application.Health.Scenes; using Evergreen.Health.Application.Health.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Text.Json; using Xunit; namespace Admin.NET.Test.Health; /// 两盏灯和空调的真实模块组合,全部使用内存网关与可推进时钟,绝不控制实机。 public sealed class SceneLightingIntegrationTests { private const string Primary = "light.bedroom", Strip = "light.strip", Climate = "climate.room", Temperature = "sensor.indoor"; private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" }; private static HomeAssistantOptions Settings() => new() { Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature, MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat" }, AdditionalSleepLights = [StripSettings()], SleepScene = new() { EnableMattress = true } }; private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new() { EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new { supported_color_modes = new[] { "color_temp" }, min_color_temp_kelvin = 2700, max_color_temp_kelvin = 6500, supported_features = 4, effect_list = effects ?? ["阅读模式", "助眠模式"], brightness = 128, color_temp_kelvin = 3000 }) }; private static HomeAssistantState ClimateState(string mode = "off") => new() { EntityId = Climate, State = mode, Attributes = JsonSerializer.SerializeToElement(new { hvac_modes = new[] { "heat", "cool", "off", "fan_only" }, supported_features = 393, min_temp = 5, max_temp = 35, target_temp_step = .5, temperature_unit = "°C", temperature = 23, current_temperature = 99 // 故意与环境仪不同,不能误用空调内置温度进行舒睡判断。 }) }; private sealed class Handler : ILightOffHandler { public int Calls; public Task HandleAsync(CancellationToken token) { Calls++; return Task.CompletedTask; } } private sealed class Fixture { public ManualTestTimeProvider Time { get; } = new(); public FakeHaGateway Gateway { get; } public IOptions Options { get; } = Microsoft.Extensions.Options.Options.Create(Settings()); public Handler Off { get; } = new(); public HomeAssistantLightTransitionService Lights { get; } public DeviceActionRunner Runner { get; } public DeviceModuleRegistry Modules { get; } public HealthSceneCoordinator Coordinator { get; } public Fixture(bool mattress = false) { Gateway = new() { Time = Time }; Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip); Gateway.States[Climate] = ClimateState(); foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Gateway.States[id] = new() { EntityId = id, State = "0" }; Runner = new(Gateway, Time, new() { VerificationDelay = TimeSpan.Zero }); var dispatcher = new LightOffDispatcher([Off]); Lights = new(Gateway, dispatcher, Options, NullLogger.Instance, new(), Time); var modules = new List { new LightingModule(Runner, Options, Lights.ForEntity(Primary), dispatcher), new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)), new ClimateModule(Runner, Options) }; if (mattress) modules.Add(new MattressModule(Runner, Options)); Modules = new(modules); Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic()); } public PreparedSleepScene Prepare(double? temperature) { if (temperature is not null) Gateway.States[Temperature] = new() { EntityId = Temperature, State = temperature.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) }; else Gateway.States.TryRemove(Temperature, out _); var comfort = new MattressComfortService(Gateway, new IndoorEnvironmentReader(Options, Time), Options) .Get(null, Gateway.States); var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 }; MattressComfortService.ApplyTo(config, comfort); RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); return new(config, "balanced"); } } private static async Task WaitUntilAsync(Func predicate) { for (var attempt = 0; attempt < 1000 && !predicate(); attempt++) await Task.Delay(1); Assert.True(predicate(), "模拟渐变任务未按时推进"); } private static async Task CompleteAsync(Task task, ManualTestTimeProvider time) { for (var attempt = 0; attempt < 1000 && !task.IsCompleted; attempt++) { time.Advance(TimeSpan.FromMilliseconds(100)); await Task.Delay(1); } Assert.True(task.IsCompleted, "场景未在模拟时间中完成"); return await task; } [Fact] public async Task SceneStartsBothLightsAtFiftyAndSetsOnlyStripsOwnSleepEffect() { var f = new Fixture(); try { var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); Assert.True(result.Success); Assert.Equal(new[] { Primary, Strip }, f.Gateway.Calls.Where(call => call.Domain == "light").Select(call => call.EntityId)); var strip = f.Gateway.Calls.Single(call => call.EntityId == Strip); Assert.Equal("助眠模式", strip.Data!["effect"]); Assert.Equal(50, strip.Data["brightness_pct"]); Assert.Equal(3000, strip.Data["color_temp_kelvin"]); Assert.False(f.Gateway.Calls.Single(call => call.EntityId == Primary).Data!.ContainsKey("effect")); f.Time.Advance(TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == 49)); Assert.Equal(2, f.Lights.GetStatus().Lights.Count); Assert.All(f.Lights.GetStatus().Lights, light => Assert.True(light.Running)); } finally { await f.Lights.StopAsync(default); } } [Fact] public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsMassageShutdownHook() { var f = new Fixture(); try { await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); for (var step = 1; step <= 50; step++) { f.Time.Advance(TimeSpan.FromSeconds(5)); var expected = 50 - step; await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == expected && (expected != 0 || !light.Running))); } Assert.Equal("off", f.Gateway.States[Primary].State); Assert.Equal("off", f.Gateway.States[Strip].State); Assert.Equal(1, f.Off.Calls); } finally { await f.Lights.StopAsync(default); } } [Fact] public async Task AllOffCancelsBothTimersAndShutsClimateWithoutLateReopening() { var f = new Fixture(); try { await f.Coordinator.ExecuteAsync("sleep", f.Prepare(30).Config, "balanced", default); var result = await f.Coordinator.ExecuteAsync("all-off", null, "", default); Assert.True(result.Success); Assert.All(new[] { Primary, Strip, Climate }, id => Assert.Equal("off", f.Gateway.States[id].State)); Assert.All(f.Lights.GetStatus().Lights, light => Assert.False(light.Running)); var count = f.Gateway.Calls.Count; f.Time.Advance(TimeSpan.FromMinutes(5)); await Task.Delay(5); Assert.Equal(count, f.Gateway.Calls.Count); } finally { await f.Lights.StopAsync(default); } } [Theory] [InlineData(15, "heat", 1)] [InlineData(30, "cool", 0)] [InlineData(22, "off", 0)] [InlineData(24, "off", 0)] [InlineData(25, "off", 0)] public async Task EnvironmentSensorDrivesActualSceneClimateAndBothHeatingLevels(double room, string mode, int heating) { var f = new Fixture(mattress: true); try { var prepared = f.Prepare(room); var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time); Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode); Assert.False(result.ClimateAdvice.PendingIntegration); foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Assert.Equal(heating.ToString(), f.Gateway.States[id].State); if (mode == "off") { Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius); Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate); } else { Assert.Equal(mode, f.Gateway.States[Climate].State); Assert.InRange(result.ClimateAdvice.TargetTemperatureCelsius!.Value, 22, 25); Assert.Equal(result.ClimateAdvice.TargetTemperatureCelsius.Value, f.Gateway.States[Climate].GetDoubleAttribute("temperature")); Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, f.Gateway.Calls.Where(call => call.EntityId == Climate).Select(call => call.Service)); } } finally { await f.Lights.StopAsync(default); } } [Fact] public async Task MissingOrStaleEnvironmentNeverStartsClimateOrHeating() { var f = new Fixture(); try { var prepared = f.Prepare(null); var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default); Assert.Equal("unavailable", result.ClimateAdvice!.Mode); Assert.Equal(0, prepared.Config.MattressHeatingLevel); Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate); f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31)); var comfort = new MattressComfortService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States); Assert.Equal("unavailable", comfort.ClimateAdvice.Mode); Assert.Equal(0, comfort.HeatingLevel); } finally { await f.Lights.StopAsync(default); } } [Theory] [InlineData(true)] [InlineData(false)] public async Task ManualStandardControlCancelsOnlyThatLightsFade(bool strip) { var f = new Fixture(); try { await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); var id = strip ? Strip : Primary; var other = strip ? Primary : Strip; var device = new HomeAssistantDiscoveredDeviceOutput { DeviceId = "manual", Online = true, PrimaryControlEntityId = id, Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }] }; var parameters = new DeviceParameterController(f.Modules, f.Runner, [new StandardLightControl()]); await parameters.ExecuteAsync(device, f.Gateway.States, "light_brightness", 77, id, default); Assert.False(f.Lights.ForEntity(id).GetStatus().Running); Assert.True(f.Lights.ForEntity(other).GetStatus().Running); f.Time.Advance(TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => f.Lights.ForEntity(other).GetStatus().BrightnessPercent == 49); Assert.Equal(77d / 100 * 255, f.Gateway.States[id].GetDoubleAttribute("brightness")); } finally { await f.Lights.StopAsync(default); } } [Theory] [InlineData(Primary, Strip)] [InlineData(Strip, Primary)] public async Task OfflineLightDoesNotBlockOtherSceneLight(string offline, string active) { var f = new Fixture(); f.Gateway.States[offline] = Light(offline, "unavailable"); try { var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); Assert.True(result.Success); Assert.True(f.Lights.ForEntity(active).GetStatus().Running); Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == offline); Assert.Contains(result.Actions, action => action.EntityId == offline && action.Status == "Skipped"); } finally { await f.Lights.StopAsync(default); } } [Fact] public async Task UnsupportedDefaultEffectFailsBeforeStripTurnsOnAndDoesNotStopOtherLight() { var f = new Fixture(); f.Gateway.States[Strip] = Light(Strip, effects: ["阅读模式"]); try { var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); Assert.Equal("Partial", result.Status); Assert.True(f.Lights.ForEntity(Primary).GetStatus().Running); Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Strip); Assert.Contains(result.Actions, action => action.EntityId == Strip && action.Status == "Failed"); } finally { await f.Lights.StopAsync(default); } } [Fact] public void ModuleDiscoveryAndPreviewExposeOwnBindingAndDefaultWithoutHardwareWrites() { var f = new Fixture(); var catalog = new HomeAssistantDeviceCatalog(f.Options, f.Modules); var discovered = catalog.BuildDiscoveredDevices(null, f.Gateway.States); var strip = Assert.Single(discovered.Where(device => device.PrimaryControlEntityId == Strip)); Assert.Equal("sleep-light-strip", strip.ModuleKey); Assert.Equal("助眠模式", strip.DefaultLightEffect); Assert.Null(discovered.Single(device => device.PrimaryControlEntityId == Primary).DefaultLightEffect); var preview = f.Modules.ForEntities([Strip])!.BuildPreview(new(), "balanced")!; Assert.Equal(Strip, preview.EntityId); Assert.Contains("每5秒降低1%", preview.Detail); Assert.Empty(f.Gateway.Calls); } [Fact] public void ProductionRegistrationAddsConfiguredStripWithoutCreatingASecondDeviceControlImplementation() { var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient(); services.AddSingleton>(Options.Create(Settings())); services.AddHealthDevices(); services.AddSingleton(new FakeHaGateway()); services.AddSingleton(new FakeAudioGateway()); using var provider = services.BuildServiceProvider(); var registry = provider.GetRequiredService(); Assert.IsType(registry.ForEntities([Strip])); Assert.IsType(registry.ForEntities([Primary])); Assert.IsType(registry.ForEntities([Climate])); } [Fact] public void DuplicateSceneLightEntityCannotCreateCompetingTimers() { var options = Settings(); options.AdditionalSleepLights.Add(new() { Key = "duplicate", Name = "重复灯", EntityId = Strip }); Assert.Throws(() => new HomeAssistantLightTransitionService(new FakeHaGateway(), new([]), Options.Create(options), NullLogger.Instance, new(), TimeProvider.System)); } }