Skip to content

Testing

tsgb edited this page Jun 4, 2026 · 2 revisions

Testing

The entire OpenMPPT test suite runs headless — no hardware, no BLE adaptor, no live device of any kind. This is the direct payoff of keeping codecs and models as pure .NET logic, entirely separate from platform transports. Everything the tests need is constructed in memory.

The suite lives alongside the library in the same solution (OpenMppt.slnx) and currently contains 83 xUnit tests targeting net10.0.

Running the tests

dotnet test OpenMppt.slnx

All 83 tests should pass with no configuration. There are no environment variables to set, no secrets, and no network access required.

What the suite covers

ModbusCrc

Verifies the CRC-16/MODBUS implementation (ModbusCrc.Compute / ModbusCrc.Verify, poly 0xA001, init 0xFFFF).

  • Canonical check value — the standard 0x4B37 check value is reproduced from the spec byte sequence, confirming the algorithm is correct end-to-end.
  • Tamper rejection — a valid frame is mutated by flipping a single byte; Verify must return false.
byte[] frame = ModbusFrame.Read(address: 0x0001, quantity: 0x0010);
byte[] crc = ModbusCrc.Compute(frame[..^2]);
Assert.True(ModbusCrc.Verify(frame));

ModbusFrame

Covers the static frame-builder and parser.

  • Header layoutRead() and WriteSingle() produce correctly structured byte arrays (slave id, function code, address bytes, quantity/value bytes, CRC).
  • Custom slave and function code — callers may pass non-default slave and function arguments; the resulting frame is verified field-by-field.
  • Parse round-trip — a frame built by Read() is fed straight into Parse(); the returned Response carries the expected FunctionCode and a non-null Payload.
  • Write echo — a WriteSingle frame is parsed back; Response.Registers() reflects the written value.
byte[] req = ModbusFrame.Read(address: 0x1001, quantity: 0x000F, slave: 0x01, function: 0x03);
ModbusFrame.Response? resp = ModbusFrame.Parse(req, slave: 0x01);
Assert.NotNull(resp);
Assert.Equal(0x03, resp!.FunctionCode);

ModbusReassembler

The reassembler (ModbusReassembler) handles the reality that a BytesReceived event from an IFrameTransport may deliver bytes in arbitrary chunk sizes.

  • Single frame — a complete frame delivered in one push is returned immediately.
  • Split frames — the same frame split across two Push calls is held internally and returned only when complete.
  • Merged frames — two back-to-back frames delivered in a single push are returned as two separate entries from that call.
  • Garbage-prefix resync — leading noise bytes before a valid frame header are discarded byte-by-byte until sync is recovered.
  • Bad-CRC resync — a frame with a corrupted CRC causes the reassembler to drop a leading byte and rescan; a well-formed frame following immediately is still recovered.
  • Custom slave and write echo — the slave, readFunction, and readInputFunction constructor parameters are respected; write-response frames are also returned correctly.
var asm = new ModbusReassembler(slave: 0x01);
byte[] full = ModbusFrame.Read(address: 0x0001, quantity: 0x0010);

// Deliver in two halves
var first  = asm.Push(full.AsSpan(0, full.Length / 2));
var second = asm.Push(full.AsSpan(full.Length / 2));

Assert.Empty(first);
Assert.Single(second);

VictronDecoder

Victron Instant Readout frames are AES-CTR encrypted with a per-device 16-byte key. Because real Victron keys are device-specific secrets, the tests use a self-consistent synthetic vector constructed entirely inside the test itself — the same key used to encrypt the test vector is the key passed to DecodeSolar, so no external key material is needed.

  • Self-consistent vector — the synthetic payload decodes to a SolarReading with the field values baked into the vector; all non-null fields are checked.
  • Key-check mismatch — a payload encrypted with key A is decoded with key B; DecodeSolar returns null because the key-check byte does not match.
  • Invalid key — calling DecodeSolar with a key of the wrong length or invalid hex returns null without throwing.
// Key and payload constructed together in the test; no real device needed.
SolarReading? reading = VictronDecoder.DecodeSolar(payload, keyHex: testKeyHex);
Assert.NotNull(reading);
Assert.NotNull(reading!.BatteryVoltage);

MpptRegisters

Exercises MpptRegisters.DecodeLive and MpptRegisters.DecodeSettings.

  • Known register block — a hand-crafted int[] matching the live register layout (LiveAddress = 0x0001, quantity 0x0010) is decoded; each field of the resulting MpptLive is checked against the expected value.
  • Injected SoC resolverDecodeLive accepts a Func<double,double> for SoC estimation; the test injects a known function and verifies SocEstimate reflects its output.
  • Short-block throws — passing a register array shorter than the expected quantity throws, confirming the decoder validates input length.
