using System.Runtime.CompilerServices; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; internal class FakeTwinCATClient : ITwinCATClient { public bool IsConnected { get; private set; } public int ConnectCount { get; private set; } public int DisposeCount { get; private set; } public bool ThrowOnConnect { get; set; } public bool ThrowOnRead { get; set; } public bool ThrowOnWrite { get; set; } public bool ThrowOnProbe { get; set; } public Exception? Exception { get; set; } public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new(); public List<(string symbol, TwinCATDataType type, int? bit, int[]? arrayDimensions)> ReadLog { get; } = new(); public bool ProbeResult { get; set; } = true; // ---- PR 2.2: handle-cache tracking ---- // // The fake mirrors the production AdsTwinCATClient handle-cache state machine so the // unit + integration tests can assert "second read of X reused the cached handle" // without hitting a real ADS device. EnsureFakeHandle is called by every per-tag read / // write path and increments HandleCreateInvocations only on a cache miss. Tests can // arm SymbolVersionInvalidOnNextRead / Write to drive the evict-and-retry path. public List HandleCreateInvocations { get; } = new(); public List HandleDeleteInvocations { get; } = new(); public List ReadByHandleInvocations { get; } = new(); public List WriteByHandleInvocations { get; } = new(); public int FlushOptionalCachesCount { get; private set; } /// Inject DeviceSymbolVersionInvalid into the next read of this symbol. public HashSet SymbolVersionInvalidOnNextRead { get; } = new(StringComparer.OrdinalIgnoreCase); /// Inject DeviceSymbolVersionInvalid into the next write of this symbol. public HashSet SymbolVersionInvalidOnNextWrite { get; } = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _handleCache = new(StringComparer.OrdinalIgnoreCase); private uint _nextHandle = 1; private uint EnsureFakeHandle(string symbolPath) { if (_handleCache.TryGetValue(symbolPath, out var existing)) return existing; HandleCreateInvocations.Add(symbolPath); var handle = _nextHandle++; _handleCache[symbolPath] = handle; return handle; } private void EvictFakeHandle(string symbolPath) { if (_handleCache.Remove(symbolPath, out var handle)) HandleDeleteInvocations.Add(handle); } /// Test helper — current cached-handle count. public int HandleCacheCount => _handleCache.Count; /// Test helper — true when the symbol currently has a cached handle. public bool HasCachedHandle(string symbolPath) => _handleCache.ContainsKey(symbolPath); public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct) { ConnectCount++; if (ThrowOnConnect) throw Exception ?? new InvalidOperationException(); // PR 2.2 — production wipes the handle cache on every (re)connect because handle // identity is per-AMS-session. Mirror so tests of the reconnect flow see the same // cache-clear semantics. Existing handles are dead with the prior session, no // wire-side delete needed. if (IsConnected) _handleCache.Clear(); IsConnected = true; // PR 2.3 — production arms the Symbol-Version listener after the AMS session is up. // Mirror so unit tests can assert "ConnectAsync registered the version listener". // Default cache-wipe handler matches AdsTwinCATClient.OnAdsSymbolVersionChanged. if (!SymbolVersionRegistered) RegisterSymbolVersionListener(WipeHandleCacheOnVersionBump); return Task.CompletedTask; } private void WipeHandleCacheOnVersionBump() { // Note: kv-snapshot delete already happened in FireSymbolVersionChange before // this callback fires, so the cache is already empty. Kept as the default // listener so the wiring contract is still observable through tests that pass // a custom action via RegisterSymbolVersionListener. } /// Test helper — simulate a reconnect (ConnectAsync after the connection drops). public void SimulateReconnect() { IsConnected = false; _handleCache.Clear(); // Production marks the listener as needing re-registration on reconnect. UnregisterSymbolVersionListener(); IsConnected = true; if (!SymbolVersionRegistered) RegisterSymbolVersionListener(WipeHandleCacheOnVersionBump); } public virtual Task<(object? value, uint status)> ReadValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, CancellationToken ct) { if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); ReadLog.Add((symbolPath, type, bitIndex, arrayDimensions)); // PR 2.2 — mirror the production handle-cache state machine: resolve handle (cache // miss → HandleCreateInvocations++), do read-by-handle, on injected // SymbolVersionInvalid evict + retry once, then deliver the live value. ReadOneByHandle(symbolPath); var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; var value = Values.TryGetValue(symbolPath, out var v) ? v : null; return Task.FromResult((value, status)); } private void ReadOneByHandle(string symbolPath) { EnsureFakeHandle(symbolPath); ReadByHandleInvocations.Add(symbolPath); if (SymbolVersionInvalidOnNextRead.Remove(symbolPath)) { EvictFakeHandle(symbolPath); EnsureFakeHandle(symbolPath); // retry — fresh handle ReadByHandleInvocations.Add(symbolPath); } } private void WriteOneByHandle(string symbolPath) { EnsureFakeHandle(symbolPath); WriteByHandleInvocations.Add(symbolPath); if (SymbolVersionInvalidOnNextWrite.Remove(symbolPath)) { EvictFakeHandle(symbolPath); EnsureFakeHandle(symbolPath); // retry — fresh handle WriteByHandleInvocations.Add(symbolPath); } } public virtual Task WriteValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, object? value, CancellationToken ct) { if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); WriteLog.Add((symbolPath, type, bitIndex, value)); // Model the parent-word RMW path the production AdsTwinCATClient performs for // bit-indexed BOOL writes so driver-level tests can assert the resulting parent state. if (bitIndex is int bit && type == TwinCATDataType.Bool) { var parentPath = AdsTwinCATClient.TryGetParentSymbolPath(symbolPath); if (parentPath is not null) { // RMW touches the parent word twice (read + write); each goes through the // handle cache, exactly mirroring the production path. ReadOneByHandle(parentPath); WriteOneByHandle(parentPath); var current = Values.TryGetValue(parentPath, out var p) && p is not null ? Convert.ToUInt32(p) : 0u; Values[parentPath] = AdsTwinCATClient.ApplyBit( current, bit, Convert.ToBoolean(value)); } } else { WriteOneByHandle(symbolPath); Values[symbolPath] = value; } var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; return Task.FromResult(status); } public virtual Task FlushOptionalCachesAsync() { FlushOptionalCachesCount++; // Mirror production: emit a delete record per cached handle, then clear. foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value); _handleCache.Clear(); return Task.CompletedTask; } // ---- PR 2.3: Symbol-Version invalidation listener ---- // // Mirror of the Beckhoff AdsClient.AdsSymbolVersionChanged surface. ConnectAsync // arms the listener so test asserts can verify the production driver registered // it on connect; FireSymbolVersionChange() drives the same handle-cache-wipe path // AdsTwinCATClient runs on a real PLC online change. public bool SymbolVersionRegistered { get; private set; } public int SymbolVersionRegistrationCount { get; private set; } public int SymbolVersionUnregistrationCount { get; private set; } public long SymbolVersionBumps { get; private set; } /// Externally-supplied callback (production wires this to the cache wipe). private Action? _onSymbolVersionChanged; /// /// Test helper exposed in lieu of the Beckhoff event surface — the production /// AdsTwinCATClient registers via RegisterSymbolVersionChangedAsync /// after connect; the fake records the registration here so tests can assert /// "subscribed on connect". The callback (an rather than the /// full shape) is the cache-wipe entry point. /// public void RegisterSymbolVersionListener(Action onChange) { _onSymbolVersionChanged = onChange; SymbolVersionRegistered = true; SymbolVersionRegistrationCount++; } public void UnregisterSymbolVersionListener() { if (!SymbolVersionRegistered) return; _onSymbolVersionChanged = null; SymbolVersionRegistered = false; SymbolVersionUnregistrationCount++; } /// /// Drive the Symbol-Version-changed callback path. Production wipes the handle /// cache + bumps the diagnostic counter; mirror so unit tests can assert /// post-bump state without standing up a real ADS device. Safe to call when no /// listener is registered (no-op + still bumps the counter so test code can /// assert "we tried but no-one was listening"). /// public void FireSymbolVersionChange() { SymbolVersionBumps++; // Mirror production cache-wipe semantics: snapshot, clear, emit per-entry deletes. foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value); _handleCache.Clear(); _onSymbolVersionChanged?.Invoke(); } public virtual Task ProbeAsync(CancellationToken ct) { if (ThrowOnProbe) return Task.FromResult(false); return Task.FromResult(ProbeResult); } // ---- Bulk surface (PR 2.1: SumSymbolRead / SumSymbolWrite) ---- public List> BulkReadInvocations { get; } = new(); public List> BulkWriteInvocations { get; } = new(); public bool ThrowOnBulkRead { get; set; } public bool ThrowOnBulkWrite { get; set; } /// Per-symbol read failure injection — overlay onto . public Dictionary BulkReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); public virtual Task> ReadValuesAsync( IReadOnlyList reads, CancellationToken ct) { // ThrowOnRead applies to both per-tag + bulk paths so legacy tests that toggled // ThrowOnRead before bulk existed still surface BadCommunicationError correctly. if (ThrowOnRead || ThrowOnBulkRead) throw Exception ?? new InvalidOperationException(); BulkReadInvocations.Add(reads); // Preserve request order — the production sum-read returns one entry per request slot // even on partial failure; so does this fake. var output = new (object? value, uint status)[reads.Count]; for (var i = 0; i < reads.Count; i++) { var r = reads[i]; ReadLog.Add((r.SymbolPath, r.Type, null, null)); if (BulkReadStatuses.TryGetValue(r.SymbolPath, out var bulkStatus)) { output[i] = (null, bulkStatus); continue; } if (ReadStatuses.TryGetValue(r.SymbolPath, out var status) && status != TwinCATStatusMapper.Good) { output[i] = (null, status); continue; } var value = Values.TryGetValue(r.SymbolPath, out var v) ? v : null; output[i] = (value, TwinCATStatusMapper.Good); } return Task.FromResult>(output); } public virtual Task> WriteValuesAsync( IReadOnlyList writes, CancellationToken ct) { if (ThrowOnWrite || ThrowOnBulkWrite) throw Exception ?? new InvalidOperationException(); BulkWriteInvocations.Add(writes); var output = new uint[writes.Count]; for (var i = 0; i < writes.Count; i++) { var w = writes[i]; WriteLog.Add((w.SymbolPath, w.Type, null, w.Value)); Values[w.SymbolPath] = w.Value; output[i] = WriteStatuses.TryGetValue(w.SymbolPath, out var s) ? s : TwinCATStatusMapper.Good; } return Task.FromResult>(output); } public virtual void Dispose() { DisposeCount++; IsConnected = false; // PR 2.2 — production deletes cached handles on Dispose; mirror so tests can assert // the fan-out delete count matches. foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value); _handleCache.Clear(); // PR 2.3 — production unregisters the Symbol-Version listener on Dispose. UnregisterSymbolVersionListener(); } // ---- notification fake ---- public List Notifications { get; } = new(); public bool ThrowOnAddNotification { get; set; } public virtual Task AddNotificationAsync( string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, Action onChange, CancellationToken cancellationToken) { if (ThrowOnAddNotification) throw Exception ?? new InvalidOperationException("fake AddNotification failure"); var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this); Notifications.Add(reg); return Task.FromResult(reg); } /// Fire a change event through the registered callback for . public void FireNotification(string symbolPath, object? value) { foreach (var n in Notifications) if (!n.Disposed && string.Equals(n.SymbolPath, symbolPath, StringComparison.OrdinalIgnoreCase)) n.OnChange(symbolPath, value); } // ---- symbol browser fake ---- public List BrowseResults { get; } = new(); public bool ThrowOnBrowse { get; set; } public virtual async IAsyncEnumerable BrowseSymbolsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure"); await Task.CompletedTask; foreach (var sym in BrowseResults) { if (cancellationToken.IsCancellationRequested) yield break; yield return sym; } } public sealed class FakeNotification( string symbolPath, TwinCATDataType type, int? bitIndex, Action onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle { public string SymbolPath { get; } = symbolPath; public TwinCATDataType Type { get; } = type; public int? BitIndex { get; } = bitIndex; public Action OnChange { get; } = onChange; public bool Disposed { get; private set; } public void Dispose() { Disposed = true; owner.Notifications.Remove(this); } } } internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory { public List Clients { get; } = new(); public Func? Customise { get; set; } public ITwinCATClient Create() { var client = Customise?.Invoke() ?? new FakeTwinCATClient(); Clients.Add(client); return client; } }