test(S1-b): add IF-LOC codec boundary/edge-case vectors

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 <noreply@anthropic.com>
This commit is contained in:
Brent Perteet
2026-08-20 13:13:49 -05:00
parent 11cad9a78a
commit a788cebea3

View File

@@ -0,0 +1,158 @@
using IfLoc.Sim;
using Xunit;
namespace IfLoc.Sim.Tests;
/// <summary>
/// QA-owned boundary/edge-case vectors for the frozen IF-LOC v1.0 codec (S1-b, SRS §3.1).
/// Complements <see cref="RoundTripTests"/> (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.
/// </summary>
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);
}
}