using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Integrations.Tuya; using Evergreen.Health.Application.Health.Policies; using Evergreen.Health.Application.Health.Scenes; using Evergreen.Health.Application.Health.Sleep; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Text.Json; using Xunit; namespace Admin.NET.Test.Health; public sealed class SafetyAndReservedWorkflowTests { private static readonly DateTimeOffset Now = new(2026, 10, 8, 0, 0, 0, TimeSpan.Zero); private static SleepObservation Reading(DateTimeOffset at, BedPresence presence = BedPresence.Occupied, SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence); private static HomeAssistantOptions Configuration() => new() { Entities = new() { AmbientLight = "light.bedroom" } }; private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new() { EntityId = "light.bedroom", State = state, LastUpdated = updated, Attributes = JsonSerializer.SerializeToElement(new { brightness }) }; [Fact] public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork() { var observation = await new ReservedJe01Source().ReadAsync(default); Assert.Equal(BedPresence.Unknown, observation.Presence); Assert.Equal(SleepDataAvailability.Unconfigured, observation.Availability); var clip = new GuidanceCatalog().InitialRelaxation; Assert.False(clip.Playable); Assert.False((await new ReservedGuidancePlayer().PlayAsync(clip, 50, default)).Completed); var tuya = new ReservedTuyaGateway(); Assert.False(tuya.IsConfigured); await Assert.ThrowsAsync(() => tuya.WriteAsync(new("fake", "switch", true), default)); } [Fact] public void DefaultWorkflowNeverPlansBedOrDeviceActions() { var workflow = new ReservedSleepWorkflow(new(), new()); Assert.Empty(workflow.Start()); Assert.Empty(workflow.Observe(Reading(Now), Now.AddMinutes(1))); Assert.Empty(workflow.Tick(Now.AddMinutes(10))); Assert.Equal(ReservedSleepPhase.Disabled, workflow.Phase); } [Theory] [InlineData(BedPresence.Unknown, SleepDataAvailability.Available, 0)] [InlineData(BedPresence.Empty, SleepDataAvailability.Available, 0)] [InlineData(BedPresence.Occupied, SleepDataAvailability.Offline, 0)] [InlineData(BedPresence.Occupied, SleepDataAvailability.Stale, 0)] [InlineData(BedPresence.Occupied, SleepDataAvailability.Available, 121)] public void UnknownOfflineOrStaleInputResetsContinuousBedWait(BedPresence presence, SleepDataAvailability availability, int age) { var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new()); Assert.Equal(SleepWorkflowActionKind.PrepareEnvironment, Assert.Single(workflow.Start()).Kind); Assert.Empty(workflow.Observe(Reading(Now), Now)); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29 - age), presence, availability), Now.AddSeconds(29))); Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30))); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59))); Assert.Equal(1, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count); } [Fact] public void PlaceholderCompletionCannotStartMusicOrLightFade() { var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new()); workflow.Start(); workflow.Observe(Reading(Now), Now); workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)); Assert.Empty(workflow.GuidanceCompleted("initial-relaxation", true, Now.AddSeconds(40))); Assert.Empty(workflow.Tick(Now.AddMinutes(5))); Assert.Equal(ReservedSleepPhase.Guiding, workflow.Phase); } [Fact] public void RealFutureGuideRestartsAtFiftyAndReminderDoesNotRestartCompletedLampFade() { var clip = new GuidanceClip("guide", "测试", "https://example.invalid/guide.mp3", 20); var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new(clip)); workflow.Start(); workflow.Observe(Reading(Now), Now); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29))); var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)); Assert.Single(actions); Assert.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent); Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50))); var music = Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddSeconds(50))); Assert.Equal(SleepWorkflowActionKind.RestartMusicAtFifty, music.Kind); Assert.Equal(50, music.VolumePercent); Assert.Empty(workflow.Tick(Now.AddSeconds(79))); Assert.Equal(SleepWorkflowActionKind.StartLightFade, Assert.Single(workflow.Tick(Now.AddSeconds(80))).Kind); Assert.Empty(workflow.Tick(Now.AddSeconds(81))); Assert.Single(workflow.RequestReminder(clip)); Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddMinutes(6))); Assert.Empty(workflow.Tick(Now.AddMinutes(7))); workflow.Stop(); Assert.Empty(workflow.Observe(Reading(Now.AddMinutes(8)), Now.AddMinutes(8))); Assert.Empty(workflow.RequestReminder(clip)); } [Fact] public void SameAnomalyCooldownOnlyStartsAfterActualDelivery() { var gate = new AnomalyReminderGate(new()); Assert.True(gate.CanRemind("restless", Now)); Assert.True(gate.CanRemind("restless", Now.AddMinutes(1))); gate.MarkDelivered("restless", Now); Assert.False(gate.CanRemind("RESTLESS", Now.AddMinutes(29))); Assert.True(gate.CanRemind("restless", Now.AddMinutes(30))); Assert.True(gate.CanRemind("another", Now)); } [Fact] public async Task NaturalFadeToZeroOnlyControlsTheLight() { var time = new ManualTestTimeProvider(); var gateway = new FakeHaGateway { Time = time }; gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow()); var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero }); var safety = new SceneLightSafetyState(); var light = new HomeAssistantLightTransitionService(gateway, new([]), Options.Create(Configuration()), NullLogger.Instance, safety, time); await light.StartSleepAsync(2700, default); for (var step = 1; step <= 50; step++) { time.Advance(TimeSpan.FromSeconds(5)); var expected = 50 - step; await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running)); } Assert.Equal("off", gateway.States["light.bedroom"].State); Assert.NotNull(safety.OffAt); Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId)); await light.StopAsync(default); } [Fact] public async Task TaskCancellationAloneCannotCountAsDeviceSceneSuccess() { var gateway = new FakeHaGateway(); var runner = new DeviceActionRunner(gateway, TimeProvider.System, new()); var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), new FakeMusic()); Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status); } [Fact] public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized() { var advice = RoomTemperaturePolicy.Evaluate(new(15, Now, true, ""), new(22, 25)); Assert.Null(advice.TargetTemperatureCelsius); RoomTemperaturePolicy.PickTargetForSession(advice); var initial = advice.TargetTemperatureCelsius; for (var i = 0; i < 20; i++) RoomTemperaturePolicy.PickTargetForSession(advice); Assert.Equal(initial, advice.TargetTemperatureCelsius); } [Fact] public async Task GatewayTimeoutIsFailureNotUserCancellationSoOtherDevicesCanContinue() { var ha = new HomeAssistantClient(new HttpClient(new TimedOutHandler()), Options.Create(new HomeAssistantOptions { Enabled = true, BaseUrl = "https://example.invalid", AccessToken = "fake-test-token" })); await Assert.ThrowsAsync(() => ha.GetStatesAsync()); var audio = new MiAirXClient(new HttpClient(new TimedOutHandler()), Options.Create(new MiAirXOptions { Enabled = true, BaseUrl = "https://example.invalid", DeviceId = "fake-test-device", RequireWebPassword = false })); await Assert.ThrowsAsync(() => audio.StopAsync()); using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); await Assert.ThrowsAnyAsync(() => ha.GetStatesAsync(cancelled.Token)); await Assert.ThrowsAnyAsync(() => audio.StopAsync(cancelled.Token)); } [Fact] public async Task UnintegratedCurtainNeverSendsOrReportsSuccessfulControl() { var gateway = new FakeHaGateway(); var runner = new DeviceActionRunner(gateway, TimeProvider.System, new()); var module = new Evergreen.Health.Application.Health.Devices.Curtain.CurtainModule(runner, Options.Create(new HomeAssistantOptions())); var context = new DeviceSceneContext(runner, new Dictionary(), []); await module.ExecuteSleepAsync(context, new(), "balanced", default); Assert.Equal("Skipped", Assert.Single(context.Results).Status); Assert.Empty(gateway.Calls); Assert.Empty(module.GetShutdownCommands()); } [Fact] public async Task AllOffAlsoCancelsOldPreparationBeforeAnyDeviceCanStart() { var gateway = new FakeHaGateway(); var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero }); var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]), runner, gateway, new FakeLight(), new FakeMusic()); var preparing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var start = coordinator.ExecutePreparedSleepAsync(async token => { preparing.SetResult(); await Task.Delay(Timeout.InfiniteTimeSpan, token); return new(new(), "balanced"); }, default); await preparing.Task; var off = await coordinator.ExecuteAsync("all-off", null, "", default); Assert.Equal("Cancelled", (await start).Status); Assert.Equal("Succeeded", off.Status); Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value"); } [Fact] public async Task MissingConstitutionInPreparationDoesNotExecuteOrCountCancellationAsSuccess() { var gateway = new FakeHaGateway(); var runner = new DeviceActionRunner(gateway, TimeProvider.System, new()); var music = new FakeMusic(); var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), music); var output = await coordinator.ExecutePreparedSleepAsync(_ => throw new ScenePreparationException("先完成四诊"), default); Assert.False(output.Success); Assert.Equal("AssessmentRequired", output.Status); Assert.Equal("先完成四诊", output.Message); Assert.Empty(gateway.Calls); Assert.Equal(0, music.StopCount); } private sealed class TimedOutHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromException(new TaskCanceledException("模拟网络超时")); } private static async Task WaitUntilAsync(Func predicate) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); while (!predicate()) await Task.Delay(5, timeout.Token); } }