b815446940
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
259 lines
10 KiB
C#
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() { }
|
|
}
|
|
}
|