Files
2026-11-11 16:11:41 +08:00

206 lines
10 KiB
C#

using Evergreen.Health.Application.Health.Integrations.Ib43;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class Ib43Tests
{
private static Ib43Binding Binding() => new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
private static string Report(string fields) => "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\"," + fields + "}}";
private static Ib43Report Parse(string fields) => new Ib43Parser().Parse(Binding().PostTopic, Report(fields), Binding(), DateTimeOffset.UtcNow);
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private static Ib43StateStore Store(Clock clock) => new(Options.Create(new Ib43Options { Devices = [Binding()] }), new(), clock);
[Theory]
[InlineData("startup.pdf-example.json", "startup")]
[InlineData("realtime.pdf-example.json", "periodic")]
[InlineData("sleep-event.pdf-keys.json", "event")]
public void Archived_supplier_examples_parse_without_issues(string file, string kind)
{
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", file));
using var doc = JsonDocument.Parse(json);
var p = doc.RootElement.GetProperty("params");
var binding = new Ib43Binding { SerialNumber = p.GetProperty("deviceName").GetString(),
ProductKey = p.TryGetProperty("productKey", out var pk) ? pk.GetString() : "example" };
var report = new Ib43Parser().Parse(binding.PostTopic, json, binding, DateTimeOffset.UtcNow);
Assert.True(report.Accepted);
Assert.Empty(report.Issues);
Assert.Contains(kind, report.Kinds);
if (kind == "startup") Assert.Equal(60, report.Fields["characteristic"].GetProperty("reportIerVal").GetInt32());
if (kind == "periodic") Assert.Equal(-52, report.Fields["airbagsPressure"][3].GetInt32());
if (kind == "event") Assert.Equal(80d, report.Readings["right.heartRate"].Value);
}
[Fact]
public void Periodic_HR_uses_confirmed_order_and_does_not_infer_presence()
{
var r = Parse("\"HR\":[72,16,1,80,18,0],\"snoreStatus\":[3,9],\"airbagsPerson\":[1,0]");
Assert.Equal(72d, r.Readings["left.heartRate"].Value);
Assert.Equal(18d, r.Readings["right.respiration"].Value);
Assert.Equal(1d, r.Readings["left.snoring"].Value);
Assert.Equal(0d, r.Readings["right.snoring"].Value);
Assert.DoesNotContain("left.presence", r.Readings.Keys);
}
[Theory]
[InlineData("SleepReportNew", "ibNew")]
[InlineData(" SleepReportNew", " ibNew")]
[InlineData(" SleepReportNew", "ibNew")]
public void Event_has_its_own_order_and_preserves_sides(string outer, string inner)
{
var r = Parse($"\"{outer}\":{{\"{inner}\":[1,0,0,16,74,0,0,1,0,17,80,0]}}");
Assert.Equal(74d, r.Readings["left.heartRate"].Value);
Assert.Equal(17d, r.Readings["right.respiration"].Value);
Assert.Equal(1d, r.Readings["left.presence"].Value);
Assert.Equal(0d, r.Readings["right.presence"].Value);
}
[Fact]
public void Partial_updates_preserve_presence_but_zero_clears_previous_vitals()
{
var clock = new Clock(); var store = Store(clock);
store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
var first = store.Snapshot()[0].Readings["left.presence"].ReceivedAt;
clock.Now += TimeSpan.FromSeconds(5);
store.Receive(Binding().PostTopic, Report("\"HR\":[0,0,0,80,18,1]"));
var state = store.Snapshot()[0];
Assert.Equal(1d, state.Readings["left.presence"].Value);
Assert.Equal(first, state.Readings["left.presence"].ReceivedAt);
Assert.Null(state.Readings["left.heartRate"].Value);
Assert.Equal(80d, state.Readings["right.heartRate"].Value);
}
[Theory]
[InlineData("\"HR\":[1,2]", "HR:invalid-length")]
[InlineData("\"HR\":[72,16,0,80,\"18\",0]", "HR:invalid-number")]
[InlineData("\"HR\":[72,16,4,80,18,0]", "left.snoring:invalid-enum")]
[InlineData("\"motorStatus\":[0,0,0,9]", "motorStatus:invalid-enum")]
[InlineData("\"SleepReportNew\":{},\" SleepReportNew\":{}", "SleepReportNew:ambiguous-alias")]
[InlineData("\"SleepReportNew\":{\"ibNew\":[],\" ibNew\":[]}", "ibNew:ambiguous-alias")]
public void Malformed_fields_record_issues(string fields, string issue)
=> Assert.Contains(issue, Parse(fields).Issues);
[Theory]
[InlineData("{", "invalid-json")]
[InlineData("[]", "invalid-envelope")]
[InlineData("{\"method\":\"x\",\"params\":{}}", "invalid-envelope")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"other\"}}", "identity-mismatch")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"productKey\":\"other\"}}", "identity-mismatch")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[],\"HR\":[]}}", "duplicate-json-key")]
public void Rejects_invalid_envelopes_and_identity(string raw, string issue)
{
var r = new Ib43Parser().Parse(Binding().PostTopic, raw, Binding(), DateTimeOffset.UtcNow);
Assert.False(r.Accepted); Assert.Contains(issue, r.Issues); Assert.Empty(r.Readings);
}
[Fact]
public void Old_events_are_not_erased_by_timeout_and_transport_is_independent()
{
var clock = new Clock(); var store = Store(clock);
Assert.Equal("unknown", store.Snapshot()[0].Freshness);
store.SetTransport("connected");
store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
clock.Now += TimeSpan.FromSeconds(151);
store.Receive(Binding().PostTopic, Report("\"HR\":[0]"));
store.Receive(Binding().PostTopic, Report("\"firmwareVer\":\"new\""));
var snapshot = store.Snapshot()[0];
Assert.Equal("stale", snapshot.Freshness);
Assert.Equal("connected", snapshot.Transport);
Assert.Equal(1d, snapshot.Readings["left.presence"].Value);
store.SetTransport("disconnected");
Assert.Equal("disconnected", store.Snapshot()[0].Transport);
}
[Fact]
public void Unbound_retained_and_oversized_reports_never_update_live_state_and_history_is_bounded()
{
var store = Store(new()); var payload = Report("\"HR\":[72,16,0,80,18,0]");
Assert.False(store.Receive("/sys/other/IB43SIM001/thing/property/post", payload).Accepted);
Assert.False(store.Receive(Binding().PostTopic, payload, true).Accepted);
Assert.False(store.Receive(Binding().PostTopic, new string('x', 65537)).Accepted);
Assert.Equal("unknown", store.Snapshot()[0].Freshness);
for (var i = 0; i < 100; i++) store.Receive(Binding().PostTopic, "bad-json");
Assert.Equal(32, store.Diagnostics().Length);
Assert.Equal("bad-json", store.Diagnostics()[0].Raw);
}
[Theory]
[InlineData(0, 1, 0)]
[InlineData(1, 0, 1)]
public void Presence_mapping_requires_explicit_configuration(int index, int left, int right)
{
var binding = Binding(); binding.PersonLeftIndex = index;
var r = new Ib43Parser().Parse(binding.PostTopic, Report("\"airbagsPerson\":[1,0]"), binding, DateTimeOffset.UtcNow);
Assert.Equal((double)left, r.Readings["left.presence"].Value);
Assert.Equal((double)right, r.Readings["right.presence"].Value);
}
[Fact]
public void Enabled_configuration_requires_credentials_and_exact_unique_topics()
{
var options = new Ib43Options(); Assert.True(options.IsValid());
options.Enabled = true; Assert.False(options.IsValid());
options.Password = "test-only"; options.Devices = [Binding()]; Assert.True(options.IsValid());
options.Devices.Add(Binding()); Assert.False(options.IsValid());
options.Devices = [new() { ProductKey = "+", SerialNumber = "#" }]; Assert.False(options.IsValid());
}
[Fact]
public async Task Disabled_service_never_creates_connection()
{
var factory = new FakeFactory();
using var service = new Ib43MqttService(Options.Create(new Ib43Options()), factory, Store(new()),
TimeProvider.System, NullLogger<Ib43MqttService>.Instance);
await service.StartAsync(default); await service.StopAsync(default);
Assert.Equal(0, factory.Count);
}
[Fact]
public async Task Service_retries_failed_subscription_then_receives_and_stops()
{
var factory = new FakeFactory(); var store = Store(new());
using var service = new Ib43MqttService(Options.Create(new Ib43Options { Enabled = true,
Password = "test-only", Devices = [Binding()] }), factory, store,
TimeProvider.System, NullLogger<Ib43MqttService>.Instance);
await service.StartAsync(default);
try
{
await factory.Connected.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, factory.Count);
Assert.Equal(72d, store.Snapshot()[0].Readings["left.heartRate"].Value);
}
finally { await service.StopAsync(default); }
Assert.Equal("disconnected", store.Snapshot()[0].Transport);
Assert.True(factory.Last.Disposed);
}
private sealed class FakeFactory : IIb43MqttTransportFactory
{
public int Count;
public FakeTransport Last;
public TaskCompletionSource Connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IIb43MqttTransport Create() => Last = new FakeTransport(++Count == 1, Connected);
}
private sealed class FakeTransport(bool fail, TaskCompletionSource connected) : IIb43MqttTransport
{
public bool Disposed;
public bool IsConnected => !Disposed;
public event Func<string, string, bool, Task> Message;
public event Action<string> OversizedMessage { add { } remove { } }
public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
{
if (fail) throw new InvalidOperationException("subscription denied");
if (Message != null) await Message.Invoke(options.Devices[0].PostTopic, Report("\"HR\":[72,16,0,80,18,0]"), false);
connected.TrySetResult();
}
public void Dispose() => Disposed = true;
}
}