diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs new file mode 100644 index 00000000..ca71667c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Resolves a driver subscription/read/write fullReference to a driver tag-definition, +/// bridging the two authoring models: a legacy authored tag-table entry (looked up by name) +/// OR an equipment tag whose reference is its raw TagConfig JSON (parsed on first use +/// and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once. +/// +/// The driver's internal tag-definition type. +public sealed class EquipmentTagRefResolver where TDef : class +{ + private readonly Func _byName; + private readonly Func _parseRef; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + /// Authored tag-table lookup (returns null on miss). + /// Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null. + public EquipmentTagRefResolver(Func byName, Func parseRef) + { + ArgumentNullException.ThrowIfNull(byName); + ArgumentNullException.ThrowIfNull(parseRef); + _byName = byName; + _parseRef = parseRef; + } + + /// True when resolves to a def (authored or equipment). + /// The wire reference handed to the driver. + /// The resolved tag-definition when this returns true. + /// when a definition was found. + public bool TryResolve(string fullReference, out TDef def) + { + var authored = _byName(fullReference); + if (authored is not null) { def = authored; return true; } + + var resolved = _cache.GetOrAdd(fullReference, _parseRef); + if (resolved is not null) { def = resolved; return true; } + + def = null!; + return false; + } + + /// Drops the transient-parse cache (call on driver reinitialise so a config change re-parses). + public void Clear() => _cache.Clear(); +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs new file mode 100644 index 00000000..d6b5f498 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs @@ -0,0 +1,54 @@ +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +public class EquipmentTagRefResolverTests +{ + private sealed record Def(string Id); + + private static EquipmentTagRefResolver Make( + Dictionary byName, Func parse) + => new(r => byName.TryGetValue(r, out var d) ? d : null, parse); + + [Fact] + public void Legacy_name_resolves_via_byName() + { + var r = Make(new() { ["Temp"] = new Def("Temp") }, _ => null); + r.TryResolve("Temp", out var def).ShouldBeTrue(); + def!.Id.ShouldBe("Temp"); + } + + [Fact] + public void Equipment_ref_resolves_via_parse_and_is_cached() + { + var calls = 0; + var r = Make(new(), reference => { calls++; return reference.StartsWith("{") ? new Def(reference) : null; }); + r.TryResolve("{\"a\":1}", out var d1).ShouldBeTrue(); + r.TryResolve("{\"a\":1}", out var d2).ShouldBeTrue(); + d1!.Id.ShouldBe("{\"a\":1}"); + calls.ShouldBe(1); + } + + [Fact] + public void Garbage_ref_returns_false_and_caches_negative() + { + var calls = 0; + var r = Make(new(), _ => { calls++; return null; }); + r.TryResolve("nope", out _).ShouldBeFalse(); + r.TryResolve("nope", out _).ShouldBeFalse(); + calls.ShouldBe(1); + } + + [Fact] + public void Clear_drops_transient_cache() + { + var calls = 0; + var r = Make(new(), reference => { calls++; return new Def(reference); }); + r.TryResolve("{\"a\":1}", out _).ShouldBeTrue(); + r.Clear(); + r.TryResolve("{\"a\":1}", out _).ShouldBeTrue(); + calls.ShouldBe(2); + } +}