using IfLoc.Sim; using Xunit; namespace IfLoc.Sim.Tests; /// /// 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). /// 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); } /// /// 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. /// [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 { 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); } }