int[] regs = BuildLiveRegisters(batteryV: 25.6, chargeCurrent: 5.0);
MpptLive live = MpptRegisters.DecodeLive(regs, timestampMs: 0L, socFromVoltage: v => 0.75);
Assert.Equal(0.75, live.SocEstimate);

Model layer

MpptSettings.ComputeSoc

  • Interpolate — a voltage midway between CutoffVoltageSetpoint (0 %) and ChargeVoltageSetpoint (100 %) returns the expected fractional value.
  • Clamp — voltages below cutoff return 0.0; voltages above charge setpoint return 1.0.
  • Degenerate — when cutoff equals charge setpoint, ComputeSoc returns null.

BatteryProfile

  • SocFromVoltage is verified against the linear interpolation between EmptyV and FullV.
  • CapacityWh is checked as CapacityAh * NominalV.
  • DeltaWh is checked for a known from/to SoC pair.
  • InferredNetCurrentA is verified for sign and magnitude given a known elapsed time.

ChargerStateLogic

ChargerStateLogic.FromRegisters branches are exercised with representative register combinations that produce each ChargerState value (Bulk, Boost, Float, Idle, LoadOff, Fault, Unknown).

MpptLive computed properties

  • BatteryWatts, LoadWatts, and ApproxPvWatts are verified against records constructed with known current values.
  • The SoC lookup table exercised via MpptLive.EstimateSoc is checked at boundary voltages (the fallback 12 V lead-acid table).

BatteryChemistry

  • DisplayName() and TypicalNominalV() extension methods are checked for each named enum member to confirm no case is missing.

DemoDriver lifecycle

DemoDriver is the only concrete IDeviceDriver in the library. Its tests confirm the lifecycle state machine:

  • State starts at ConnectionState.Idle.
  • StartAsync transitions to Ready and begins firing LiveChanged events.
  • Live is non-null after starting and each sample passes the MpptLive computed-property invariants.
  • StopAsync transitions back to Idle and stops further events.
  • DisposeAsync can be called safely after stop.

DemoDriver.Sample is also tested in isolation — a call with a fixed tSec and a known socFromVoltage delegate must return an MpptLive whose fields are within the expected synthetic range.

Adding a test

Pure codec or model test

Create a new test class in the test project and reference the relevant namespace (OpenMppt.Codecs.Modbus, OpenMppt.Codecs.Victron, or OpenMppt.Model). No special setup is needed — construct input data in memory and assert on outputs.

public class MyModbusTest
{
    [Fact]
    public void WriteSingle_RoundTrip()
    {
        byte[] frame = ModbusFrame.WriteSingle(address: 0x1001, value: 42);
        ModbusFrame.Response? resp = ModbusFrame.Parse(frame);
        Assert.NotNull(resp);
        Assert.Equal(42, resp!.Registers()[0]);
    }
}

Driver test with a fake transport

To test a driver that consumes an IFrameTransport, implement the interface in the test project as a fake that lets test code inject bytes and capture writes:

internal sealed class FakeTransport : IFrameTransport
{
    public TransportState State { get; private set; } = TransportState.Disconnected;
    public event Action<TransportState>? StateChanged;
    public event Action<ReadOnlyMemory<byte>>? BytesReceived;

    public Task<bool> ConnectAsync(string address, CancellationToken ct = default)
    {
        State = TransportState.Connected;
        StateChanged?.Invoke(State);
        return Task.FromResult(true);
    }

    public Task<bool> WriteAsync(ReadOnlyMemory<byte> bytes, CancellationToken ct = default)
        => Task.FromResult(true);   // capture bytes here if needed

    public Task DisconnectAsync()
    {
        State = TransportState.Disconnected;
        StateChanged?.Invoke(State);
        return Task.CompletedTask;
    }

    /// <summary>Simulate the device sending a response frame.</summary>
    public void Inject(byte[] frame) => BytesReceived?.Invoke(frame);

    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

Pass the fake to the driver under test instead of a real BLE or serial transport. Call Inject to simulate device responses and assert on the driver's Live, Settings, or FrameSeen events.

Vendor drivers for generic Modbus MPPT, Victron Instant Readout, Renogy/SRNE, and EPEver are built and tested exactly this way: a fake IFrameTransport (or IAdvertisementSource) feeds canned frames or adverts to the driver and the test asserts on the decoded MpptLive.

See also

Clone this wiki locally