Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
T
Joseph Doherty bac93744ce fix(alarms): #647 re-review — re-statement needs the same message; CNC refusal is not comms loss; worst-of health
R1: NativeAlarmProjector treats Raise-on-Active as a re-statement only when the message matches what
the condition shows; a different alarm appearing during a respawn un-acks and writes its alerts row.
FOCAS condition messages are a deterministic function of the matched set (tested across fresh driver
instances and reordered lists); Runtime E2E now covers both the same alarm and a new one during a
respawn. Residual (source message determinism) documented in AlarmTracking.md.

R2: a non-OK cnc_rdalmmsg2 return is thrown as FocasWireException{Rc} and classified as a CNC
refusal: conditions get a quality-only BadNotSupported once, one Warning, polling continues, and
driver health is untouched (no 90 s watchdog reconnect loop on a production control). Only transport
faults degrade (falsification control in the test).

R3: alarm-path health is tracked apart from the value path and combined in GetHealth: worst state,
later LastSuccessfulRead. A good value read can no longer mask a failing alarm read (no per-poll
/hosts flip) and a good alarm read cannot mask a value Degraded.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 20:45:32 -04:00

266 lines
14 KiB
C#

using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
internal class FakeFocasClient : IFocasClient
{
/// <summary>Gets a value indicating whether the client is connected.</summary>
public bool IsConnected { get; private set; }
/// <summary>Gets the count of connection attempts.</summary>
public int ConnectCount { get; private set; }
/// <summary>Gets the count of dispose operations.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets or sets a value indicating whether to throw on connect.</summary>
public bool ThrowOnConnect { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on read.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on write.</summary>
public bool ThrowOnWrite { get; set; }
/// <summary>Gets or sets the result of probe operations.</summary>
public bool ProbeResult { get; set; } = true;
/// <summary>Gets or sets the exception to throw.</summary>
public Exception? Exception { get; set; }
/// <summary>Gets the dictionary of read values keyed by address.</summary>
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the dictionary of read statuses keyed by address.</summary>
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the dictionary of write statuses keyed by address.</summary>
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the log of write operations.</summary>
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
/// <summary>Connects to a FOCAS host asynchronously.</summary>
/// <param name="address">The FOCAS host address.</param>
/// <param name="timeout">The connection timeout duration.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
ConnectCount++;
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
IsConnected = true;
return Task.CompletedTask;
}
/// <summary>Reads a value from a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to read from.</param>
/// <param name="type">The data type of the value.</param>
/// <param name="ct">The cancellation token.</param>
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));
}
/// <summary>PMC byte memory the range read serves, keyed by (upper-case letter, byte address); absent = 0.</summary>
public Dictionary<(string Letter, int Address), byte> PmcMemory { get; } = new();
/// <summary>Gets the log of range reads issued (letter, slot type, start, end).</summary>
public List<(string Letter, FocasDataType SlotType, int Start, int End)> PmcRangeReads { get; } = new();
/// <summary>Ranges (letter, start, end) the fake CNC rejects with <see cref="PmcRangeRejectStatus"/>.</summary>
public List<Func<string, int, int, bool>> PmcRangeRejectors { get; } = new();
/// <summary>The status a rejected range returns.</summary>
public uint PmcRangeRejectStatus { get; set; } = FocasStatusMapper.BadOutOfRange;
/// <summary>When true every range read throws (transport failure).</summary>
public bool ThrowOnPmcRange { get; set; }
/// <summary>Reads a PMC range as raw big-endian slots from <see cref="PmcMemory"/>.</summary>
/// <param name="letter">PMC letter.</param>
/// <param name="slotType">Slot type.</param>
/// <param name="start">First byte.</param>
/// <param name="end">Last byte, inclusive.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken ct)
{
PmcRangeReads.Add((letter, slotType, start, end));
if (ThrowOnPmcRange) throw new InvalidOperationException("transport down");
if (PmcRangeRejectors.Any(r => r(letter, start, end)))
return Task.FromResult<(IReadOnlyList<long>?, uint)>((null, PmcRangeRejectStatus));
var width = slotType switch
{
FocasDataType.Int16 => 2,
FocasDataType.Int32 or FocasDataType.Float32 => 4,
FocasDataType.Float64 => 8,
_ => 1,
};
var slots = new List<long>();
for (var o = start; o + width - 1 <= end; o += width)
{
long v = 0;
for (var k = 0; k < width; k++)
v = (v << 8) | (PmcMemory.TryGetValue((letter, o + k), out var b) ? b : (byte)0);
// Sign-extend like the wire parser (Int16/Int32/Int64 big-endian); width 1 stays 0..255.
v = width switch { 2 => (short)v, 4 => (int)v, _ => v };
slots.Add(v);
}
return Task.FromResult<(IReadOnlyList<long>?, uint)>((slots, FocasStatusMapper.Good));
}
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to write to.</param>
/// <param name="type">The data type of the value.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">The cancellation token.</param>
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>Probes the FOCAS connection asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
/// <summary>Gets the list of active alarms.</summary>
public List<FocasActiveAlarm> Alarms { get; } = [];
/// <summary>Gets or sets a value indicating whether the alarm read throws (Gitea #647: a failed read).</summary>
public bool ThrowOnReadAlarms { get; set; }
/// <summary>Gets or sets an exception the alarm read throws (e.g. a CNC refusal carrying an rc).</summary>
public Exception? AlarmReadException { get; set; }
/// <summary>Gets the number of alarm reads served (successful or not).</summary>
public int AlarmReadCount => Volatile.Read(ref _alarmReadCount);
private int _alarmReadCount;
/// <summary>Replaces the active alarm list atomically with respect to the poll thread.</summary>
/// <param name="alarms">The new active alarms.</param>
public void SetAlarms(params FocasActiveAlarm[] alarms)
{
lock (Alarms) { Alarms.Clear(); Alarms.AddRange(alarms); }
}
/// <summary>Reads active alarms asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct)
{
Interlocked.Increment(ref _alarmReadCount);
if (AlarmReadException is { } refusal) throw refusal;
if (ThrowOnReadAlarms) throw new InvalidOperationException("cnc_rdalmmsg2 failed (fake)");
lock (Alarms) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
}
// ---- Fixed-tree T1 ----
/// <summary>Gets or sets the system information.</summary>
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
/// <summary>Gets the list of axis names.</summary>
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
/// <summary>Gets the list of spindle names.</summary>
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
/// <summary>Gets the dictionary of dynamic snapshots keyed by axis index.</summary>
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
/// <summary>Gets system information asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
/// <summary>Gets axis names asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
/// <summary>Gets spindle names asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
/// <summary>Reads dynamic data for an axis asynchronously.</summary>
/// <param name="axisIndex">The zero-based axis index.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
return Task.FromResult(snap);
}
/// <summary>Gets or sets the program information.</summary>
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
/// <summary>Gets program information asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo);
/// <summary>Gets or sets the cnc_statinfo status snapshot.</summary>
public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0);
/// <summary>Gets or sets a value indicating whether <see cref="GetStatusAsync"/> throws (older series / refused call).</summary>
public bool ThrowOnStatus { get; set; }
/// <summary>Gets the number of status reads served.</summary>
public int StatusReads { get; private set; }
/// <summary>Gets the CNC status flags asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasStatus> GetStatusAsync(CancellationToken ct)
{
StatusReads++;
if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused");
return Task.FromResult(Status);
}
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
/// <summary>Gets timer data asynchronously.</summary>
/// <param name="kind">The timer kind to retrieve.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
{
if (!Timers.TryGetValue(kind, out var t))
t = new FocasTimer(kind, 0, 0);
return Task.FromResult(t);
}
/// <summary>Gets the list of servo loads.</summary>
public List<FocasServoLoad> ServoLoads { get; } = [];
/// <summary>Gets servo loads asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
public List<double?> SpindleLoads { get; } = [];
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
public List<double?> SpindleMotorSpeeds { get; } = [];
/// <summary>Gets spindle loads asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
/// <summary>Disposes the client.</summary>
public virtual void Dispose()
{
DisposeCount++;
IsConnected = false;
}
}
/// <summary>A factory for creating fake FOCAS clients.</summary>
internal sealed class FakeFocasClientFactory : IFocasClientFactory
{
/// <summary>Gets the list of created clients.</summary>
public List<FakeFocasClient> Clients { get; } = new();
/// <summary>Gets or sets a customization function for creating clients.</summary>
public Func<FakeFocasClient>? Customise { get; set; }
/// <summary>No-op usability probe — the fake factory is always usable.</summary>
public void EnsureUsable() { }
/// <summary>Creates a fake FOCAS client.</summary>
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
Clients.Add(c);
return c;
}
}