using System.Collections.Concurrent; using System.Runtime.CompilerServices; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; internal class FakeTwinCATClient : ITwinCATClient { /// Gets a value indicating whether the client is connected. public bool IsConnected { get; private set; } /// Gets the number of times Connect has been called. public int ConnectCount { get; private set; } /// Gets the number of times Dispose has been called. public int DisposeCount { get; private set; } /// Gets or sets a value indicating whether ConnectAsync should throw. public bool ThrowOnConnect { get; set; } /// Gets or sets a value indicating whether ReadValueAsync should throw. public bool ThrowOnRead { get; set; } /// Gets or sets a value indicating whether WriteValueAsync should throw. public bool ThrowOnWrite { get; set; } /// Gets or sets a value indicating whether ProbeAsync should throw. public bool ThrowOnProbe { get; set; } /// Gets or sets the exception to throw when a throw flag is set. public Exception? Exception { get; set; } /// Gets the simulated values by symbol path. public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); /// Gets the read statuses by symbol path. public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); /// Gets the write statuses by symbol path. public Dictionary WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); /// Records the arrayCount argument of the most recent /// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested /// the array with the authored length. public int? LastReadArrayCount { get; private set; } /// Gets the log of all write operations. public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new(); /// Gets or sets the result returned by ProbeAsync. public bool ProbeResult { get; set; } = true; /// Occurs when the symbol version changes. public event EventHandler? OnSymbolVersionChanged; /// Test hook — fire the symbol-version-changed signal as the real client would. public void FireSymbolVersionChanged() => OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty); /// Test hook — simulate a wire-level drop that leaves the AMS port "connected" but dead: /// flips to false without disposing, so the driver's IsConnected /// fast-path treats the client as stale and rebuilds (exercises the STAB-2 reconnect+replay). public void SimulateWireDrop() => IsConnected = false; /// Simulates connecting to the TwinCAT system. /// The AMS address to connect to. /// The connection timeout. /// The cancellation token. /// A task that completes when the connection succeeds or fails. public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct) { ConnectCount++; if (ThrowOnConnect) throw Exception ?? new InvalidOperationException(); if (BlockConnect is { } block) { // Models the real client's blocking SDK Connect: ignores the token until released. return block.Task.ContinueWith(_ => { IsConnected = true; }, TaskScheduler.Default); } IsConnected = true; return Task.CompletedTask; } /// When set, does not complete (and ignores cancellation) until this /// is completed — a probe wedged in the synchronous ADS Connect. public TaskCompletionSource? BlockConnect { get; set; } /// Gets or sets the simulated notification-queue drop count. public long DroppedNotificationCount { get; set; } /// Simulates reading a value from a symbol. /// The path to the symbol to read. /// The data type of the symbol. /// The optional bit index for bit-level reads. /// The optional 1-D array element count (null = scalar read). /// The cancellation token. /// A task that returns the simulated value and status. public virtual Task<(object? value, uint status)> ReadValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct) { if (BlockRead is { } block) { // A read wedged in the (uncancellable) wire call; completes with the normal answer once released. return block.Task.ContinueWith(_ => { BlockRead = null; return ReadValueAsync(symbolPath, type, bitIndex, arrayCount, CancellationToken.None).Result; }, TaskScheduler.Default); } if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); Interlocked.Increment(ref _singleReadCount); LastReadArrayCount = arrayCount; 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)); } /// When set, does not complete (and ignores cancellation) until this is /// completed — a poll read wedged in the wire call. public TaskCompletionSource? BlockRead { get; set; } private int _singleReadCount; /// Gets the number of individual calls served. public int SingleReadCount { get => Volatile.Read(ref _singleReadCount); private set => Volatile.Write(ref _singleReadCount, value); } /// Gets the size of each batch the driver issued, in order (#645). public List SumReadBatches { get; } = new(); /// Gets or sets an exception throws instead of answering — a /// models a target without sum commands. public Exception? SumReadException { get; set; } /// Gets or sets the ADS state text returns. public string? AdsStateText { get; set; } /// Simulates an ADS sum-read: records the batch size, then answers each item from the same /// tables the single read uses. /// The batch. /// The cancellation token. /// One outcome per item. public virtual Task> ReadValuesAsync( IReadOnlyList reads, CancellationToken ct) { if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); SumReadBatches.Add(reads.Count); if (SumReadException is { } ex) throw ex; if (SimulateSumFallback) SumFallbackCount++; var results = new (object? value, uint status)[reads.Count]; for (var i = 0; i < reads.Count; i++) { var path = reads[i].SymbolPath; results[i] = (Values.TryGetValue(path, out var v) ? v : null, ReadStatuses.TryGetValue(path, out var s) ? s : TwinCATStatusMapper.Good); } return Task.FromResult>(results); } /// Gets or sets whether each batch counts as a non-latching sum fallback. public bool SimulateSumFallback { get; set; } /// Gets the simulated count of non-latching sum fallbacks. public long SumFallbackCount { get; private set; } /// Gets the simulated overall code of the last fallback. public uint LastSumFallbackCode => 1795; /// Gets the ADS state the "probe" last read. public string? LastAdsState => AdsStateText; /// Simulates writing a value to a symbol. /// The path to the symbol to write. /// The data type of the symbol. /// The optional bit index for bit-level writes. /// The value to write. /// The cancellation token. /// A task that returns the write status. public virtual Task WriteValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct) { if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); if (type is TwinCATDataType.String or TwinCATDataType.WString && SymbolShapes.TryGetValue(symbolPath, out var shape)) { // The real client's encode plan (resolve → CheckRead → codec at the PLC width). var (planStatus, bytes) = AdsTwinCATClient.PlanStringWrite(type, shape, value, utf8: false); if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus); RawWrites[symbolPath] = bytes; } WriteLog.Add((symbolPath, type, bitIndex, value)); Values[symbolPath] = value; var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; return Task.FromResult(status); } /// Gets the log of every call. public List<(string symbol, TwinCATDataType type, IReadOnlyList elements)> ArrayWriteLog { get; } = new(); /// Simulates an array write: records it, stores the elements and answers from . /// The array symbol path. /// The declared element type. /// The converted elements. /// The cancellation token. /// The write status. public virtual Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList elements, CancellationToken ct) { if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); if (ArrayShapes.TryGetValue(symbolPath, out var array) && SymbolShapes.TryGetValue(symbolPath, out var shape)) { // The real client's encode plan (CheckArray layout → TwinCATArrayCodec.Encode). var (planStatus, bytes) = AdsTwinCATClient.PlanArrayWrite(type, elements, shape, array, utf8: false); if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus); RawWrites[symbolPath] = bytes; } ArrayWriteLog.Add((symbolPath, type, elements)); Values[symbolPath] = elements; return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good); } /// Resolved PLC symbol shapes; a string / array write to a symbol listed here runs the real client's /// encode plan and lands its bytes in (#739 fix round 1). public Dictionary SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase); /// Resolved PLC array shapes for entries that are arrays. public Dictionary ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase); /// The raw bytes the encode plan produced, by symbol. public ConcurrentDictionary RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase); /// Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width. public Dictionary ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase); /// Optional parent shapes; absent = an unsigned integer word as wide as its entry. public Dictionary ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase); /// Gets the log of every call. public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new(); /// When set, yields between its read and its write so a missing /// per-parent lock in the driver loses updates. public bool YieldInBitWrite { get; set; } /// Simulates the parent-width bit RMW with the driver's own . /// The parent word's symbol path. /// The bit index. /// The bit value. /// The cancellation token. /// The write status. public virtual async Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken ct) { if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); lock (BitWriteLog) BitWriteLog.Add((parentPath, bit, value)); if (ReadStatuses.TryGetValue(parentPath, out var readStatus) && readStatus != TwinCATStatusMapper.Good) return readStatus; byte[] current; lock (ParentWords) current = ParentWords.TryGetValue(parentPath, out var w) ? [.. w] : []; var shape = ParentShapes.TryGetValue(parentPath, out var sh) ? sh : new TwinCATPlcSymbolShape(TwinCATPlcKind.Scalar, current.Length == 0 ? 4 : current.Length, false, "UDINT"); if (YieldInBitWrite) await Task.Yield(); var status = TwinCATBitAccess.ApplyBitWrite(shape, current, bit, value, out var updated); if (status != TwinCATStatusMapper.Good) return status; lock (ParentWords) ParentWords[parentPath] = updated; return WriteStatuses.TryGetValue(parentPath, out var s) ? s : TwinCATStatusMapper.Good; } /// Simulates probing the connection status. /// The cancellation token. /// A task that returns the probe result. public virtual Task ProbeAsync(CancellationToken ct) { Interlocked.Increment(ref _probeCount); if (ThrowOnProbe) return Task.FromResult(false); return Task.FromResult(ProbeResult); } private int _probeCount; /// Gets how many times ran — a measured window for absence assertions. public int ProbeCount => Volatile.Read(ref _probeCount); /// Releases unmanaged resources. public virtual void Dispose() { DisposeCount++; IsConnected = false; } // ---- notification fake ---- /// Gets the list of registered notifications. public List Notifications { get; } = new(); /// Gets or sets a value indicating whether AddNotificationAsync should throw. public bool ThrowOnAddNotification { get; set; } /// Per-symbol exceptions throws (e.g. a /// for one refused tag). public ConcurrentDictionary AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _addAttempts = new(StringComparer.OrdinalIgnoreCase); /// How many times was called for . /// The symbol path. /// The attempt count. public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0; /// Records the most recently-supplied maxDelayMs for Driver.TwinCAT-014 tests. public int LastMaxDelayMs { get; private set; } /// When set, awaits this gate BEFORE applying /// — wedges an attempt in flight whose outcome is a failure. public TaskCompletionSource? PreAttemptGate { get; set; } /// When set, awaits this gate before creating the handle — /// lets a test wedge a replay/register mid-flight (STAB-17 orphan race). public TaskCompletionSource? AddNotificationGate { get; set; } /// Simulates adding a notification for value changes. /// The path to the symbol to watch. /// The data type of the symbol. /// The optional bit index for bit-level notifications. /// The authored array length for an array tag; null for a scalar. /// The sampling cycle time. /// The maximum delay in milliseconds. /// The callback to invoke on value change. /// The cancellation token. /// A task that returns a notification handle. public virtual async Task AddNotificationAsync( string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime, int maxDelayMs, Action onChange, CancellationToken cancellationToken) { _addAttempts.AddOrUpdate(symbolPath, 1, (_, n) => n + 1); if (PreAttemptGate is { } pre) await pre.Task.ConfigureAwait(false); if (ThrowOnAddNotification) throw Exception ?? new InvalidOperationException("fake AddNotification failure"); if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol)) throw perSymbol; if (AddNotificationGate is { } gate) await gate.Task.ConfigureAwait(false); LastMaxDelayMs = maxDelayMs; var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this) { ArrayCount = arrayCount }; Notifications.Add(reg); return reg; } /// Fire a change event through the registered callback for . /// The symbol path for which to fire the change. /// The new value to pass to the callback. 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 ---- /// Gets the simulated browse results. public List BrowseResults { get; } = new(); /// Gets or sets a value indicating whether BrowseSymbolsAsync should throw. public bool ThrowOnBrowse { get; set; } /// Simulates browsing the symbol tree. /// The cancellation token. /// An async enumerable of discovered symbols. 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; } } /// Represents a registered notification in the fake client. public sealed class FakeNotification( string symbolPath, TwinCATDataType type, int? bitIndex, Action onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle { /// Gets the symbol path being watched. public string SymbolPath { get; } = symbolPath; /// Gets the data type of the symbol. public TwinCATDataType Type { get; } = type; /// Gets the optional bit index. public int? BitIndex { get; } = bitIndex; /// Gets the array count the driver registered with (null = scalar). public int? ArrayCount { get; init; } /// Gets the callback to invoke on value change. public Action OnChange { get; } = onChange; /// Gets a value indicating whether this notification has been disposed. public bool Disposed { get; private set; } /// Disposes this notification handle. public void Dispose() { Disposed = true; owner.Notifications.Remove(this); } } } /// Represents a factory for creating fake TwinCAT clients. internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory { /// Gets the list of clients created by this factory. public List Clients { get; } = new(); /// Gets or sets an optional customization function for creating clients. public Func? Customise { get; set; } /// Creates a new fake TwinCAT client. /// A newly created client instance. public ITwinCATClient Create() { var client = Customise?.Invoke() ?? new FakeTwinCATClient(); Clients.Add(client); return client; } }