From a788cebea37f65e07a2a337d54bca7a958f98fba Mon Sep 17 00:00:00 2001 From: Brent Perteet Date: Thu, 20 Aug 2026 13:13:49 -0500 Subject: [PATCH] test(S1-b): add IF-LOC codec boundary/edge-case vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-owned boundary coverage for the frozen IF-LOC v1.0 codec, complementing app-owner's nominal round-trip suite. Pins min/max/resolution limits per wire field via the pure Encode/Decode seam, staying clear of the no-value sentinels, and characterises the two sentinel-collision edges (depth -327.68 m, current 0xFFFF) so the contract's documented ranges are enforceable. 40/40 green; full QA gate green (app 43 + server 2). Trace: SRS §3.1, Risk R-3. Co-Authored-By: Claude Opus 4.8 --- tests/IfLoc.Sim.Tests/BoundaryTests.cs | 158 +++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/IfLoc.Sim.Tests/BoundaryTests.cs diff --git a/tests/IfLoc.Sim.Tests/BoundaryTests.cs b/tests/IfLoc.Sim.Tests/BoundaryTests.cs new file mode 100644 index 0000000..14756d8 --- /dev/null +++ b/tests/IfLoc.Sim.Tests/BoundaryTests.cs @@ -0,0 +1,158 @@ +using IfLoc.Sim; +using Xunit; + +namespace IfLoc.Sim.Tests; + +/// +/// QA-owned boundary/edge-case vectors for the frozen IF-LOC v1.0 codec (S1-b, SRS §3.1). +/// Complements (nominal fidelity + capture round-trip) by +/// pinning the min/max/resolution limits of each wire field via the pure +/// EncodePayload()/EncodeFrame()/DecodeFrame() seam. Values are chosen to stay clear of the +/// no-value sentinels; the exact sentinel-collision boundaries are characterised at the +/// bottom so the contract's documented ranges are enforceable, not just implied. +/// +public class BoundaryTests +{ + // --- Telemetry: depth (int16 centimetres, 0.01 m resolution, sentinel 0x8000) -------- + [Theory] + [InlineData(0.0)] + [InlineData(0.01)] // one resolution step + [InlineData(-0.01)] + [InlineData(12.34)] + [InlineData(327.67)] // max representable magnitude (32767 cm) + [InlineData(-327.67)] // min valid magnitude (-32767 cm); -327.68 would alias the sentinel + public void Depth_roundtrips_at_range_and_resolution_limits(double meters) + { + var back = Telemetry.DecodePayload(new Telemetry { DepthMeters = meters }.EncodePayload()); + Assert.NotNull(back.DepthMeters); + Assert.Equal(meters, back.DepthMeters!.Value, 2); + } + + // --- Telemetry: signal current (uint16 mA, sentinel 0xFFFF) --------------------------- + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)1)] + [InlineData((ushort)65534)] // max valid; 65535 == CurrentInvalid sentinel + public void SignalCurrent_roundtrips_to_max_valid(ushort mA) + { + var back = Telemetry.DecodePayload(new Telemetry { SignalCurrentMa = mA }.EncodePayload()); + Assert.Equal(mA, back.SignalCurrentMa); + } + + // --- Telemetry: frequency (uint32 Hz, sentinel 0xFFFFFFFF) ---------------------------- + [Theory] + [InlineData(0u)] + [InlineData(82_500u)] + [InlineData(4_294_967_294u)] // 0xFFFFFFFE — max valid; 0xFFFFFFFF is n/a sentinel + public void Frequency_roundtrips_to_max_valid(uint hz) + { + var back = Telemetry.DecodePayload(new Telemetry { FrequencyHz = hz }.EncodePayload()); + Assert.Equal(hz, back.FrequencyHz); + } + + // --- Telemetry: guidance offset (int16, documented range -1000..+1000; sentinel 0x8000) -- + [Theory] + [InlineData((short)-1000)] + [InlineData((short)-1)] + [InlineData((short)0)] + [InlineData((short)1)] + [InlineData((short)1000)] + [InlineData((short)-32767)] // wire minimum (just above the sentinel) + [InlineData((short)32767)] // wire maximum + public void GuidanceOffset_roundtrips_across_range(short offset) + { + var back = Telemetry.DecodePayload(new Telemetry { GuidanceOffset = offset }.EncodePayload()); + Assert.Equal(offset, back.GuidanceOffset); + } + + // --- Telemetry: single-byte fields at 0 and 0xFF ------------------------------------- + [Theory] + [InlineData((byte)0)] + [InlineData((byte)100)] + [InlineData((byte)255)] + public void Byte_fields_roundtrip_at_extremes(byte v) + { + var t = new Telemetry + { + GainDb = v, SignalLevel = v, DistortionQualityPct = v, + SignalDirection = v, CompassAngleDeg = v, BatteryPercent = v, + }; + var back = Telemetry.DecodePayload(t.EncodePayload()); + Assert.Equal(v, back.GainDb); + Assert.Equal(v, back.SignalLevel); + Assert.Equal(v, back.DistortionQualityPct); + Assert.Equal(v, back.SignalDirection); + Assert.Equal(v, back.CompassAngleDeg); + Assert.Equal(v, back.BatteryPercent); + } + + // --- Telemetry: full bitfields set --------------------------------------------------- + [Fact] + public void All_warning_and_status_flags_roundtrip() + { + var allWarn = WarningFlags.Shallow | WarningFlags.Overload | WarningFlags.SwingTilt + | WarningFlags.DepthInvalid | WarningFlags.CurrentInvalid | WarningFlags.OutOfRange + | WarningFlags.DistortionHigh | WarningFlags.LowBattery; + var allStatus = StatusFlags.Locating | StatusFlags.MenuActive + | StatusFlags.TimeSynced | StatusFlags.DepthModeAuto; + var back = Telemetry.DecodePayload( + new Telemetry { Warnings = allWarn, Status = allStatus }.EncodePayload()); + Assert.Equal(allWarn, back.Warnings); + Assert.Equal(allStatus, back.Status); + } + + // --- CaptureResult: WGS84 position extremes (int32 * 1e7) ----------------------------- + [Theory] + [InlineData(90.0, 180.0)] + [InlineData(-90.0, -180.0)] + [InlineData(0.0, 0.0)] + [InlineData(45.7649321, 4.8354792)] + public void CaptureResult_position_roundtrips_at_wgs84_extremes(double lat, double lon) + { + var r = new CaptureResult + { + Outcome = CaptureOutcome.Stored, Reason = ReasonCode.Ok, FixType = FixType.RtkFixed, + Lat = lat, Lon = lon, OrthometricHeightM = 0.0, + }; + var back = CaptureResult.DecodeFrame(r.EncodeFrame(0)); + Assert.Equal(lat, back.Lat!.Value, 7); + Assert.Equal(lon, back.Lon!.Value, 7); + } + + // --- CaptureResult: RMS accuracy (uint16 mm, sentinel 0xFFFF) ------------------------- + [Theory] + [InlineData(0.0)] + [InlineData(0.001)] // 1 mm resolution step + [InlineData(65.534)] // 65534 mm — max valid; 65535 == RmsUnknown sentinel + public void CaptureResult_rms_roundtrips_to_max_valid(double meters) + { + var r = new CaptureResult + { + Outcome = CaptureOutcome.Stored, Reason = ReasonCode.Ok, FixType = FixType.RtkFixed, + Lat = 0, Lon = 0, OrthometricHeightM = 0, HrmsM = meters, VrmsM = meters, + }; + var back = CaptureResult.DecodeFrame(r.EncodeFrame(0)); + Assert.Equal(meters, back.HrmsM!.Value, 3); + Assert.Equal(meters, back.VrmsM!.Value, 3); + } + + // --- Characterisation of the sentinel-collision boundaries --------------------------- + // These pin the *edges* of the representable ranges so the frozen contract documents + // them explicitly. A magnitude one step beyond the max valid value aliases the field's + // no-value sentinel and therefore decodes to null — i.e. it is NOT representable. Flagged + // to app-owner for the range notes in ble-device-interface.md (IF-LOC v1.0). + [Fact] + public void Depth_negative_full_scale_aliases_the_no_value_sentinel() + { + // -327.68 m -> -32768 cm == DepthNoValue (0x8000): not a representable depth. + var back = Telemetry.DecodePayload(new Telemetry { DepthMeters = -327.68 }.EncodePayload()); + Assert.Null(back.DepthMeters); + } + + [Fact] + public void SignalCurrent_0xFFFF_aliases_the_invalid_sentinel() + { + var back = Telemetry.DecodePayload(new Telemetry { SignalCurrentMa = 0xFFFF }.EncodePayload()); + Assert.Null(back.SignalCurrentMa); + } +}