S1-b: IF-LOC v1.0 locator simulator + round-trip test
Reference implementation of the frozen IF-LOC v1.0 contract (meta/contracts/ble-device-interface.md): byte-accurate codec for the telemetry, capture-trigger, and capture-result frames, plus a LocatorSimulator that replays a canned locate session over a transport-agnostic loopback link. Unblocks App development without firmware (Risk R-3, SRS S3.1). Plain net9.0 (not a MAUI head) so it builds/runs headless under the QA gate without the Android/iOS workloads; qa-gate.sh auto-discovers *.Tests.csproj under app/tests/. Round-trip test asserts field-level fidelity (every SRS S3.1 telemetry + capture-result field), sentinels, endianness (incl. RFC 4122 big-endian pointId), the REJECTED path, and exactly-once capture correlation (no silent drop, SRS-LOG-7). 8/8 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
228
tests/IfLoc.Sim.Tests/RoundTripTests.cs
Normal file
228
tests/IfLoc.Sim.Tests/RoundTripTests.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
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 Field Logger'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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user