using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Curtain; using Evergreen.Health.Application.Health.Devices.Dehumidifier; using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Lighting; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Scenes; using Evergreen.Health.Application.Health.Sleep; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Text.Json; using Xunit; namespace Admin.NET.Test.Health; public sealed class BedPresenceAutomationTests { private const string Binding = "a98788f2aeeb40b68f145a182d99f832"; private const string Light = "light.test_bedroom"; private const string Curtain = "cover.test_curtain"; private const string Dehumidifier = "humidifier.test"; private const string Humidity = "sensor.test_humidity"; private const string Climate = "climate.test"; private const string Temperature = "sensor.test_temperature"; private static BedPresenceSample Sample(BedPresence presence, DateTimeOffset at, Guid connection) => new(Binding, "evergreen-test", "IB43SIM001", BedSide.Left, true, presence, at, connection); [Fact] public void Edge_requires_fresh_empty_baseline_on_the_same_bed_and_connection() { var edge = new BedPresenceEdge(); var at = DateTimeOffset.UtcNow; var connection = Guid.NewGuid(); Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at)); Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at)); Assert.True(edge.Observe(Sample(BedPresence.Occupied, at, connection), at)); Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at)); Assert.False(edge.Observe(Sample(BedPresence.Empty, at.AddSeconds(-1), connection), at)); Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at)); Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at)); Assert.False(edge.Observe(Sample(BedPresence.Occupied, at.AddSeconds(61), connection), at.AddSeconds(61))); Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at.AddSeconds(61))); Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, Guid.NewGuid()), at)); } [Fact] public async Task Preview_requires_configured_simulation_identity_and_never_executes_action() { var options = Options.Create(new BedPresenceAutomationOptions { Mode = BedAutomationMode.Preview, BindingId = Binding, ActionKey = "curtain-close", TargetEntityId = Curtain }); var action = new NeverExecuteAction(); var logger = new PreviewLogger(); using var automation = new BedPresenceAutomation(options, [action], TimeProvider.System, logger); await automation.StartAsync(default); try { var connection = Guid.NewGuid(); var at = DateTimeOffset.UtcNow; var occupied = Sample(BedPresence.Occupied, at, connection); var empty = occupied with { Presence = BedPresence.Empty }; // 这些样本即使构成上床边沿也不能进入规则。 Assert.False(options.Value.Matches(empty with { Side = BedSide.Right })); Assert.False(options.Value.Matches(empty with { Simulation = false })); Assert.False(options.Value.Matches(empty with { BindingId = "someone-else" })); automation.Observe(occupied, default); automation.Observe(empty, default); automation.Observe(occupied, default); await logger.Preview.Task.WaitAsync(TimeSpan.FromSeconds(3)); Assert.Contains("Action=curtain-close", logger.Preview.Task.Result); Assert.Contains(Curtain, logger.Preview.Task.Result); Assert.Equal(0, action.Calls); } finally { await automation.StopAsync(default); } } [Fact] public async Task Stopping_and_reopening_rule_cancels_the_old_action_waiting_for_persistence() { var time = new ManualTestTimeProvider(); var options = Options.Create(new BedPresenceAutomationOptions { Mode = BedAutomationMode.Managed, ActionKey = "curtain-close", TargetEntityId = Curtain }); var action = new NeverExecuteAction(); var executionLog = new PausedExecutionLog(); using var automation = new BedPresenceAutomation(options, [action], time, NullLogger.Instance, executionLog); await automation.StartAsync(default); try { var bed = new BedAutomationBinding(Binding, 1, "evergreen-test", "IB43SIM001", BedSide.Left, true); var connection = Guid.NewGuid(); Assert.True(automation.TryEnable(bed)); automation.Observe(Sample(BedPresence.Empty, time.GetUtcNow(), connection), default); automation.Observe(Sample(BedPresence.Occupied, time.GetUtcNow(), connection), default); await executionLog.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); automation.Disable(Binding, 1); Assert.True(automation.TryEnable(bed)); executionLog.Release.TrySetResult("old-execution"); Assert.Equal("cancelled-unconfirmed", await executionLog.Completed.Task.WaitAsync(TimeSpan.FromSeconds(3))); Assert.Equal(0, action.Calls); Assert.True(automation.IsEnabled(Binding, 1)); } finally { executionLog.Release.TrySetResult(null); await automation.StopAsync(default); } } private sealed class PausedExecutionLog : IBedPresenceExecutionLog { public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token) { Started.TrySetResult(); return Release.Task; } public Task CompleteAsync(string id, string result, DateTimeOffset completedAt) { Completed.TrySetResult(result); return Task.CompletedTask; } } [Theory] [InlineData("26", "°C", 0, false, "preview-climate-cool")] [InlineData("21", "°C", 0, false, "preview-climate-heat")] [InlineData("82.4", "°F", 0, false, "preview-climate-cool")] [InlineData("22", "°C", 0, false, "preview-temperature-within-range")] [InlineData("25", "°C", 0, false, "preview-temperature-within-range")] [InlineData(null, "°C", 0, false, "preview-temperature-unavailable")] [InlineData("26", "°C", 0, true, "preview-temperature-unavailable")] [InlineData("26", "°C", 31, false, "preview-temperature-stale")] [InlineData("26", "°C", null, false, "preview-temperature-stale")] [InlineData("26", "%", 0, false, "preview-temperature-invalid")] [InlineData("NaN", "°C", 0, false, "preview-temperature-invalid")] public async Task Climate_preview_uses_the_default_range_and_never_sends_commands( string? state, string unit, int? ageMinutes, bool readFails, string expected) { var clock = new ManualTestTimeProvider(); var gateway = new FakeHaGateway { ReadsToFail = readFails ? 1 : 0 }; if (state is not null) gateway.States[Temperature] = new() { EntityId = Temperature, State = state, LastReported = ageMinutes is { } age ? clock.GetUtcNow().AddMinutes(-age) : null, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = unit }) }; var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = Climate, Temperature = Temperature } }); var preview = new BedPresenceClimatePreview(gateway, new IndoorEnvironmentReader(options, clock), options); Assert.True(preview.PreviewOnly); Assert.Equal("target-not-configured", await preview.ExecuteAsync("climate.other", default)); Assert.Equal(expected, await preview.ExecuteAsync(Climate, default)); Assert.Empty(gateway.Calls); } [Theory] [InlineData("64", "%", 0, "preview-would-start")] [InlineData("55", "%", 0, "preview-humidity-normal")] [InlineData("45", "%", 0, "preview-humidity-normal")] [InlineData(null, "%", 0, "preview-humidity-unavailable")] [InlineData("unavailable", "%", 0, "preview-humidity-unavailable")] [InlineData("64", "%", 31, "preview-humidity-stale")] [InlineData("64", "%", -2, "preview-humidity-stale")] [InlineData("64", "%", null, "preview-humidity-stale")] [InlineData("64", "°C", 0, "preview-humidity-invalid")] [InlineData("NaN", "%", 0, "preview-humidity-invalid")] [InlineData("101", "%", 0, "preview-humidity-invalid")] public async Task Dehumidifier_preview_records_humidity_decision_without_sending_commands( string? state, string unit, int? ageMinutes, string expected) { var clock = new ManualTestTimeProvider(); var gateway = new FakeHaGateway(); if (state is not null) gateway.States[Humidity] = new() { EntityId = Humidity, State = state, LastUpdated = ageMinutes is { } age ? clock.GetUtcNow().AddMinutes(-age) : null, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = unit }) }; var options = Options.Create(new HomeAssistantOptions { Entities = new() { Dehumidifier = Dehumidifier, Humidity = Humidity }, SleepScene = new() { TargetHumidity = 55 } }); var preview = new BedPresenceDehumidifierPreview(gateway, options, clock); Assert.True(preview.PreviewOnly); Assert.Equal("target-not-configured", await preview.ExecuteAsync("humidifier.other", default)); Assert.Equal(expected, await preview.ExecuteAsync(Dehumidifier, default)); Assert.Empty(gateway.Calls); } [Theory] [InlineData("64", 0, "off", "on", "readback-confirmed", 1)] [InlineData("64", 0, "off", "off", "sent-readback-unconfirmed", 1)] [InlineData("55", 0, "off", "on", "humidity-normal", 0)] [InlineData("64", 31, "off", "on", "humidity-stale", 0)] [InlineData(null, 0, "off", "on", "humidity-unavailable", 0)] [InlineData("101", 0, "off", "on", "humidity-invalid", 0)] [InlineData("64", 0, "on", "on", "dehumidifier-already-on", 0)] [InlineData("64", 0, "unavailable", "on", "dehumidifier-unavailable", 0)] [InlineData("64", 0, "unknown-state", "on", "dehumidifier-state-not-off", 0)] public async Task Dehumidifier_rule_requires_fresh_high_humidity_and_off_state_and_never_resends( string? humidity, int ageMinutes, string power, string readback, string result, int commandCount) { var clock = new ManualTestTimeProvider(); var gateway = new FakeHaGateway { HumidifierTurnOnResult = readback }; gateway.States[Dehumidifier] = new() { EntityId = Dehumidifier, State = power }; if (humidity is not null) gateway.States[Humidity] = new() { EntityId = Humidity, State = humidity, LastReported = clock.GetUtcNow().AddMinutes(-ageMinutes), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) }; var options = Options.Create(new HomeAssistantOptions { Entities = new() { Dehumidifier = Dehumidifier, Humidity = Humidity }, SleepScene = new() { TargetHumidity = 55 } }); var runner = new DeviceActionRunner(gateway, clock, new() { VerificationDelay = TimeSpan.Zero }); var action = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, options), runner, options, clock); Assert.Equal("target-not-configured", await action.ExecuteAsync("humidifier.other", default)); Assert.Equal(result, await action.ExecuteAsync(Dehumidifier, default)); Assert.Equal(commandCount, gateway.Calls.Count); Assert.All(gateway.Calls, command => { Assert.Equal(Dehumidifier, command.EntityId); Assert.Equal("humidifier", command.Domain); Assert.Equal("turn_on", command.Service); Assert.Equal("entity_id", Assert.Single(command.Data!).Key); }); } [Theory] [InlineData("closed", "already-closed")] [InlineData("closing", "already-closing")] [InlineData("opening", "state-not-open")] public async Task Curtain_action_leaves_closed_or_moving_curtain_alone(string state, string expected) { var (action, gateway) = CurtainFixture(state, 2, "closed"); Assert.Equal(expected, await action.ExecuteAsync(Curtain, default)); Assert.Empty(gateway.Calls); } [Theory] [InlineData("closed", "readback-confirmed")] [InlineData("closing", "sent-readback-unconfirmed")] public async Task Curtain_action_sends_one_close_and_waits_for_closed_readback(string reported, string expected) { var (action, gateway) = CurtainFixture("open", 2, reported); Assert.Equal("target-not-configured", await action.ExecuteAsync("cover.other", default)); Assert.Equal(expected, await action.ExecuteAsync(Curtain, default)); var command = Assert.Single(gateway.Calls); Assert.Equal(Curtain, command.EntityId); Assert.Equal("close_cover", command.Service); } [Fact] public async Task Curtain_action_requires_close_capability_before_sending() { var (action, gateway) = CurtainFixture("open", 1, "closed"); await Assert.ThrowsAsync(() => action.ExecuteAsync(Curtain, default)); Assert.Empty(gateway.Calls); } private static (BedPresenceCurtainCloseAction, FakeHaGateway) CurtainFixture(string state, int features, string closeResult) { var gateway = new FakeHaGateway { CoverCloseResult = closeResult }; gateway.States[Curtain] = new() { EntityId = Curtain, State = state, Attributes = JsonSerializer.SerializeToElement(new { supported_features = features }) }; var options = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = Curtain } }); var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero }); return (new(new CurtainModule(runner, options), runner, options), gateway); } [Fact] public async Task Light_action_starts_only_target_and_verifies_brightness_without_restarting_active_fade() { var gateway = new FakeHaGateway(); var time = new ManualTestTimeProvider(); gateway.States[Light] = new() { EntityId = Light, State = "off", Attributes = JsonSerializer.SerializeToElement(new { }) }; gateway.States["light.other"] = new() { EntityId = "light.other", State = "off" }; var options = Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = Light } }); var lights = new HomeAssistantLightTransitionService(gateway, new LightOffDispatcher([]), options, NullLogger.Instance, new(), time); var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero }; var action = new BedPresenceSleepLightAction(lights, new DeviceActionRunner(gateway, time, execution), execution, options); try { Assert.Equal("target-not-configured", await action.ExecuteAsync("light.other", default)); Assert.Empty(gateway.Calls); Assert.Equal("readback-confirmed", await action.ExecuteAsync(Light, default)); Assert.Equal("already-running", await action.ExecuteAsync(Light, default)); var call = Assert.Single(gateway.Calls); Assert.Equal(Light, call.EntityId); Assert.Equal(50, call.Data!["brightness_pct"]); Assert.Equal("off", gateway.States["light.other"].State); } finally { await lights.StopAsync(default); } } private sealed class NeverExecuteAction : IBedPresenceAction { public int Calls; public string Key => "curtain-close"; public string Description => "关闭窗帘"; public bool SupportsTarget(string entityId) => entityId == Curtain; public Task ExecuteAsync(string entityId, CancellationToken token) { Interlocked.Increment(ref Calls); throw new InvalidOperationException("Preview cannot execute"); } } private sealed class PreviewLogger : ILogger { public TaskCompletionSource Preview { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public IDisposable BeginScope(TState state) where TState : notnull => null!; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = formatter(state, exception); if (message.StartsWith("Bed presence preview:", StringComparison.Ordinal)) Preview.TrySetResult(message); } } }