266 lines
12 KiB
C#
266 lines
12 KiB
C#
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
internal class FakeFocasClient : IFocasClient
|
|
{
|
|
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 ProbeResult { get; set; } = true;
|
|
public Exception? Exception { get; set; }
|
|
|
|
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-b (issue #269) — separate log of <c>cnc_wrparam</c>-shaped calls
|
|
/// observed via <see cref="WriteParameterAsync"/>. Tests assert this list to
|
|
/// verify the driver routed PARAM writes through the typed entry point rather
|
|
/// than the generic <see cref="WriteAsync"/> dispatch.
|
|
/// </summary>
|
|
public List<(FocasAddress addr, FocasDataType type, object? value)> ParameterWriteLog { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-b (issue #269) — separate log of <c>cnc_wrmacro</c>-shaped calls
|
|
/// observed via <see cref="WriteMacroAsync"/>.
|
|
/// </summary>
|
|
public List<(FocasAddress addr, object? value)> MacroWriteLog { get; } = new();
|
|
|
|
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
ConnectCount++;
|
|
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
|
IsConnected = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-d (issue #271) — count of <see cref="UnlockAsync"/> invocations.
|
|
/// Tests assert this to verify the driver routed <c>cnc_wrunlockparam</c> on
|
|
/// connect when <c>FocasDeviceOptions.Password</c> was non-null and re-issued
|
|
/// unlock on EW_PASSWD retry exactly once.
|
|
/// </summary>
|
|
public int UnlockCount { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-d (issue #271) — last password observed by <see cref="UnlockAsync"/>.
|
|
/// Used by the round-trip test to confirm the driver passed the configured
|
|
/// password through unmodified. <b>This field exists ONLY in the fake — no
|
|
/// production wire client retains the password past the wire call.</b>
|
|
/// </summary>
|
|
public string? LastUnlockPassword { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-d (issue #271) — when set, <see cref="UnlockAsync"/> throws on
|
|
/// invocation so tests can drive the failed-unlock retry path (where the
|
|
/// driver surfaces BadUserAccessDenied as-is rather than retrying).
|
|
/// </summary>
|
|
public bool ThrowOnUnlock { get; set; }
|
|
|
|
public virtual Task UnlockAsync(string password, CancellationToken ct)
|
|
{
|
|
UnlockCount++;
|
|
LastUnlockPassword = password;
|
|
if (ThrowOnUnlock) throw Exception ?? new InvalidOperationException("Unlock fails");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public virtual Task<(object? value, uint status)> ReadAsync(
|
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
|
{
|
|
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
|
var key = address.Canonical;
|
|
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
|
var value = Values.TryGetValue(key, out var v) ? v : null;
|
|
return Task.FromResult((value, status));
|
|
}
|
|
|
|
public virtual Task<uint> WriteAsync(
|
|
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
|
{
|
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
|
WriteLog.Add((address, type, value));
|
|
Values[address.Canonical] = value;
|
|
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
|
return Task.FromResult(status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-b (issue #269) — typed parameter-write entry point. Records the
|
|
/// call in <see cref="ParameterWriteLog"/>, persists the value into
|
|
/// <see cref="Values"/> at the canonical address (so a subsequent read returns
|
|
/// the written value), and resolves to <see cref="WriteStatuses"/> if seeded
|
|
/// (lets a test simulate <c>EW_PASSWD</c> -> <see cref="FocasStatusMapper.BadUserAccessDenied"/>).
|
|
/// </summary>
|
|
public virtual Task<uint> WriteParameterAsync(
|
|
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
|
{
|
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
|
ParameterWriteLog.Add((address, type, value));
|
|
Values[address.Canonical] = value;
|
|
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
|
return Task.FromResult(status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-b (issue #269) — typed macro-write entry point. See
|
|
/// <see cref="WriteParameterAsync"/> for the per-canonical-address store / log shape.
|
|
/// </summary>
|
|
public virtual Task<uint> WriteMacroAsync(
|
|
FocasAddress address, object? value, CancellationToken ct)
|
|
{
|
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
|
MacroWriteLog.Add((address, value));
|
|
Values[address.Canonical] = value;
|
|
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
|
return Task.FromResult(status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plan PR F4-c (issue #270) — typed PMC range-write entry point. Records
|
|
/// the call in <see cref="PmcRangeWriteLog"/> and applies the bytes to
|
|
/// <see cref="PmcByteRanges"/> at <c>(letter, pathId)</c> so a subsequent
|
|
/// <see cref="ReadPmcRangeAsync"/> sees the updated bytes (round-trip
|
|
/// shape). Status looked up by the canonical PMC address (e.g. <c>R100</c>)
|
|
/// of the first byte if seeded; otherwise Good.
|
|
/// </summary>
|
|
public List<(string Letter, int PathId, int StartByte, byte[] Bytes)> PmcRangeWriteLog { get; } = new();
|
|
|
|
public virtual Task<uint> WritePmcRangeAsync(
|
|
string letter, int pathId, int startByte, byte[] bytes, CancellationToken ct)
|
|
{
|
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
|
var copy = bytes.ToArray();
|
|
PmcRangeWriteLog.Add((letter, pathId, startByte, copy));
|
|
// Persist into PmcByteRanges so subsequent range reads see the write — this
|
|
// mirrors the simulator round-trip the integration tests check.
|
|
var key = (letter.ToUpperInvariant(), pathId);
|
|
if (!PmcByteRanges.TryGetValue(key, out var src))
|
|
{
|
|
src = new byte[startByte + copy.Length];
|
|
PmcByteRanges[key] = src;
|
|
}
|
|
else if (src.Length < startByte + copy.Length)
|
|
{
|
|
var grown = new byte[startByte + copy.Length];
|
|
Array.Copy(src, 0, grown, 0, src.Length);
|
|
src = grown;
|
|
PmcByteRanges[key] = src;
|
|
}
|
|
Array.Copy(copy, 0, src, startByte, copy.Length);
|
|
// Status seeded by canonical PMC address of the first byte (no bit index).
|
|
var canonical = $"{letter.ToUpperInvariant()}{startByte}";
|
|
var status = WriteStatuses.TryGetValue(canonical, out var sx) ? sx : FocasStatusMapper.Good;
|
|
return Task.FromResult(status);
|
|
}
|
|
|
|
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
|
|
|
|
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(
|
|
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken ct)
|
|
{
|
|
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
|
DiagnosticReads.Add((diagNumber, axisOrZero, type));
|
|
var key = axisOrZero == 0 ? $"DIAG:{diagNumber}" : $"DIAG:{diagNumber}/{axisOrZero}";
|
|
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
|
var value = Values.TryGetValue(key, out var v) ? v : null;
|
|
return Task.FromResult((value, status));
|
|
}
|
|
|
|
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
|
|
|
/// <summary>
|
|
/// Configurable path count surfaced via <see cref="GetPathCountAsync"/> — defaults to
|
|
/// 1 (single-path controller). Tests asserting multi-path behaviour set this to 2..N
|
|
/// so the driver's PathId validation + cnc_setpath dispatch can be exercised
|
|
/// without a live CNC (issue #264).
|
|
/// </summary>
|
|
public int PathCount { get; set; } = 1;
|
|
|
|
/// <summary>Ordered log of <c>cnc_setpath</c> calls observed on this fake session.</summary>
|
|
public List<int> SetPathLog { get; } = new();
|
|
|
|
public virtual Task<int> GetPathCountAsync(CancellationToken ct) => Task.FromResult(PathCount);
|
|
|
|
public virtual Task SetPathAsync(int pathId, CancellationToken ct)
|
|
{
|
|
SetPathLog.Add(pathId);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-letter / per-path byte storage the coalesced range path reads from. Tests
|
|
/// populate <c>PmcByteRanges[("R", 1)] = new byte[size]</c> + the corresponding values to
|
|
/// drive both the per-tag <see cref="ReadAsync"/> + the coalesced
|
|
/// <see cref="ReadPmcRangeAsync"/> path against the same source of truth (issue #266).
|
|
/// </summary>
|
|
public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Ordered log of <c>pmc_rdpmcrng</c>-shaped range calls observed on this fake
|
|
/// session — one entry per coalesced wire call. Tests assert this count to verify
|
|
/// coalescing actually collapsed N per-byte reads into one range read (issue #266).
|
|
/// </summary>
|
|
public List<(string Letter, int PathId, int StartByte, int ByteCount)> RangeReadLog { get; } = new();
|
|
|
|
public virtual Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
|
string letter, int pathId, int startByte, int byteCount, CancellationToken ct)
|
|
{
|
|
RangeReadLog.Add((letter, pathId, startByte, byteCount));
|
|
if (!PmcByteRanges.TryGetValue((letter.ToUpperInvariant(), pathId), out var src))
|
|
return Task.FromResult<(byte[]?, uint)>((new byte[byteCount], FocasStatusMapper.Good));
|
|
var buf = new byte[byteCount];
|
|
var copy = Math.Min(byteCount, Math.Max(0, src.Length - startByte));
|
|
if (copy > 0) Array.Copy(src, startByte, buf, 0, copy);
|
|
return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canned alarm-history payload returned to <see cref="ReadAlarmHistoryAsync"/>.
|
|
/// Defaults to empty so tests that don't care about history get the back-compat
|
|
/// no-op behaviour. Tests asserting <c>cnc_rdalmhistry</c> behaviour seed entries
|
|
/// here (issue #267, plan PR F3-a).
|
|
/// </summary>
|
|
public List<FocasAlarmHistoryEntry> AlarmHistory { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Ordered log of <c>cnc_rdalmhistry</c>-shaped calls observed on this fake session
|
|
/// (depth-per-call). Tests assert this length to verify the projection's poll
|
|
/// cadence + that <c>HistoryDepth</c> got clamped to the wire correctly.
|
|
/// </summary>
|
|
public List<int> AlarmHistoryReadLog { get; } = new();
|
|
|
|
public virtual Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
|
|
int depth, CancellationToken ct)
|
|
{
|
|
AlarmHistoryReadLog.Add(depth);
|
|
IReadOnlyList<FocasAlarmHistoryEntry> snap = AlarmHistory.ToList();
|
|
return Task.FromResult(snap);
|
|
}
|
|
|
|
public virtual void Dispose()
|
|
{
|
|
DisposeCount++;
|
|
IsConnected = false;
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
|
{
|
|
public List<FakeFocasClient> Clients { get; } = new();
|
|
public Func<FakeFocasClient>? Customise { get; set; }
|
|
|
|
public IFocasClient Create()
|
|
{
|
|
var c = Customise?.Invoke() ?? new FakeFocasClient();
|
|
Clients.Add(c);
|
|
return c;
|
|
}
|
|
}
|