111111
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
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;
|
||||
@@ -16,6 +19,10 @@ 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);
|
||||
|
||||
@@ -67,6 +74,158 @@ public sealed class BedPresenceAutomationTests
|
||||
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<BedPresenceAutomation>.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<string?> Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource<string> Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task<string?> 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")]
|
||||
|
||||
@@ -3,6 +3,7 @@ using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
@@ -16,11 +17,25 @@ namespace Admin.NET.Test.Health;
|
||||
public sealed class ClimateCurtainIntegrationTests
|
||||
{
|
||||
private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain";
|
||||
private static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId } };
|
||||
private const string RoomTemperatureId = "sensor.test_room_temperature";
|
||||
private static HomeAssistantOptions Settings() => new()
|
||||
{ Entities = new() { Climate = ClimateId, Curtain = CurtainId, Temperature = RoomTemperatureId } };
|
||||
private static DeviceActionRunner Runner(Gateway gateway) => new(gateway, TimeProvider.System,
|
||||
new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
|
||||
private static ClimateModule Climate(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
|
||||
private static CurtainModule Curtain(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
|
||||
private static BedPresenceClimateHeatAction HeatAutomation(Gateway gateway)
|
||||
{
|
||||
var runner = Runner(gateway);
|
||||
var options = Options.Create(Settings());
|
||||
return new(new ClimateModule(runner, options), new IndoorEnvironmentReader(options, TimeProvider.System), runner, options);
|
||||
}
|
||||
|
||||
private static HomeAssistantState RoomTemperature(string celsius = "20", int ageMinutes = 0) => new()
|
||||
{
|
||||
EntityId = RoomTemperatureId, State = celsius, LastReported = DateTimeOffset.UtcNow.AddMinutes(-ageMinutes),
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
};
|
||||
private static HomeAssistantState ClimateState(string state = "off", int features = 393,
|
||||
double minimum = 5, double maximum = 35, double step = .5) => new()
|
||||
{
|
||||
@@ -43,6 +58,64 @@ public sealed class ClimateCurtainIntegrationTests
|
||||
TargetTemperatureCelsius = target, Reason = "测试室温建议" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_sends_each_command_once_and_confirms_heat_and_22_degrees()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature());
|
||||
var action = HeatAutomation(gateway);
|
||||
Assert.Equal("target-not-configured", await action.ExecuteAsync("climate.other", default));
|
||||
Assert.Equal("readback-confirmed", await action.ExecuteAsync(ClimateId, default));
|
||||
Assert.Collection(gateway.Calls,
|
||||
mode => { Assert.Equal("set_hvac_mode", mode.Service); Assert.Equal("heat", mode.Data!["hvac_mode"]); },
|
||||
temperature => { Assert.Equal("set_temperature", temperature.Service); Assert.Equal(22m, temperature.Data!["temperature"]); });
|
||||
Assert.Equal("heat", gateway.States[ClimateId].State);
|
||||
Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("22", 0, "off", 5, "temperature-not-low")]
|
||||
[InlineData("27", 0, "off", 5, "temperature-not-low")]
|
||||
[InlineData("20", 31, "off", 5, "temperature-stale")]
|
||||
[InlineData("NaN", 0, "off", 5, "temperature-invalid")]
|
||||
[InlineData(null, 0, "off", 5, "temperature-unavailable")]
|
||||
[InlineData("20", 0, "cool", 5, "climate-not-off")]
|
||||
[InlineData("20", 0, "unavailable", 5, "climate-unavailable")]
|
||||
[InlineData("20", 0, "off", 24, "climate-heat-unsupported")]
|
||||
public async Task Bed_presence_heat_checks_room_power_and_target_before_sending(
|
||||
string? roomTemperature, int ageMinutes, string climateState, int minimum, string result)
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(climateState, minimum: minimum));
|
||||
if (roomTemperature is not null) gateway.States[RoomTemperatureId] = RoomTemperature(roomTemperature, ageMinutes);
|
||||
Assert.Equal(result, await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_leaves_the_temperature_alone_when_heat_mode_cannot_be_confirmed()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature()) { ApplyCommands = false };
|
||||
Assert.Equal("climate-heat-mode-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_does_not_set_temperature_after_the_user_changes_the_mode()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature());
|
||||
gateway.BeforeRead = read => { if (read == 3) gateway.States[ClimateId] = ClimateState("off"); };
|
||||
Assert.Equal("climate-heat-state-changed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_requires_both_mode_and_target_in_the_final_readback()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature()) { ModeAfterTemperature = "off" };
|
||||
Assert.Equal("climate-heat-target-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
||||
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(23.5)]
|
||||
@@ -383,9 +456,12 @@ public sealed class ClimateCurtainIntegrationTests
|
||||
public int Reads { get; private set; }
|
||||
public bool ApplyCommands { get; set; } = true;
|
||||
public bool BecomeUnavailableAfterSend { get; set; }
|
||||
public string? ModeAfterTemperature { get; set; }
|
||||
public Action<int>? BeforeRead { get; set; }
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested(); Reads++;
|
||||
BeforeRead?.Invoke(Reads);
|
||||
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
|
||||
}
|
||||
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
@@ -397,7 +473,14 @@ public sealed class ClimateCurtainIntegrationTests
|
||||
if (!ApplyCommands) return Task.CompletedTask;
|
||||
var previous = States[entityId];
|
||||
var attributes = previous.Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
|
||||
var current = service switch { "set_hvac_mode" => (string)data["hvac_mode"]!, "turn_on" => "heat", "turn_off" => "off", _ => previous.State };
|
||||
var current = service switch
|
||||
{
|
||||
"set_hvac_mode" => (string)data["hvac_mode"]!,
|
||||
"set_temperature" when ModeAfterTemperature is not null => ModeAfterTemperature,
|
||||
"turn_on" => "heat",
|
||||
"turn_off" => "off",
|
||||
_ => previous.State
|
||||
};
|
||||
if (data.TryGetValue("temperature", out var temperature)) attributes["temperature"] = temperature;
|
||||
if (data.TryGetValue("fan_mode", out var fan)) attributes["fan_mode"] = fan;
|
||||
States[entityId] = new() { EntityId = entityId, State = BecomeUnavailableAfterSend ? "unavailable" : current,
|
||||
|
||||
@@ -193,6 +193,53 @@ public sealed class DeviceArchitectureTests
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FragranceManualControlsUseTheirOwnDeviceAndKeepWakeOutOfSleepScenes()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
|
||||
var gateway = new FakeHaGateway();
|
||||
services.AddSingleton<IHomeAssistantGateway>(gateway);
|
||||
services.AddSingleton(Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
OnlyConfiguredDevices = true,
|
||||
Entities = new()
|
||||
{
|
||||
FragranceSwitch = "switch.sleep_fragrance", FragranceDuration = "number.sleep_duration",
|
||||
FragranceQuickRelease = "button.sleep_release",
|
||||
WakeFragranceSwitch = "switch.wake_fragrance", WakeFragranceDuration = "number.wake_duration",
|
||||
WakeFragranceQuickRelease = "button.wake_release"
|
||||
}
|
||||
}));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
|
||||
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
|
||||
var parameters = provider.GetRequiredService<DeviceParameterController>();
|
||||
var sleep = Assert.Single(registry.Modules, module => module.Key == "fragrance");
|
||||
var wake = Assert.Single(registry.Modules, module => module.Key == "wake-fragrance");
|
||||
foreach (var id in sleep.EntityIds.Concat(wake.EntityIds))
|
||||
gateway.States[id] = new() { EntityId = id, State = "off" };
|
||||
var inventory = catalog.BuildDiscoveredDevices(null, gateway.States);
|
||||
var sleepDevice = Assert.Single(inventory, device => device.ModuleKey == sleep.Key);
|
||||
var wakeDevice = Assert.Single(inventory, device => device.ModuleKey == wake.Key);
|
||||
Assert.Equal("switch.wake_fragrance", wakeDevice.PrimaryControlEntityId);
|
||||
await wake.SetPowerAsync(true, default);
|
||||
await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_duration", 5, null, default);
|
||||
await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_quick_release", true, null, default);
|
||||
await parameters.ExecuteAsync(sleepDevice, gateway.States, "fragrance_duration", 3, null, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => parameters.ExecuteAsync(
|
||||
wakeDevice, gateway.States, "fragrance_quick_release", true, null, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => parameters.ExecuteAsync(
|
||||
sleepDevice, gateway.States, "wake_fragrance_quick_release", true, null, default));
|
||||
Assert.Equal(new[] { "switch.wake_fragrance", "number.wake_duration", "button.wake_release", "number.sleep_duration" },
|
||||
gateway.Calls.Select(command => command.EntityId));
|
||||
Assert.Null(wake.BuildPreview(new(), "balanced"));
|
||||
Assert.Empty(wake.GetShutdownCommands());
|
||||
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
||||
await wake.ExecuteSleepAsync(context, new() { EnableFragrance = true }, "balanced", default);
|
||||
Assert.Equal(4, gateway.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownContinuesAfterAnIndividualDeviceFailure()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
|
||||
public string? FailEntity { get; set; }
|
||||
public int ReadsToFail { get; set; }
|
||||
public string? CoverCloseResult { get; set; }
|
||||
public string? HumidifierTurnOnResult { get; set; }
|
||||
public TimeProvider Time { get; set; } = TimeProvider.System;
|
||||
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
@@ -34,6 +35,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
|
||||
if (entity == FailEntity) throw new IOException("模拟设备命令失败");
|
||||
var state = service switch
|
||||
{
|
||||
"turn_on" when domain == "humidifier" && HumidifierTurnOnResult is not null => HumidifierTurnOnResult,
|
||||
"turn_on" => "on", "turn_off" => "off",
|
||||
"close_cover" when CoverCloseResult is not null => CoverCloseResult,
|
||||
"select_option" => data["option"]!.ToString()!,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Devices.Audio;
|
||||
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.Fragrance;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using SqlSugar;
|
||||
@@ -13,6 +19,7 @@ using MQTTnet.Protocol;
|
||||
using MQTTnet.Server;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
@@ -23,9 +30,17 @@ public sealed class Ib43MqttIntegrationTests
|
||||
{
|
||||
private const string LeftBinding = "a98788f2aeeb40b68f145a182d99f832";
|
||||
private const string TargetCurtain = "cover.test_curtain";
|
||||
private const string TargetDehumidifier = "humidifier.test";
|
||||
private const string HumiditySensor = "sensor.test_humidity";
|
||||
private const string TargetClimate = "climate.test";
|
||||
private const string TemperatureSensor = "sensor.test_temperature";
|
||||
private const string FragranceButton = "button.test_fragrance";
|
||||
private const string WakeFragranceButton = "button.test_wake_fragrance";
|
||||
private const string Speaker = "media_player.test_speaker";
|
||||
private const string TrackUrl = "https://example.test/sleep.mp3";
|
||||
|
||||
[Fact]
|
||||
public async Task Real_MQTT_receives_supplier_examples_and_resubscribes_after_broker_restart()
|
||||
public async Task Real_MQTT_resubscribes_and_only_fresh_empty_to_occupied_triggers_after_broker_restart()
|
||||
{
|
||||
// 只监听 loopback,随机端口;不访问用户的 EMQX、数据库或家具。
|
||||
using var reservation = new TcpListener(IPAddress.Loopback, 0);
|
||||
@@ -43,17 +58,55 @@ public sealed class Ib43MqttIntegrationTests
|
||||
var store = new Ib43StateStore(options, new(), TimeProvider.System);
|
||||
using var sink = new DatabaseSink(binding);
|
||||
var gateway = new FakeHaGateway { CoverCloseResult = "closed" };
|
||||
gateway.States[TargetCurtain] = new() { EntityId = TargetCurtain, State = "open",
|
||||
var openCurtain = new HomeAssistantState { EntityId = TargetCurtain, State = "open",
|
||||
Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) };
|
||||
var haOptions = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = TargetCurtain } });
|
||||
var stoppedDehumidifier = new HomeAssistantState { EntityId = TargetDehumidifier, State = "off" };
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
gateway.States[HumiditySensor] = new() { EntityId = HumiditySensor, State = "64", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) };
|
||||
gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "27", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) };
|
||||
gateway.States[FragranceButton] = new() { EntityId = FragranceButton, State = "unknown" };
|
||||
gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unknown" };
|
||||
gateway.States[Speaker] = new() { EntityId = Speaker, State = "idle" };
|
||||
var haOptions = Options.Create(new HomeAssistantOptions
|
||||
{ Entities = new() { Curtain = TargetCurtain, Dehumidifier = TargetDehumidifier, Humidity = HumiditySensor,
|
||||
Climate = TargetClimate, Temperature = TemperatureSensor, FragranceQuickRelease = FragranceButton,
|
||||
WakeFragranceQuickRelease = WakeFragranceButton, Speaker = Speaker } });
|
||||
var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, execution);
|
||||
var curtainAction = new BedPresenceCurtainCloseAction(new CurtainModule(runner, haOptions), runner, haOptions);
|
||||
var preview = new BedPresenceDehumidifierPreview(gateway, haOptions, TimeProvider.System);
|
||||
var dehumidifier = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, haOptions),
|
||||
runner, haOptions, TimeProvider.System);
|
||||
var room = new IndoorEnvironmentReader(haOptions, TimeProvider.System);
|
||||
var climate = new BedPresenceClimatePreview(gateway, room, haOptions);
|
||||
var heat = new BedPresenceClimateHeatAction(new ClimateModule(runner, haOptions), room, runner, haOptions);
|
||||
var fragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: false);
|
||||
var wakeFragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: true);
|
||||
var audio = new FakeAudioGateway();
|
||||
var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
|
||||
{
|
||||
Tracks = new() { ["balanced"] = [new() { Url = TrackUrl, DurationSeconds = 30 }] }
|
||||
}), NullLogger<TcmMusicTransitionService>.Instance, new ManualTestTimeProvider());
|
||||
var speaker = new BedPresenceSleepMusicAction(gateway, music, haOptions);
|
||||
var executionLog = new RecordingExecutionLog();
|
||||
using var automation = new BedPresenceAutomation(Options.Create(new BedPresenceAutomationOptions
|
||||
{ Mode = BedAutomationMode.Execute, BindingId = LeftBinding, ActionKey = "curtain-close", TargetEntityId = TargetCurtain }),
|
||||
[curtainAction], TimeProvider.System, NullLogger<BedPresenceAutomation>.Instance);
|
||||
{
|
||||
Mode = BedAutomationMode.Managed, ActionKey = curtainAction.Key, TargetEntityId = TargetCurtain,
|
||||
AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ActionKey = preview.Key, TargetEntityId = TargetDehumidifier },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = dehumidifier.Key, TargetEntityId = TargetDehumidifier },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = climate.Key, TargetEntityId = TargetClimate },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = heat.Key, TargetEntityId = TargetClimate },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = fragrance.Key, TargetEntityId = FragranceButton },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = wakeFragrance.Key, TargetEntityId = WakeFragranceButton },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = speaker.Key, TargetEntityId = Speaker }]
|
||||
}), [curtainAction, preview, dehumidifier, climate, heat, fragrance, wakeFragrance, speaker], TimeProvider.System,
|
||||
NullLogger<BedPresenceAutomation>.Instance, executionLog);
|
||||
var observedPresence = new ObservedPresenceSink(automation);
|
||||
using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store,
|
||||
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, automation);
|
||||
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, observedPresence);
|
||||
using var publisher = factory.CreateMqttClient();
|
||||
try
|
||||
{
|
||||
@@ -79,42 +132,209 @@ public sealed class Ib43MqttIntegrationTests
|
||||
Assert.Equal(80d, (await sink.Repository.Latest(2, "right-test"))["heartRate"].Value);
|
||||
Assert.Empty(gateway.Calls);
|
||||
|
||||
await PublishPresence(publisher, binding, 0, 0);
|
||||
await PublishPresence(publisher, binding, 1, 0);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 4);
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1));
|
||||
Assert.Empty(gateway.Calls);
|
||||
var bed = new BedAutomationBinding(LeftBinding, 1, binding.ProductKey, binding.SerialNumber, BedSide.Left, true);
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
|
||||
// 左侧空床后上床才触发。连续重复在床不会重启,右侧的变化不触发左侧规则。
|
||||
await PublishPresence(publisher, binding, 0, 0);
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 6 && gateway.Calls.Count == 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 8 && gateway.Calls.Count == 1);
|
||||
Assert.Equal("readback-confirmed", await executionLog.WaitForResult(curtainAction.Key));
|
||||
var close = Assert.Single(gateway.Calls);
|
||||
Assert.Equal(TargetCurtain, close.EntityId);
|
||||
Assert.Equal("close_cover", close.Service);
|
||||
Assert.Equal("closed", gateway.States[TargetCurtain].State);
|
||||
Assert.Equal(1d, (await sink.Repository.Latest(1, LeftBinding))["presence"].Value);
|
||||
automation.Disable(LeftBinding, 1);
|
||||
|
||||
Assert.True(automation.TryEnable(bed, preview.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 11);
|
||||
Assert.Equal("preview-would-start", await executionLog.WaitForResult(preview.Key));
|
||||
Assert.Single(gateway.Calls);
|
||||
Assert.Equal("off", gateway.States[TargetDehumidifier].State);
|
||||
Assert.True(automation.Disable(LeftBinding, 1, preview.Key));
|
||||
|
||||
Assert.True(automation.TryEnable(bed, dehumidifier.Key));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 14);
|
||||
Assert.Equal("readback-confirmed", await executionLog.WaitForResult(dehumidifier.Key));
|
||||
Assert.Equal("on", gateway.States[TargetDehumidifier].State);
|
||||
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
var oldConnection = observedPresence.LastLeft!.ConnectionId;
|
||||
|
||||
// 保持两条实物规则开启,断开前的空床基线不能与新连接的首条在床组成上床事件。
|
||||
await broker.StopAsync();
|
||||
await WaitFor(() => store.Snapshot()[0].Transport != "connected");
|
||||
await broker.StartAsync();
|
||||
await WaitFor(() => store.Snapshot()[0].Transport == "connected");
|
||||
using var publisherAfterRestart = factory.CreateMqttClient();
|
||||
await publisherAfterRestart.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port).Build());
|
||||
Assert.True(automation.IsEnabled(LeftBinding, 1));
|
||||
Assert.True(automation.IsEnabled(LeftBinding, 1, dehumidifier.Key));
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var firstOnNewConnection = observedPresence.LastLeft!;
|
||||
Assert.NotEqual(oldConnection, firstOnNewConnection.ConnectionId);
|
||||
Assert.Equal(3, executionLog.Started.Count);
|
||||
Assert.Equal(2, gateway.Calls.Count);
|
||||
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var freshTransition = observedPresence.LastLeft!;
|
||||
Assert.Equal(firstOnNewConnection.ConnectionId, freshTransition.ConnectionId);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 19 && executionLog.Completed.Count >= 5);
|
||||
|
||||
await publisherAfterRestart.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
|
||||
.WithPayload("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[75,16,0,81,18,0]}}")
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
|
||||
await WaitFor(() => store.Snapshot()[0].Readings["left.heartRate"].Value == 75);
|
||||
Assert.Equal(1d, store.Snapshot()[0].Readings["left.presence"].Value);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 7);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 20);
|
||||
Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
|
||||
Assert.Single(gateway.Calls);
|
||||
var newExecutions = executionLog.Started.Skip(3).ToArray();
|
||||
Assert.Equal(new[] { curtainAction.Key, dehumidifier.Key },
|
||||
newExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.All(newExecutions, entry => Assert.Equal(freshTransition.ReceivedAt, entry.TriggeredAt));
|
||||
Assert.All(executionLog.Completed.Skip(3), result => Assert.Equal("readback-confirmed", result));
|
||||
Assert.Equal(5, executionLog.Completed.Count);
|
||||
Assert.Equal(new[] { "close_cover", "turn_on" },
|
||||
gateway.Calls.Skip(2).Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal));
|
||||
|
||||
Assert.True(automation.Disable(LeftBinding, 1));
|
||||
Assert.True(automation.Disable(LeftBinding, 1, dehumidifier.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, climate.Key));
|
||||
Assert.True(automation.TryEnable(bed, climate.Key));
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 23);
|
||||
Assert.Equal("preview-climate-cool", await executionLog.WaitForResult(climate.Key));
|
||||
Assert.Equal(climate.Key, Assert.Single(executionLog.Started.Skip(5)).Action);
|
||||
Assert.Equal(4, gateway.Calls.Count);
|
||||
|
||||
Assert.True(automation.Disable(LeftBinding, 1, climate.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, heat.Key));
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "20", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) };
|
||||
gateway.States[TargetClimate] = new() { EntityId = TargetClimate, State = "off",
|
||||
Attributes = JsonSerializer.SerializeToElement(new { hvac_modes = new[] { "off", "heat", "cool" }, supported_features = 1,
|
||||
temperature_unit = "°C", min_temp = 16, max_temp = 30, target_temp_step = 1, temperature = 24 }) };
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
Assert.True(automation.TryEnable(bed, dehumidifier.Key));
|
||||
Assert.True(automation.TryEnable(bed, heat.Key));
|
||||
foreach (var action in new[] { fragrance.Key, wakeFragrance.Key, speaker.Key })
|
||||
{
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, action));
|
||||
Assert.True(automation.TryEnable(bed, action));
|
||||
}
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var combinedTransition = observedPresence.LastLeft!;
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 26 && executionLog.Completed.Count >= 12);
|
||||
var combinedExecutions = executionLog.Started.Skip(6).ToArray();
|
||||
Assert.Equal(new[] { heat.Key, curtainAction.Key, dehumidifier.Key, fragrance.Key, speaker.Key, wakeFragrance.Key },
|
||||
combinedExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.All(combinedExecutions, entry => Assert.Equal(combinedTransition.ReceivedAt, entry.TriggeredAt));
|
||||
var combinedResults = executionLog.Completed.Skip(6).ToArray();
|
||||
Assert.Equal(6, combinedResults.Length);
|
||||
Assert.Equal(3, combinedResults.Count(result => result == "readback-confirmed"));
|
||||
Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(fragrance.Key));
|
||||
Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(wakeFragrance.Key));
|
||||
Assert.Equal("speaker-playback-sent", await executionLog.WaitForResult(speaker.Key));
|
||||
|
||||
var combinedCommands = gateway.Calls.Skip(4).ToArray();
|
||||
Assert.Equal(new[] { "close_cover", "press", "press", "set_hvac_mode", "set_temperature", "turn_on" },
|
||||
combinedCommands.Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal));
|
||||
Assert.Equal(new[] { FragranceButton, WakeFragranceButton }, combinedCommands.Where(command => command.Service == "press")
|
||||
.Select(command => command.EntityId).OrderBy(id => id, StringComparer.Ordinal));
|
||||
Assert.Equal(TrackUrl, Assert.Single(audio.Tracks));
|
||||
Assert.Equal(50, Assert.Single(audio.Volumes));
|
||||
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" },
|
||||
combinedCommands.Where(command => command.EntityId == TargetClimate).Select(command => command.Service));
|
||||
Assert.Equal("closed", gateway.States[TargetCurtain].State);
|
||||
Assert.Equal("on", gateway.States[TargetDehumidifier].State);
|
||||
Assert.Equal("heat", gateway.States[TargetClimate].State);
|
||||
Assert.Equal(22, gateway.States[TargetClimate].GetDoubleAttribute("temperature"));
|
||||
|
||||
Assert.Equal("speaker-not-idle", await speaker.ExecuteAsync(Speaker, default));
|
||||
Assert.Single(audio.Tracks);
|
||||
gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unavailable" };
|
||||
Assert.Equal("fragrance-release-unavailable", await wakeFragrance.ExecuteAsync(WakeFragranceButton, default));
|
||||
Assert.Equal("target-not-configured", await fragrance.ExecuteAsync(WakeFragranceButton, default));
|
||||
Assert.Equal(10, gateway.Calls.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await service.StopAsync(timeout.Token);
|
||||
await automation.StopAsync(timeout.Token);
|
||||
await music.StopAsync(timeout.Token);
|
||||
await broker.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ObservedPresenceSink(IBedPresenceSink inner) : IBedPresenceSink
|
||||
{
|
||||
private BedPresenceSample? _lastLeft;
|
||||
public BedPresenceSample? LastLeft => Volatile.Read(ref _lastLeft);
|
||||
|
||||
public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
|
||||
{
|
||||
inner.Observe(sample, connectionToken);
|
||||
if (sample.Side == BedSide.Left) Volatile.Write(ref _lastLeft, sample);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutionLog : IBedPresenceExecutionLog
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _results = new();
|
||||
public ConcurrentQueue<(string Action, DateTimeOffset TriggeredAt)> Started { get; } = new();
|
||||
public ConcurrentQueue<string> Completed { get; } = new();
|
||||
public Task<string> WaitForResult(string action) => Result(action).Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
|
||||
private TaskCompletionSource<string> Result(string action) =>
|
||||
_results.GetOrAdd(action, _ => new(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
public Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token)
|
||||
{
|
||||
Assert.Equal(LeftBinding, bindingId);
|
||||
Assert.Equal(1L, userId);
|
||||
Started.Enqueue((action, triggeredAt));
|
||||
return Task.FromResult<string?>(action);
|
||||
}
|
||||
public Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
{
|
||||
Completed.Enqueue(result);
|
||||
Result(id).TrySetResult(result);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DatabaseSink : IIb43ReportSink, IDisposable
|
||||
{
|
||||
private readonly SqlSugarClient _db;
|
||||
|
||||
@@ -2,7 +2,9 @@ using Admin.NET.Core;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Services;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SqlSugar;
|
||||
using System.Security.Claims;
|
||||
@@ -12,6 +14,7 @@ namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class Ib43PersistenceTests : IDisposable
|
||||
{
|
||||
private const string PreviewAction = "dehumidifier-humidity-preview";
|
||||
private readonly SqlSugarClient _db;
|
||||
private readonly string _databasePath = Path.Combine(Path.GetTempPath(), "ib43-test-" + Guid.NewGuid().ToString("N") + ".db");
|
||||
private readonly Ib43DataRepository _repository;
|
||||
@@ -19,6 +22,7 @@ public sealed class Ib43PersistenceTests : IDisposable
|
||||
private readonly Ib43Binding _device = new() { ProductKey = "test", SerialNumber = "sim", Simulation = true };
|
||||
private readonly HttpContextAccessor _context = new() { HttpContext = new DefaultHttpContext() };
|
||||
private readonly HealthIb43Service _service;
|
||||
private readonly BedPresenceAutomation _automation;
|
||||
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
@@ -37,14 +41,31 @@ public sealed class Ib43PersistenceTests : IDisposable
|
||||
if (Nullable.GetUnderlyingType(property.PropertyType) != null) column.IsNullable = true;
|
||||
} } });
|
||||
_db.CodeFirst.InitTables<HealthIb43Binding, HealthIb43Observation, HealthUser>();
|
||||
_db.CodeFirst.InitTables<HealthIb43AutomationExecution>();
|
||||
_db.Ado.ExecuteCommand("CREATE TABLE SysUser (Id INTEGER PRIMARY KEY, AccountType INTEGER, Status INTEGER)");
|
||||
_db.Ado.ExecuteCommand("INSERT INTO SysUser VALUES (99,999,1)");
|
||||
foreach (var id in new[] { 1L, 2L, 3L })
|
||||
_db.Insertable(new HealthUser { Id = id, Phone = id.ToString(), DisplayName = "test", PasswordHash = "unused",
|
||||
CreateTime = DateTime.UtcNow, CreateUserName = "test", UpdateUserName = "test" }).ExecuteCommand();
|
||||
_repository = new(_db);
|
||||
var options = Options.Create(new Ib43Options { Devices = [_device] });
|
||||
_service = new(_repository, _context, options, new(options, new(), _clock), _clock);
|
||||
_automation = CreateAutomation(_repository, PreviewAction);
|
||||
_service = CreateService(_repository, _automation);
|
||||
}
|
||||
|
||||
private BedPresenceAutomation CreateAutomation(Ib43DataRepository repository, string secondaryAction) =>
|
||||
new(Options.Create(new BedPresenceAutomationOptions
|
||||
{
|
||||
Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey, SerialNumber = _device.SerialNumber,
|
||||
ActionKey = "curtain-close", TargetEntityId = "cover.test", TargetLabel = "测试窗帘",
|
||||
AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey,
|
||||
SerialNumber = _device.SerialNumber, ActionKey = secondaryAction, TargetEntityId = "humidifier.test" }]
|
||||
}), [new TestAction("curtain-close", "cover.test"), new TestAction(secondaryAction, "humidifier.test")],
|
||||
_clock, NullLogger<BedPresenceAutomation>.Instance, new TestExecutionLog(repository));
|
||||
|
||||
private HealthIb43Service CreateService(Ib43DataRepository repository, BedPresenceAutomation automation)
|
||||
{
|
||||
var options = Options.Create(new Ib43Options { Enabled = true, Devices = [_device] });
|
||||
return new(repository, _context, options, new(options, new(), _clock), _clock, automation);
|
||||
}
|
||||
|
||||
private void User(long id) => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
@@ -166,6 +187,121 @@ public sealed class Ib43PersistenceTests : IDisposable
|
||||
Assert.Equal("Furion.FriendlyException.AppFriendlyException", error.GetType().FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_controls_only_the_configured_bed_and_can_read_the_durable_execution_result()
|
||||
{
|
||||
var left = await Assign("left", 1);
|
||||
var right = await Assign("right", 2);
|
||||
await _automation.StartAsync(default);
|
||||
try
|
||||
{
|
||||
User(1);
|
||||
var state = await _service.AutomationStatus(left.Id);
|
||||
Assert.True(state.Available);
|
||||
Assert.False(state.Enabled);
|
||||
Assert.Equal(new[] { "curtain-close", PreviewAction }, state.Rules.Select(rule => rule.ActionKey));
|
||||
await _service.StartAutomation(left.Id);
|
||||
Assert.True((await _service.AutomationStatus(left.Id)).Enabled);
|
||||
await _service.StartAutomation(left.Id, PreviewAction);
|
||||
Assert.All((await _service.AutomationStatus(left.Id)).Rules, rule => Assert.True(rule.Enabled));
|
||||
await ExpectRejection(() => _service.StartAutomation(left.Id, "unknown"));
|
||||
await ExpectRejection(() => _service.StopAutomation(left.Id, "unknown"));
|
||||
|
||||
var id = await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default);
|
||||
Assert.NotNull(id);
|
||||
await _repository.CompleteAutomation(id!, "readback-confirmed", _clock.Now.ToUnixTimeMilliseconds());
|
||||
Assert.Equal("readback-confirmed", Assert.Single((await _service.AutomationStatus(left.Id)).Records).Result);
|
||||
|
||||
User(2);
|
||||
await ExpectRejection(() => _service.AutomationStatus(left.Id));
|
||||
await ExpectRejection(() => _service.StartAutomation(left.Id));
|
||||
await ExpectRejection(() => _service.StopAutomation(left.Id));
|
||||
await ExpectRejection(() => _service.StartAutomation(left.Id, PreviewAction));
|
||||
await ExpectRejection(() => _service.StopAutomation(left.Id, PreviewAction));
|
||||
Assert.False((await _service.AutomationStatus(right.Id)).Available);
|
||||
await ExpectRejection(() => _service.StartAutomation(right.Id));
|
||||
Assert.True(_automation.IsEnabled(left.Id, 1));
|
||||
|
||||
User(1);
|
||||
await _service.StopAutomation(left.Id);
|
||||
Assert.False((await _service.AutomationStatus(left.Id)).Enabled);
|
||||
Assert.True(_automation.IsEnabled(left.Id, 1, PreviewAction));
|
||||
await _service.StartAutomation(left.Id);
|
||||
Admin(); await _service.Release(left.Id);
|
||||
Assert.False(_automation.IsEnabled(left.Id, 1));
|
||||
Assert.False(_automation.IsEnabled(left.Id, 1, PreviewAction));
|
||||
Assert.Null(await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default));
|
||||
User(1);
|
||||
state = await _service.AutomationStatus(left.Id);
|
||||
Assert.False(state.Available);
|
||||
Assert.Single(state.Records);
|
||||
}
|
||||
finally { await _automation.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restarted_backend_services_leave_rules_stopped_and_return_the_same_saved_execution_history()
|
||||
{
|
||||
const string dehumidifier = "dehumidifier-humidity-start";
|
||||
var left = await Assign("left", 1);
|
||||
User(1);
|
||||
Ib43AutomationOutput before;
|
||||
using (var automation = CreateAutomation(_repository, dehumidifier))
|
||||
{
|
||||
await automation.StartAsync(default);
|
||||
try
|
||||
{
|
||||
var service = CreateService(_repository, automation);
|
||||
await service.StartAutomation(left.Id);
|
||||
await service.StartAutomation(left.Id, dehumidifier);
|
||||
foreach (var (action, target) in new[] { ("curtain-close", "测试窗帘"), (dehumidifier, "测试除湿机") })
|
||||
{
|
||||
var now = _clock.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var id = await _repository.BeginAutomation(left.Id, 1, action, target, now, default);
|
||||
Assert.NotNull(id);
|
||||
await _repository.CompleteAutomation(id!, "readback-confirmed", now);
|
||||
_clock.Now = _clock.Now.AddSeconds(1);
|
||||
}
|
||||
before = await service.AutomationStatus(left.Id);
|
||||
Assert.Equal(2, before.Rules.Count);
|
||||
Assert.All(before.Rules, rule => Assert.True(rule.Enabled));
|
||||
Assert.Equal(2, before.Records.Count);
|
||||
}
|
||||
finally { await automation.StopAsync(default); }
|
||||
}
|
||||
|
||||
_db.Ado.Close();
|
||||
using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
|
||||
DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute });
|
||||
var repository = new Ib43DataRepository(reopened);
|
||||
using var restarted = CreateAutomation(repository, dehumidifier);
|
||||
await restarted.StartAsync(default);
|
||||
try
|
||||
{
|
||||
var after = await CreateService(repository, restarted).AutomationStatus(left.Id);
|
||||
Assert.False(after.Enabled);
|
||||
Assert.Equal(before.Rules.Select(rule => rule with { Enabled = false }), after.Rules);
|
||||
Assert.Equal(before.Records.ToArray(), after.Records.ToArray());
|
||||
}
|
||||
finally { await restarted.StopAsync(default); }
|
||||
}
|
||||
|
||||
private sealed class TestAction(string key, string target) : IBedPresenceAction
|
||||
{
|
||||
public string Key => key;
|
||||
public string Description => "测试";
|
||||
public bool SupportsTarget(string entityId) => entityId == target;
|
||||
public Task<string> ExecuteAsync(string entityId, CancellationToken token) => throw new InvalidOperationException("No real action in this test");
|
||||
}
|
||||
|
||||
private sealed class TestExecutionLog(Ib43DataRepository repository) : IBedPresenceExecutionLog
|
||||
{
|
||||
public Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token)
|
||||
=> repository.BeginAutomation(bindingId, userId, action, target, triggeredAt.ToUnixTimeMilliseconds(), token);
|
||||
public Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
=> repository.CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Local_maintenance_previews_then_binds_with_audit_and_is_idempotent()
|
||||
{
|
||||
@@ -201,6 +337,7 @@ public sealed class Ib43PersistenceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_automation.Dispose();
|
||||
_db.Dispose();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
File.Delete(_databasePath);
|
||||
|
||||
Reference in New Issue
Block a user