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:
158
tests/IfLoc.Sim.Tests/BoundaryTests.cs
Normal file
158
tests/IfLoc.Sim.Tests/BoundaryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user