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 Values { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new(); /// /// Plan PR F4-b (issue #269) — separate log of cnc_wrparam-shaped calls /// observed via . Tests assert this list to /// verify the driver routed PARAM writes through the typed entry point rather /// than the generic dispatch. /// public List<(FocasAddress addr, FocasDataType type, object? value)> ParameterWriteLog { get; } = new(); /// /// Plan PR F4-b (issue #269) — separate log of cnc_wrmacro-shaped calls /// observed via . /// 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; } 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 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); } /// /// Plan PR F4-b (issue #269) — typed parameter-write entry point. Records the /// call in , persists the value into /// at the canonical address (so a subsequent read returns /// the written value), and resolves to if seeded /// (lets a test simulate EW_PASSWD -> ). /// public virtual Task 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); } /// /// Plan PR F4-b (issue #269) — typed macro-write entry point. See /// for the per-canonical-address store / log shape. /// public virtual Task 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); } /// /// Plan PR F4-c (issue #270) — typed PMC range-write entry point. Records /// the call in and applies the bytes to /// at (letter, pathId) so a subsequent /// sees the updated bytes (round-trip /// shape). Status looked up by the canonical PMC address (e.g. R100) /// of the first byte if seeded; otherwise Good. /// public List<(string Letter, int PathId, int StartByte, byte[] Bytes)> PmcRangeWriteLog { get; } = new(); public virtual Task 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 ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult); /// /// Configurable path count surfaced via — 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). /// public int PathCount { get; set; } = 1; /// Ordered log of cnc_setpath calls observed on this fake session. public List SetPathLog { get; } = new(); public virtual Task GetPathCountAsync(CancellationToken ct) => Task.FromResult(PathCount); public virtual Task SetPathAsync(int pathId, CancellationToken ct) { SetPathLog.Add(pathId); return Task.CompletedTask; } /// /// Per-letter / per-path byte storage the coalesced range path reads from. Tests /// populate PmcByteRanges[("R", 1)] = new byte[size] + the corresponding values to /// drive both the per-tag + the coalesced /// path against the same source of truth (issue #266). /// public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new(); /// /// Ordered log of pmc_rdpmcrng-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). /// 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)); } /// /// Canned alarm-history payload returned to . /// Defaults to empty so tests that don't care about history get the back-compat /// no-op behaviour. Tests asserting cnc_rdalmhistry behaviour seed entries /// here (issue #267, plan PR F3-a). /// public List AlarmHistory { get; } = new(); /// /// Ordered log of cnc_rdalmhistry-shaped calls observed on this fake session /// (depth-per-call). Tests assert this length to verify the projection's poll /// cadence + that HistoryDepth got clamped to the wire correctly. /// public List AlarmHistoryReadLog { get; } = new(); public virtual Task> ReadAlarmHistoryAsync( int depth, CancellationToken ct) { AlarmHistoryReadLog.Add(depth); IReadOnlyList snap = AlarmHistory.ToList(); return Task.FromResult(snap); } public virtual void Dispose() { DisposeCount++; IsConnected = false; } } internal sealed class FakeFocasClientFactory : IFocasClientFactory { public List Clients { get; } = new(); public Func? Customise { get; set; } public IFocasClient Create() { var c = Customise?.Invoke() ?? new FakeFocasClient(); Clients.Add(c); return c; } }