Files
ulapp/tests/IfLoc.Sim.Tests/RoundTripTests.cs
2026-08-23 20:54:03 -05:00

229 lines
9.3 KiB
C#

using IfLoc.Sim;
using Xunit;
namespace IfLoc.Sim.Tests;
/// <summary>
/// Mapped test for S1-b (Risk R-3, SRS §3.1). Verifies (1) every telemetry dictionary
/// field survives a byte-level encode/decode with correct units and sentinels, and
/// (2) the capture round-trip: every locator trigger yields exactly one correctly
/// correlated capture-result and no capture is silently dropped (SRS-LOG-7).
/// </summary>
public class RoundTripTests
{
[Fact]
public void Telemetry_frame_is_exactly_32_bytes()
{
var frame = LocatorSimulator.BuildTelemetry(0, 0).EncodeFrame(0);
Assert.Equal(IfLoc.TelemetryFrameLen, frame.Length);
Assert.Equal(IfLoc.FrameVersion, frame[0]);
Assert.Equal((byte)MessageType.Telemetry, frame[1]);
}
[Fact]
public void Telemetry_roundtrips_every_field_with_units()
{
var t = new Telemetry
{
LocatorUptimeMs = 1_234_567,
DepthMeters = 1.23, // 0.01 m resolution → 123 cm on wire
SignalCurrentMa = 742,
FrequencyHz = 82_500,
Mode = LocateMode.TwinSweep,
SignalType = SignalType.Sonde,
GainDb = 137,
SignalLevel = 88,
DistortionQualityPct = 91,
SignalDirection = 2,
CompassAngleDeg = 174,
GuidanceOffset = -375, // negative = left
Warnings = WarningFlags.Shallow | WarningFlags.DistortionHigh,
Utility = UtilityType.Gas,
BatteryPercent = 64,
Status = StatusFlags.Locating | StatusFlags.TimeSynced,
};
var back = Telemetry.DecodePayload(t.EncodePayload());
Assert.Equal(t.LocatorUptimeMs, back.LocatorUptimeMs);
Assert.Equal(1.23, back.DepthMeters!.Value, 3);
Assert.Equal(t.SignalCurrentMa, back.SignalCurrentMa);
Assert.Equal(t.FrequencyHz, back.FrequencyHz);
Assert.Equal(t.Mode, back.Mode);
Assert.Equal(t.SignalType, back.SignalType);
Assert.Equal(t.GainDb, back.GainDb);
Assert.Equal(t.SignalLevel, back.SignalLevel);
Assert.Equal(t.DistortionQualityPct, back.DistortionQualityPct);
Assert.Equal(t.SignalDirection, back.SignalDirection);
Assert.Equal(t.CompassAngleDeg, back.CompassAngleDeg);
Assert.Equal(t.GuidanceOffset, back.GuidanceOffset);
Assert.Equal(t.Warnings, back.Warnings);
Assert.Equal(t.Utility, back.Utility);
Assert.Equal(t.BatteryPercent, back.BatteryPercent);
Assert.Equal(t.Status, back.Status);
}
[Fact]
public void Telemetry_sentinels_decode_to_null()
{
var t = new Telemetry
{
DepthMeters = null, // no depth
SignalCurrentMa = null, // invalid current
FrequencyHz = null, // n/a
GuidanceOffset = null, // n/a
};
var back = Telemetry.DecodePayload(t.EncodePayload());
Assert.Null(back.DepthMeters);
Assert.Null(back.SignalCurrentMa);
Assert.Null(back.FrequencyHz);
Assert.Null(back.GuidanceOffset);
}
[Fact]
public void Depth_is_little_endian_centimetres()
{
// 1.23 m → 123 cm → 0x007B, little-endian at frame offset 8..9 = 7B 00
var frame = new Telemetry { DepthMeters = 1.23 }.EncodeFrame(0);
Assert.Equal(0x7B, frame[8]);
Assert.Equal(0x00, frame[9]);
}
[Fact]
public void CaptureResult_pointId_is_rfc4122_big_endian()
{
var id = Guid.Parse("018f5b2c-1a2b-7c3d-9e4f-a0b1c2d3e4f5"); // UUIDv7 shape
var frame = new CaptureResult { PointId = id }.EncodeFrame(0);
// §5.3: pointId at offset 36, most-significant byte first.
Assert.Equal(0x01, frame[36]);
Assert.Equal(0x8f, frame[37]);
Assert.Equal(0xf5, frame[51]);
Assert.Equal(id, CaptureResult.DecodeFrame(frame).PointId);
}
[Fact]
public void CaptureResult_roundtrips_position_and_accuracy()
{
var r = new CaptureResult
{
CaptureSeq = 7,
Outcome = CaptureOutcome.Stored,
Reason = ReasonCode.Ok,
FixType = FixType.RtkFixed,
Lat = 45.7649321,
Lon = 4.8354792,
OrthometricHeightM = 172.418,
HrmsM = 0.008,
VrmsM = 0.015,
Utc = DateTimeOffset.FromUnixTimeMilliseconds(1_760_000_000_000),
PointId = Guid.NewGuid(),
};
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
Assert.Equal(IfLoc.CaptureResultFrameLen, r.EncodeFrame(0).Length);
Assert.Equal(r.CaptureSeq, back.CaptureSeq);
Assert.Equal(r.Outcome, back.Outcome);
Assert.Equal(r.FixType, back.FixType);
Assert.Equal(45.7649321, back.Lat!.Value, 7);
Assert.Equal(4.8354792, back.Lon!.Value, 7);
Assert.Equal(172.418, back.OrthometricHeightM!.Value, 3);
Assert.Equal(0.008, back.HrmsM!.Value, 3);
Assert.Equal(0.015, back.VrmsM!.Value, 3);
Assert.Equal(r.Utc, back.Utc);
Assert.Equal(r.PointId, back.PointId);
}
[Fact]
public void Rejected_result_carries_reason_and_no_position()
{
var r = new CaptureResult
{
CaptureSeq = 3,
Outcome = CaptureOutcome.Rejected,
Reason = ReasonCode.HrmsExceeded,
FixType = FixType.RtkFloat,
Lat = null, Lon = null, OrthometricHeightM = null,
};
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
Assert.Equal(CaptureOutcome.Rejected, back.Outcome);
Assert.Equal(ReasonCode.HrmsExceeded, back.Reason);
Assert.Null(back.Lat);
Assert.Null(back.Lon);
Assert.Equal(Guid.Empty, back.PointId);
}
/// <summary>
/// End-to-end capture round-trip over the loopback link: the simulator replays a
/// session and raises capture-triggers; a stand-in App decodes each trigger and writes
/// back a capture-result gated on accuracy. Asserts every trigger is answered exactly
/// once (no silent-failure) and correlation holds by captureSeq.
/// </summary>
[Fact]
public async Task Capture_round_trip_answers_every_trigger_exactly_once()
{
var link = new LoopbackLink();
var sim = new LocatorSimulator(link);
int telemetrySeen = 0;
int triggersSeen = 0;
// Stand-in App: consume telemetry, and on each trigger apply a simple accuracy gate
// and write back a capture-result. This is what UM Trace's Locator Driver does.
link.ToApp += frame =>
{
switch (Frames.PeekType(frame))
{
case MessageType.Telemetry:
_ = Telemetry.DecodePayload(frame.AsSpan(IfLoc.HeaderLen, IfLoc.TelemetryPayloadLen));
telemetrySeen++;
break;
case MessageType.CaptureTrigger:
triggersSeen++;
var trig = CaptureTrigger.DecodeFrame(frame);
// Alternate a good fix and an out-of-spec fix to exercise both outcomes.
bool good = trig.CaptureSeq % 2 == 1;
var result = good
? new CaptureResult
{
CaptureSeq = trig.CaptureSeq,
Outcome = CaptureOutcome.Stored,
Reason = ReasonCode.Ok,
FixType = FixType.RtkFixed,
Lat = 45.76 + trig.CaptureSeq * 1e-5,
Lon = 4.83,
OrthometricHeightM = 170 + trig.Snapshot.DepthMeters ?? 170,
HrmsM = 0.009,
VrmsM = 0.014,
Utc = DateTimeOffset.UtcNow,
PointId = Guid.NewGuid(),
}
: new CaptureResult
{
CaptureSeq = trig.CaptureSeq,
Outcome = CaptureOutcome.Rejected,
Reason = ReasonCode.HrmsExceeded,
FixType = FixType.RtkFloat,
};
link.WriteToLocator(result.EncodeFrame((ushort)(1000 + trig.CaptureSeq)));
break;
}
};
var captureAt = new HashSet<int> { 5, 17, 42, 88 };
var report = await sim.ReplayAsync(frames: 100, captureAtFrames: captureAt, realTime: false);
Assert.Equal(100, telemetrySeen);
Assert.Equal(captureAt.Count, triggersSeen);
Assert.Equal(captureAt.Count, report.CaptureTriggersEmitted);
Assert.Equal(captureAt.Count, report.CaptureResultsReceived);
Assert.Empty(report.PendingCaptures); // SRS-LOG-7: no silent drop
// Every result correlates to a distinct trigger, and both outcomes occurred.
Assert.Equal(captureAt.Count, report.Results.Select(r => r.CaptureSeq).Distinct().Count());
Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Stored);
Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Rejected);
foreach (var r in report.Results.Where(r => r.Outcome == CaptureOutcome.Stored))
Assert.NotEqual(Guid.Empty, r.PointId);
}
}