Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs
T
Joseph Doherty b815446940 refactor(drivers): delete GetMemoryFootprint / FlushOptionalCachesAsync (#525)
Both `IDriver` members had no production call site. Their only consumer was
`MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and
`GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which
had not been true since v3, even before #522 removed the poller.

Owner decision on #525 was (a) delete rather than (b) build the process-wide
watchdog that would consume them. The driver-side implementations are in git
history and were mostly reusable; what was always missing is the trigger, and a
member nobody calls is how the misleading doc-comment survived this long.

Removed from `IDriver`, all 12 driver implementations, and every test stub —
plus the support code that existed only to feed them, which is the part a
member-only deletion would have left behind as dead weight:

- MTConnect's three per-entry byte-weight constants, its cached
  `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run
  on every cache fill to populate a number with no remaining reader.
- OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it.
- MQTT's `ApproxBytesPerAuthoredTag`.

Two behavioural notes, neither a change in what the software does:

- `AbCipTemplateCache` now has no production reset path. Its only caller was
  AB CIP's flush, which itself had no caller — so nothing changes except how
  visible the fact is. Recorded on the type, along with the correction that its
  comment's claim about a device `ReinitializeAsync` also flushing it was never
  true. A UDT redefined on a live controller keeps its stale shape until the
  driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
  Agent restart. That is now `internal` rather than `private` so the tests which
  used the flush purely to empty the cache drive the real path instead — they
  now cover a case that actually occurs in production. Several unrelated doc
  comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are
  repointed to it.

Tests: the ones that existed only to assert on the removed members are deleted
(3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was
vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a
footprint of 0 before the flush and 0 after with no discovery in between, so it
would have passed against a flush that did nothing at all.

Full suite green except 7 failures verified identical on master before and
after (4 AbLegacy integration with the compose profile down, 3
Host.IntegrationTests), checked by name rather than assumed.

Docs corrected where they described this as live: `DriverLifecycle.md` (which
carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and
`v2/driver-stability.md` — whose "polls this every 30 s" + per-driver
`DriverConfig` memory budget were never built as written and are now banner-ed
as design intent rather than description.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 09:07:11 -04:00

259 lines
10 KiB
C#

using System.Buffers.Binary;
using System.Reflection;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipFetchUdtShapeTests
{
/// <summary>Test implementation of IAbCipTemplateReader.</summary>
private sealed class FakeTemplateReader : IAbCipTemplateReader
{
/// <summary>Gets or sets the response bytes to return.</summary>
public byte[] Response { get; set; } = [];
/// <summary>Gets the count of read operations.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets a value indicating whether the reader has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Gets the last template ID read.</summary>
public uint LastTemplateId { get; private set; }
/// <summary>Reads the template data for the specified device and template ID.</summary>
/// <param name="deviceParams">The device parameters.</param>
/// <param name="templateInstanceId">The template instance ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the template response bytes.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
{
ReadCount++;
LastTemplateId = templateInstanceId;
return Task.FromResult(Response);
}
/// <summary>Disposes the reader.</summary>
public void Dispose() => Disposed = true;
}
/// <summary>Test factory for creating fake template readers.</summary>
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <summary>Gets the list of created readers.</summary>
public List<IAbCipTemplateReader> Readers { get; } = new();
/// <summary>Gets or sets an optional customization function for reader creation.</summary>
public Func<IAbCipTemplateReader>? Customise { get; set; }
/// <summary>Creates a new template reader.</summary>
/// <returns>The created reader.</returns>
public IAbCipTemplateReader Create()
{
var r = Customise?.Invoke() ?? new FakeTemplateReader();
Readers.Add(r);
return r;
}
}
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
var headerSize = 12;
var blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
{
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)!;
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
}
/// <summary>Verifies that FetchUdtShapeAsync decodes a blob and caches the result.</summary>
[Fact]
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader
{
Response = BuildSimpleTemplate("MotorUdt", 8,
("Speed", 0xC4, 0, 0),
("Enabled", 0xC1, 0, 4)),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
shape.ShouldNotBeNull();
shape.TypeName.ShouldBe("MotorUdt");
shape.Members.Count.ShouldBe(2);
// Second fetch must hit the cache — no second reader created.
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
factory.Readers.Count.ShouldBe(1);
}
/// <summary>Verifies that different template IDs result in separate fetch operations.</summary>
[Fact]
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
{
var callCount = 0;
var factory = new FakeTemplateReaderFactory
{
Customise = () =>
{
callCount++;
var name = callCount == 1 ? "UdtA" : "UdtB";
return new FakeTemplateReader
{
Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)),
};
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2);
a!.TypeName.ShouldBe("UdtA");
b!.TypeName.ShouldBe("UdtB");
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Verifies that FetchUdtShapeAsync returns null for an unknown device.</summary>
[Fact]
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
{
var factory = new FakeTemplateReaderFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1);
shape.ShouldBeNull();
factory.Readers.ShouldBeEmpty();
}
/// <summary>Verifies that a decode failure returns null and does not cache the result.</summary>
[Fact]
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape.ShouldBeNull();
// Next call retries (not cached as a failure).
var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape2.ShouldBeNull();
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Verifies that a reader exception returns null.</summary>
[Fact]
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new ThrowingTemplateReader(),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape.ShouldBeNull();
}
/// <summary>Verifies that a cleared template cache is re-populated from the network.</summary>
[Fact]
public async Task A_cleared_template_cache_refetches_from_the_controller()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader
{
Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
drv.TemplateCache.Count.ShouldBe(1);
// Gitea #525 deleted AbCipDriver.FlushOptionalCachesAsync, which is what used to clear this;
// the cache's own Clear() is now the only reset, and nothing in production calls it (see the
// AbCipTemplateCache remarks). What this test still pins is the cache-miss path below.
drv.TemplateCache.Clear();
drv.TemplateCache.Count.ShouldBe(0);
// Next fetch hits the network again.
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Test implementation of IAbCipTemplateReader that throws on read.</summary>
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
{
/// <summary>Throws an exception when read is attempted.</summary>
/// <param name="p">The device parameters.</param>
/// <param name="id">The template ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Never returns; throws instead.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
throw new InvalidOperationException("fake read failure");
/// <summary>Disposes the reader.</summary>
public void Dispose() { }
}
}