239 lines
12 KiB
C#
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);
|
|
}
|
|
}
|