a8d313ed5a
- I1: the poll engine belongs to a teardown generation. ShutdownAsync/Dispose
retire the current engine and install a fresh one in the same swap as the
device/tag tables, then dispose the retired one (possibly in the background
after a cancelled or over-budget shutdown). A subscription made after the
next InitializeAsync can no longer land in an engine whose late dispose tail
would dispose its CTS without cancelling it (a loop no UnsubscribeAsync could
find). Poll handles are generation-qualified (TwinCATPollHandle) because each
engine numbers handles from 1; SubscribeAsync re-homes a subscription that
raced a retirement.
- M1: a write's status feeds per-device health - Good clears that device's
comms fault and advances LastSuccessfulRead, a comms status degrades, a
refusal keeps the state.
- M2: a symbol-version re-arm bumps a per-registration epoch; an in-flight
retry that fails afterwards no longer re-imposes the backoff.
- M3: a re-arm restarts the refusal backoff at 30 s.
- M4: documented that PLC-not-in-RUN detection needs the probe.
- M5: WSTRING and multi-dimensional array encode-plan tests; the cancelled-
shutdown test asserts Unknown health and awaits its background teardown.
- M6: an infinity spelled as a string ('Infinity', '-inf') passes like a
float/double infinity; a finite overflow stays BadOutOfRange.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
395 lines
22 KiB
C#
395 lines
22 KiB
C#
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
|
|
{
|
|
/// <summary>Gets a value indicating whether the client is connected.</summary>
|
|
public bool IsConnected { get; private set; }
|
|
/// <summary>Gets the number of times Connect has been called.</summary>
|
|
public int ConnectCount { get; private set; }
|
|
/// <summary>Gets the number of times Dispose has been called.</summary>
|
|
public int DisposeCount { get; private set; }
|
|
/// <summary>Gets or sets a value indicating whether ConnectAsync should throw.</summary>
|
|
public bool ThrowOnConnect { get; set; }
|
|
/// <summary>Gets or sets a value indicating whether ReadValueAsync should throw.</summary>
|
|
public bool ThrowOnRead { get; set; }
|
|
/// <summary>Gets or sets a value indicating whether WriteValueAsync should throw.</summary>
|
|
public bool ThrowOnWrite { get; set; }
|
|
/// <summary>Gets or sets a value indicating whether ProbeAsync should throw.</summary>
|
|
public bool ThrowOnProbe { get; set; }
|
|
/// <summary>Gets or sets the exception to throw when a throw flag is set.</summary>
|
|
public Exception? Exception { get; set; }
|
|
/// <summary>Gets the simulated values by symbol path.</summary>
|
|
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Gets the read statuses by symbol path.</summary>
|
|
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Gets the write statuses by symbol path.</summary>
|
|
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Records the <c>arrayCount</c> argument of the most recent <see cref="ReadValueAsync"/>
|
|
/// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested
|
|
/// the array with the authored length.</summary>
|
|
public int? LastReadArrayCount { get; private set; }
|
|
/// <summary>Gets the log of all write operations.</summary>
|
|
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
|
/// <summary>Gets or sets the result returned by ProbeAsync.</summary>
|
|
public bool ProbeResult { get; set; } = true;
|
|
|
|
/// <summary>Occurs when the symbol version changes.</summary>
|
|
public event EventHandler? OnSymbolVersionChanged;
|
|
|
|
/// <summary>Test hook — fire the symbol-version-changed signal as the real client would.</summary>
|
|
public void FireSymbolVersionChanged() => OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
|
|
|
|
/// <summary>Test hook — simulate a wire-level drop that leaves the AMS port "connected" but dead:
|
|
/// flips <see cref="IsConnected"/> to false without disposing, so the driver's IsConnected
|
|
/// fast-path treats the client as stale and rebuilds (exercises the STAB-2 reconnect+replay).</summary>
|
|
public void SimulateWireDrop() => IsConnected = false;
|
|
|
|
/// <summary>Simulates connecting to the TwinCAT system.</summary>
|
|
/// <param name="address">The AMS address to connect to.</param>
|
|
/// <param name="timeout">The connection timeout.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>A task that completes when the connection succeeds or fails.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>When set, <see cref="ConnectAsync"/> does not complete (and ignores cancellation) until this
|
|
/// is completed — a probe wedged in the synchronous ADS <c>Connect</c>.</summary>
|
|
public TaskCompletionSource? BlockConnect { get; set; }
|
|
|
|
/// <summary>Gets or sets the simulated notification-queue drop count.</summary>
|
|
public long DroppedNotificationCount { get; set; }
|
|
|
|
/// <summary>Simulates reading a value from a symbol.</summary>
|
|
/// <param name="symbolPath">The path to the symbol to read.</param>
|
|
/// <param name="type">The data type of the symbol.</param>
|
|
/// <param name="bitIndex">The optional bit index for bit-level reads.</param>
|
|
/// <param name="arrayCount">The optional 1-D array element count (null = scalar read).</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>A task that returns the simulated value and status.</returns>
|
|
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));
|
|
}
|
|
|
|
/// <summary>When set, <see cref="ReadValueAsync"/> does not complete (and ignores cancellation) until this is
|
|
/// completed — a poll read wedged in the wire call.</summary>
|
|
public TaskCompletionSource? BlockRead { get; set; }
|
|
|
|
private int _singleReadCount;
|
|
|
|
/// <summary>Gets the number of individual <see cref="ReadValueAsync"/> calls served.</summary>
|
|
public int SingleReadCount { get => Volatile.Read(ref _singleReadCount); private set => Volatile.Write(ref _singleReadCount, value); }
|
|
/// <summary>Gets the size of each <see cref="ReadValuesAsync"/> batch the driver issued, in order (#645).</summary>
|
|
public List<int> SumReadBatches { get; } = new();
|
|
/// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a
|
|
/// <see cref="TwinCATSumReadRefusedException"/> models a target without sum commands.</summary>
|
|
public Exception? SumReadException { get; set; }
|
|
/// <summary>Gets or sets the ADS state text <see cref="LastAdsState"/> returns.</summary>
|
|
public string? AdsStateText { get; set; }
|
|
|
|
/// <summary>Simulates an ADS sum-read: records the batch size, then answers each item from the same
|
|
/// tables the single read uses.</summary>
|
|
/// <param name="reads">The batch.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>One outcome per item.</returns>
|
|
public virtual Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
|
IReadOnlyList<TwinCATBulkReadItem> 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<IReadOnlyList<(object? value, uint status)>>(results);
|
|
}
|
|
|
|
/// <summary>Gets or sets whether each batch counts as a non-latching sum fallback.</summary>
|
|
public bool SimulateSumFallback { get; set; }
|
|
/// <summary>Gets the simulated count of non-latching sum fallbacks.</summary>
|
|
public long SumFallbackCount { get; private set; }
|
|
/// <summary>Gets the simulated overall code of the last fallback.</summary>
|
|
public uint LastSumFallbackCode => 1795;
|
|
/// <summary>Gets the ADS state the "probe" last read.</summary>
|
|
public string? LastAdsState => AdsStateText;
|
|
|
|
/// <summary>Simulates writing a value to a symbol.</summary>
|
|
/// <param name="symbolPath">The path to the symbol to write.</param>
|
|
/// <param name="type">The data type of the symbol.</param>
|
|
/// <param name="bitIndex">The optional bit index for bit-level writes.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>A task that returns the write status.</returns>
|
|
public virtual Task<uint> 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);
|
|
}
|
|
|
|
/// <summary>Gets the log of every <see cref="WriteArrayAsync"/> call.</summary>
|
|
public List<(string symbol, TwinCATDataType type, IReadOnlyList<object> elements)> ArrayWriteLog { get; } = new();
|
|
|
|
/// <summary>Simulates an array write: records it, stores the elements and answers from <see cref="WriteStatuses"/>.</summary>
|
|
/// <param name="symbolPath">The array symbol path.</param>
|
|
/// <param name="type">The declared element type.</param>
|
|
/// <param name="elements">The converted elements.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The write status.</returns>
|
|
public virtual Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> 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);
|
|
}
|
|
|
|
/// <summary>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 <see cref="RawWrites"/> (#739 fix round 1).</summary>
|
|
public Dictionary<string, TwinCATPlcSymbolShape> SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Resolved PLC array shapes for <see cref="SymbolShapes"/> entries that are arrays.</summary>
|
|
public Dictionary<string, TwinCATPlcArrayShape> ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>The raw bytes the encode plan produced, by symbol.</summary>
|
|
public ConcurrentDictionary<string, byte[]> RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.</summary>
|
|
public Dictionary<string, byte[]> ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Optional parent shapes; absent = an unsigned integer word as wide as its <see cref="ParentWords"/> entry.</summary>
|
|
public Dictionary<string, TwinCATPlcSymbolShape> ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Gets the log of every <see cref="WriteBitAsync"/> call.</summary>
|
|
public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new();
|
|
/// <summary>When set, <see cref="WriteBitAsync"/> yields between its read and its write so a missing
|
|
/// per-parent lock in the driver loses updates.</summary>
|
|
public bool YieldInBitWrite { get; set; }
|
|
|
|
/// <summary>Simulates the parent-width bit RMW with the driver's own <see cref="TwinCATBitAccess.ApplyBitWrite"/>.</summary>
|
|
/// <param name="parentPath">The parent word's symbol path.</param>
|
|
/// <param name="bit">The bit index.</param>
|
|
/// <param name="value">The bit value.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The write status.</returns>
|
|
public virtual async Task<uint> 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;
|
|
}
|
|
|
|
/// <summary>Simulates probing the connection status.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>A task that returns the probe result.</returns>
|
|
public virtual Task<bool> ProbeAsync(CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _probeCount);
|
|
if (ThrowOnProbe) return Task.FromResult(false);
|
|
return Task.FromResult(ProbeResult);
|
|
}
|
|
|
|
private int _probeCount;
|
|
|
|
/// <summary>Gets how many times <see cref="ProbeAsync"/> ran — a measured window for absence assertions.</summary>
|
|
public int ProbeCount => Volatile.Read(ref _probeCount);
|
|
|
|
/// <summary>Releases unmanaged resources.</summary>
|
|
public virtual void Dispose()
|
|
{
|
|
DisposeCount++;
|
|
IsConnected = false;
|
|
}
|
|
|
|
// ---- notification fake ----
|
|
|
|
/// <summary>Gets the list of registered notifications.</summary>
|
|
public List<FakeNotification> Notifications { get; } = new();
|
|
/// <summary>Gets or sets a value indicating whether AddNotificationAsync should throw.</summary>
|
|
public bool ThrowOnAddNotification { get; set; }
|
|
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
|
|
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
|
|
public ConcurrentDictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private readonly ConcurrentDictionary<string, int> _addAttempts = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>How many times <see cref="AddNotificationAsync"/> was called for <paramref name="symbolPath"/>.</summary>
|
|
/// <param name="symbolPath">The symbol path.</param>
|
|
/// <returns>The attempt count.</returns>
|
|
public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0;
|
|
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
|
|
public int LastMaxDelayMs { get; private set; }
|
|
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate BEFORE applying
|
|
/// <see cref="AddNotificationErrors"/> — wedges an attempt in flight whose outcome is a failure.</summary>
|
|
public TaskCompletionSource? PreAttemptGate { get; set; }
|
|
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
|
|
/// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).</summary>
|
|
public TaskCompletionSource? AddNotificationGate { get; set; }
|
|
|
|
/// <summary>Simulates adding a notification for value changes.</summary>
|
|
/// <param name="symbolPath">The path to the symbol to watch.</param>
|
|
/// <param name="type">The data type of the symbol.</param>
|
|
/// <param name="bitIndex">The optional bit index for bit-level notifications.</param>
|
|
/// <param name="arrayCount">The authored array length for an array tag; null for a scalar.</param>
|
|
/// <param name="cycleTime">The sampling cycle time.</param>
|
|
/// <param name="maxDelayMs">The maximum delay in milliseconds.</param>
|
|
/// <param name="onChange">The callback to invoke on value change.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>A task that returns a notification handle.</returns>
|
|
public virtual async Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
|
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
|
|
int maxDelayMs, Action<string, object?> 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;
|
|
}
|
|
|
|
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
|
|
/// <param name="symbolPath">The symbol path for which to fire the change.</param>
|
|
/// <param name="value">The new value to pass to the callback.</param>
|
|
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 ----
|
|
|
|
/// <summary>Gets the simulated browse results.</summary>
|
|
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
|
|
/// <summary>Gets or sets a value indicating whether BrowseSymbolsAsync should throw.</summary>
|
|
public bool ThrowOnBrowse { get; set; }
|
|
|
|
/// <summary>Simulates browsing the symbol tree.</summary>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>An async enumerable of discovered symbols.</returns>
|
|
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Represents a registered notification in the fake client.</summary>
|
|
public sealed class FakeNotification(
|
|
string symbolPath, TwinCATDataType type, int? bitIndex,
|
|
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
|
|
{
|
|
/// <summary>Gets the symbol path being watched.</summary>
|
|
public string SymbolPath { get; } = symbolPath;
|
|
/// <summary>Gets the data type of the symbol.</summary>
|
|
public TwinCATDataType Type { get; } = type;
|
|
/// <summary>Gets the optional bit index.</summary>
|
|
public int? BitIndex { get; } = bitIndex;
|
|
/// <summary>Gets the array count the driver registered with (null = scalar).</summary>
|
|
public int? ArrayCount { get; init; }
|
|
/// <summary>Gets the callback to invoke on value change.</summary>
|
|
public Action<string, object?> OnChange { get; } = onChange;
|
|
/// <summary>Gets a value indicating whether this notification has been disposed.</summary>
|
|
public bool Disposed { get; private set; }
|
|
|
|
/// <summary>Disposes this notification handle.</summary>
|
|
public void Dispose()
|
|
{
|
|
Disposed = true;
|
|
owner.Notifications.Remove(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Represents a factory for creating fake TwinCAT clients.</summary>
|
|
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory
|
|
{
|
|
/// <summary>Gets the list of clients created by this factory.</summary>
|
|
public List<FakeTwinCATClient> Clients { get; } = new();
|
|
/// <summary>Gets or sets an optional customization function for creating clients.</summary>
|
|
public Func<FakeTwinCATClient>? Customise { get; set; }
|
|
|
|
/// <summary>Creates a new fake TwinCAT client.</summary>
|
|
/// <returns>A newly created client instance.</returns>
|
|
public ITwinCATClient Create()
|
|
{
|
|
var client = Customise?.Invoke() ?? new FakeTwinCATClient();
|
|
Clients.Add(client);
|
|
return client;
|
|
}
|
|
}
|