-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
Verifies the CRC-16/MODBUS implementation (ModbusCrc.Compute / ModbusCrc.Verify, poly 0xA001, init 0xFFFF).
-
Canonical check value — the standard
0x4B37check 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;
Verifymust return false.
byte[] frame = ModbusFrame.Read(address: 0x0001, quantity: 0x0010);
byte[] crc = ModbusCrc.Compute(frame[..^2]);
Assert.True(ModbusCrc.Verify(frame));Covers the static frame-builder and parser.
-
Header layout —
Read()andWriteSingle()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
slaveandfunctionarguments; the resulting frame is verified field-by-field. -
Parse round-trip — a frame built by
Read()is fed straight intoParse(); the returnedResponsecarries the expectedFunctionCodeand a non-nullPayload. -
Write echo — a
WriteSingleframe 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);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
Pushcalls 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, andreadInputFunctionconstructor 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);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
SolarReadingwith 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;
DecodeSolarreturnsnullbecause the key-check byte does not match. -
Invalid key — calling
DecodeSolarwith a key of the wrong length or invalid hex returnsnullwithout 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);Exercises MpptRegisters.DecodeLive and MpptRegisters.DecodeSettings.
-
Known register block — a hand-crafted
int[]matching the live register layout (LiveAddress = 0x0001, quantity0x0010) is decoded; each field of the resultingMpptLiveis checked against the expected value. -
Injected SoC resolver —
DecodeLiveaccepts aFunc<double,double>for SoC estimation; the test injects a known function and verifiesSocEstimatereflects 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);-
Interpolate — a voltage midway between
CutoffVoltageSetpoint(0 %) andChargeVoltageSetpoint(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,
ComputeSocreturnsnull.
-
SocFromVoltageis verified against the linear interpolation betweenEmptyVandFullV. -
CapacityWhis checked asCapacityAh * NominalV. -
DeltaWhis checked for a known from/to SoC pair. -
InferredNetCurrentAis verified for sign and magnitude given a known elapsed time.
ChargerStateLogic.FromRegisters branches are exercised with representative register combinations that produce each ChargerState value (Bulk, Boost, Float, Idle, LoadOff, Fault, Unknown).
-
BatteryWatts,LoadWatts, andApproxPvWattsare verified against records constructed with known current values. - The SoC lookup table exercised via
MpptLive.EstimateSocis checked at boundary voltages (the fallback 12 V lead-acid table).
-
DisplayName()andTypicalNominalV()extension methods are checked for each named enum member to confirm no case is missing.
DemoDriver is the only concrete IDeviceDriver in the library. Its tests confirm the lifecycle state machine:
-
Statestarts atConnectionState.Idle. -
StartAsynctransitions toReadyand begins firingLiveChangedevents. -
Liveis non-null after starting and each sample passes theMpptLivecomputed-property invariants. -
StopAsynctransitions back toIdleand stops further events. -
DisposeAsynccan 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.
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]);
}
}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(orIAdvertisementSource) feeds canned frames or adverts to the driver and the test asserts on the decodedMpptLive.
⚡ OpenMPPT Wiki · Repository · GPL-3.0-or-later · contributions welcome