206 lines
10 KiB
C#
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;
|
|
}
|
|
}
|