161 lines
8.4 KiB
C#
161 lines
8.4 KiB
C#
using Evergreen.Health.Application.Health.Devices.Curtain;
|
|
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 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); }
|
|
}
|
|
|
|
[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<InvalidOperationException>(() => 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<HomeAssistantLightTransitionService>.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<string> ExecuteAsync(string entityId, CancellationToken token)
|
|
{ Interlocked.Increment(ref Calls); throw new InvalidOperationException("Preview cannot execute"); }
|
|
}
|
|
|
|
private sealed class PreviewLogger : ILogger<BedPresenceAutomation>
|
|
{
|
|
public TaskCompletionSource<string> Preview { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
var message = formatter(state, exception);
|
|
if (message.StartsWith("Bed presence preview:", StringComparison.Ordinal)) Preview.TrySetResult(message);
|
|
}
|
|
}
|
|
}
|