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; public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct) { ConnectCount++; if (ThrowOnConnect) throw Exception ?? new InvalidOperationException(); IsConnected = true; return Task.CompletedTask; } 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)); 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)); } 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) { 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 { Values[symbolPath] = value; } var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; return Task.FromResult(status); } 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; } // ---- 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; } }