Files
Evergreen-Health/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs
2026-11-09 16:10:05 +08:00

239 lines
12 KiB
C#

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<NotSupportedException>(() => 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<HomeAssistantLightTransitionService>.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<TimeoutException>(() => 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<TimeoutException>(() => audio.StopAsync());
using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ha.GetStatesAsync(cancelled.Token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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<string, HomeAssistantState>(), []);
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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromException<HttpResponseMessage>(new TaskCanceledException("模拟网络超时"));
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (!predicate()) await Task.Delay(5, timeout.Token);
}
}