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>
159 lines
6.8 KiB
C#
159 lines
6.8 KiB
C#
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);
|
|
}
|
|
}
|