This commit is contained in:
fuenmao
2026-11-12 15:45:52 +08:00
parent 61e25175db
commit 718b138033
30 changed files with 1511 additions and 99 deletions
+139 -2
View File
@@ -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);