diff --git a/CLAUDE.md b/CLAUDE.md index ee6d2f81..e72a1e37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,38 +188,22 @@ line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDisc browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor. -⛔ **Tag-set drift detection is slated for REMOVAL (Gitea #523).** It is in `master` today and the -description below is accurate, but the periodic device re-browse is being taken back out: it is -recurring, unconfigurable (interval is a constructor parameter, no appsettings key) and never -live-gated traffic to real PLCs/CNCs/Agents. After removal, **AbCip and FOCAS have no tag-change signal -at all** (they implement no `IRediscoverable`); TwinCAT and MTConnect keep their native ones. The -`IRediscoverable` consumption above is unaffected. +**`IRediscoverable` is the ONLY tag-change signal, and four drivers do not raise it.** A periodic +tag-set drift check briefly existed (shipped `e77c8a35`, **removed** by Gitea **#523**): every +`SupportsOnlineDiscovery` driver was re-browsed on a 5-minute timer and the result diffed against its +authored tags. It was taken back out because it generated recurring, **unconfigurable** (interval was a +constructor parameter with no appsettings key), **never-live-gated** outbound traffic to real +PLCs/CNCs/Agents — how often to browse someone's plant equipment is an operational decision, and the +design treated it as an implementation detail. -**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor` -re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers -against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and -FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their -only way to notice a PLC re-download. - -⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting -`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo -authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a -tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse -than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection -legitimately means "nothing authored, so everything discovered is new". - -⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit -their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker -shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear -missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then -reports only what it can see and **says so** in the operator message rather than implying a clean bill of -health. **MTConnect is currently the only driver where both directions are detectable** — its stream is -built purely from the Agent's probe model. - -Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise -report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an -**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that -resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit. +⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but +implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download +or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT, +MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an +event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a +deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`, +`DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` / +`DiscoveryStreamIncludesAuthoredTags`. ⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so diff --git a/deferment.md b/deferment.md index dfe60f16..843640ee 100644 --- a/deferment.md +++ b/deferment.md @@ -46,11 +46,11 @@ themes into tracked decisions. What remains: | **#520** node ACLs authored + deployed but **never enforced** | Decision: wire it or retire the surface. Non-enforcement is now stated in docs and warned in the UI, so nobody can author a deny rule believing it works — but reads are still ungated. | §3.1 | | **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 | | **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 | -| **#523** remove the periodic 5-min device re-browse | **Reversal of work shipped in this remediation.** After removal, AbCip and FOCAS have no tag-change signal at all. | §9 (2026-07-28 entry) | +| ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done** — removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) | | **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 | | **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 | | 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 | -| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10, and the **un-gated drift detector** (moot if #523 lands). | §5 | +| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 | **Three issues are resolved but still open on the tracker** — **#516** (fixed for all 12 drivers), **#518** (the `IRediscoverable` half fixed; the probe half split to #521) and **#507** (injection deleted, @@ -300,7 +300,7 @@ mere env-var presence, so it cannot pass vacuously. | Gate | Status | Evidence | |---|---|---| -| **Tag-set drift detection** (shipped `e77c8a35`) | **OPEN — and likely moot.** Never live-gated: it needs a device whose tag set actually changes. Gitea **#523** removes the feature, which would close this gate by deletion. | +| ~~**Tag-set drift detection** (shipped `e77c8a35`)~~ | ✅ **CLOSED BY DELETION** — removed under Gitea **#523** rather than gated. The gate it needed (a device whose tag set actually changes) required exactly the traffic the removal objected to. | §9, 2026-07-28 (#523) | | **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists | | **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse | | **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` | @@ -470,7 +470,7 @@ the tree. | 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `88ce8df0` | | 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `88ce8df0` | | follow-up A | ✅ **Done** — Sql/FOCAS in-place re-parse made **atomic** | `5184a2e1` | -| follow-up B | ⛔ **Shipped, then reverted by decision** — tag-set drift detection; removal tracked as Gitea **#523** | `e77c8a35` | +| follow-up B | ⛔ **Shipped, then removed by decision** — tag-set drift detection; **removal landed**, Gitea **#523** | `e77c8a35`, removed below | ### 2026-07-27 — §8.1 ACL non-enforcement made explicit @@ -624,10 +624,9 @@ guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass. ### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2) -> ⛔ **SUPERSEDED SAME DAY — the periodic re-browse is to be REMOVED (owner decision, Gitea #523).** -> It ships in `master` (`e77c8a35`, merged `914d70bf`) and this entry stays as the record of what was -> built and why, but it is **not the intended end state**. The objection is the one this entry does not -> weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no +> ⛔ **REMOVED — the periodic re-browse is gone from the tree (owner decision, Gitea #523; removal +> recorded in the next entry).** It shipped in `master` (`e77c8a35`, merged `914d70bf`) and this entry +> stays as the record of what was built and why. The objection is the one this entry does not weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no > appsettings key) and **never live-gated** outbound traffic to real PLCs, CNCs and MTConnect Agents. > Frequency of an unsolicited device browse is an operational decision, and the design treated it as an > implementation detail. @@ -679,6 +678,40 @@ The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no prompt raised", which would have passed for the wrong reason if the timer simply never fired. +### 2026-07-28 — #523: the periodic re-browse removed + +The feature described in the entry above is **gone from the tree**. Removed: + +| Removed | Where | +|---|---| +| `TagSetDrift` + `TagSetDriftDetector` | `Runtime/Drivers/TagSetDrift.cs` (file deleted) | +| The drift timer, `DriftCheckTick`, `DefaultDriftCheckInterval`, the `driftCheckInterval` Props/ctor parameter, `QualifiesForDriftCheck`, `Start`/`StopDriftCheck`, `HandleDriftCheckAsync`, `CollectLeafRefs`, `_lastDriftSignature` | `DriverInstanceActor` | +| `ITagDiscovery.AuthoredDiscoveryRefs` + `.DiscoveryStreamIncludesAuthoredTags`, and their four implementations | `Core.Abstractions`; FOCAS, TwinCAT, MTConnect, AbCip | +| `DriverInstanceActorDriftCheckTests` (6), `TagSetDriftDetectorTests` (8) | `Runtime.Tests` | + +**What survives, deliberately.** `ITagDiscovery.SupportsOnlineDiscovery` stays — it predates drift +detection and is what the universal browse picker dispatches on. The `IRediscoverable` consumption +(`09a401b8`) is untouched: the driver tells us, we do not poll, and it costs nothing when nothing +changes. The `/hosts` re-browse chip stays and is now fed by that one source. + +**The consequence, stated plainly rather than buried.** **AbCip and FOCAS now have no tag-change signal +at all.** Both browse a live backend, neither implements `IRediscoverable`, and the periodic compare was +the only thing that would have noticed a PLC re-download or a CNC option install. That is the accepted +trade, not an oversight — it is recorded in `CLAUDE.md` §Change Detection so the next reader does not +have to rediscover it, together with the shape to reach for if the coverage is wanted back +(event-driven, or triggered by a reconnect/deploy, not a wall-clock timer). + +**Why the earlier entry's reasoning did not save it.** That entry defends the *design* — the gate is +real, the tautology drivers are correctly excluded, the undetectable direction is declared rather than +faked. All of that is true and none of it is the objection. The objection is to the feature existing at +all in that shape: it browsed real plant equipment on a recurring timer, at a frequency nobody could +configure, and that behaviour was never once observed against real hardware. A well-built mechanism for +something that should not run is still something that should not run. + +Build clean; `Runtime.Tests` 476 passed / 13 skipped (unchanged skip set — the stale +`EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342, +`Core.Abstractions` 266, all green. + ### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6) **The maps had to become data before they could be guarded.** A Razor `@switch` compiles into diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs index 76d2009d..cc713a1b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs @@ -38,32 +38,4 @@ public interface ITagDiscovery /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. /// bool SupportsOnlineDiscovery => false; - - /// - /// The device-native references this driver's authored tags bind to — the same vocabulary - /// streams as . Comparing the - /// two sets detects that the remote's tag set has drifted from what an operator authored. - /// Null means "I do not report this", and drift detection is skipped. That is the - /// default on purpose: an empty collection legitimately means "no tags authored, so everything - /// discovered is new", and the two must not be confused. A driver opting in is asserting that its - /// authored refs are directly comparable to what it discovers. - /// Only consulted when is true. For a driver that - /// replays authored tags from config rather than browsing the backend, the comparison is a - /// tautology — it would report "no drift" forever and read as reassurance. - /// - IReadOnlyCollection? AuthoredDiscoveryRefs => null; - - /// - /// True when re-emits this driver's authored tags into the same - /// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker - /// shows an operator their existing tags alongside what the device offers. - /// This makes one half of drift detection structurally undetectable. If authored tags - /// are always re-emitted then the discovered set always contains the authored set, so an authored tag - /// that has VANISHED from the device can never appear missing. Declaring it here means the detector - /// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading - /// as reassurance. - /// False (the default) means the stream is purely device-derived — as MTConnect's is, built - /// from the Agent's probe model — so both directions are detectable. - /// - bool DiscoveryStreamIncludesAuthoredTags => false; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 60aff54f..05ad2904 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1036,17 +1036,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; - /// - /// Pre-declared tags are emitted under their Name, which is the driver FullName the - /// controller-browse leaves also use. - public IReadOnlyCollection? AuthoredDiscoveryRefs => - _declaredTags.Select(t => t.Name).ToArray(); - - /// - /// True: DiscoverAsync emits browsed controller symbols AND re-emits every - /// pre-declared tag. - public bool DiscoveryStreamIncludesAuthoredTags => true; - /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 72b28f91..b01ef4a4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -517,17 +517,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// until the node set is non-empty and stable. public bool SupportsOnlineDiscovery => true; - /// - /// The authored tag's Name IS its driver-side FullName — DiscoverAsync emits - /// FullName: tag.Name for every authored tag — so the two sets are directly comparable. - public IReadOnlyCollection? AuthoredDiscoveryRefs => - _tagsByRawPath.Values.Select(t => t.Name).ToArray(); - - /// - /// True: DiscoverAsync emits the device-derived FixedTree AND re-emits every authored - /// tag, so an authored tag that vanished from the CNC cannot appear missing. - public bool DiscoveryStreamIncludesAuthoredTags => true; - /// public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 384d4f5b..9c60197c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -907,19 +907,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// public bool SupportsOnlineDiscovery => true; - /// - /// The DataItem ids the authored raw tags bind. DiscoverAsync emits - /// FullName: dataItem.Id from the Agent's probe model, so the two sets are the same - /// vocabulary. - public IReadOnlyCollection? AuthoredDiscoveryRefs => - Volatile.Read(ref _binding).RawPathsByDataItemId.Keys; - - /// - /// False — DiscoverAsync is built purely from the Agent's probe model and never - /// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as - /// missing. MTConnect is currently the only driver where both drift directions are detectable. - public bool DiscoveryStreamIncludesAuthoredTags => false; - /// /// /// Once, not the default UntilStable: streams the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 6e960bdd..4acdbdf2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -404,17 +404,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; - /// - /// The authored tag's Name is its RawPath and its driver FullName - /// (DiscoverAsync emits FullName: tag.Name), directly comparable to the browsed - /// symbols' InstancePath. - public IReadOnlyCollection? AuthoredDiscoveryRefs => - _tagsByRawPath.Values.Select(t => t.Name).ToArray(); - - /// - /// True: DiscoverAsync emits browsed ADS symbols AND re-emits every authored tag. - public bool DiscoveryStreamIncludesAuthoredTags => true; - /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 19e2331e..5fc2139e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -1,6 +1,5 @@ using Akka.Actor; using Akka.Event; -using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; @@ -33,11 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); - /// Default period of the tag-set drift check. Deliberately slow: it browses a real device, and - /// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime - /// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens. - public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5); - public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeSucceeded(int Generation); public sealed record InitializeFailed(string Reason, int Generation); @@ -127,15 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// between connects), and dropping it in one state would lose the signal silently. private sealed record RediscoveryRaised(RediscoveryEventArgs Args); - /// Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the - /// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see - /// . - private sealed record DriftCheckTick - { - public static readonly DriftCheckTick Instance = new(); - private DriftCheckTick() { } - } - public sealed class RetryConnect { public static readonly RetryConnect Instance = new(); @@ -196,14 +181,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private DateTime? _rediscoveryNeededUtc; private string? _rediscoveryReason; - /// Period of the tag-set drift check; tests inject a tiny value. - private readonly TimeSpan _driftCheckInterval; - - /// Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every - /// check. Without this the operator's prompt would re-stamp its timestamp every interval forever, - /// which reads as "it just happened again" rather than "it is still true". - private string? _lastDriftSignature; - /// The references the host wants kept subscribed (set by ). /// Re-applied on every entry into Connected so values resume after a reconnect or redeploy. private IReadOnlyList _desiredRefs = Array.Empty(); @@ -237,8 +214,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// defaults to an empty string when not provided (e.g. in unit tests). /// Optional Phase 6.1 resilience invoker wrapping this driver's capability /// calls; defaults to (pass-through) when not supplied. - /// Optional period of the tag-set drift check; defaults to - /// . Tests inject a tiny value so the check runs without a wait. /// Optional period of the health-poll heartbeat, which also drives the /// subscription reconcile (); defaults to /// . Exists so a test can drive the reconcile without waiting 30 s. @@ -250,8 +225,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, IDriverCapabilityInvoker? invoker = null, - TimeSpan? healthPollInterval = null, - TimeSpan? driftCheckInterval = null) => + TimeSpan? healthPollInterval = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( driver, reconnectInterval ?? DefaultReconnectInterval, @@ -259,8 +233,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers healthPublisher ?? NullDriverHealthPublisher.Instance, clusterId ?? string.Empty, invoker, - healthPollInterval, - driftCheckInterval)); + healthPollInterval)); /// /// Returns true when the driver should boot in DEV-STUB mode based on host platform and @@ -291,8 +264,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Cluster identifier forwarded in health snapshots. /// Phase 6.1 resilience invoker wrapping this driver's capability calls; /// defaults to (pass-through) when null. - /// Period of the tag-set drift check; defaults to - /// . /// Period of the health-poll heartbeat, which also drives the /// subscription reconcile; defaults to when null. public DriverInstanceActor( @@ -302,15 +273,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, IDriverCapabilityInvoker? invoker = null, - TimeSpan? healthPollInterval = null, - TimeSpan? driftCheckInterval = null) + TimeSpan? healthPollInterval = null) { _driver = driver; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _driverInstanceId = driver.DriverInstanceId; _clusterId = clusterId ?? string.Empty; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; - _driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval; _reconnectInterval = reconnectInterval; _healthPollInterval = healthPollInterval ?? HealthPollInterval; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, @@ -381,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDriftCheck(); }); Receive(msg => { @@ -427,7 +395,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _driverInstanceId, msg.Reason); DetachSubscription(); RecordFault(); - StopDriftCheck(); Become(Reconnecting); PublishHealthSnapshot(); }); @@ -435,7 +402,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); DetachSubscription(); - StopDriftCheck(); Become(Reconnecting); PublishHealthSnapshot(); }); @@ -458,7 +424,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers SubscribeDesiredAlarms(); }); ReceiveAsync(HandleSubscribeAlarmsAsync); - ReceiveAsync(HandleDriftCheckAsync); Receive(OnDataChangeForward); // Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent; // project it to the parent the same way DataChangeForward projects AttributeValuePublished. @@ -553,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDriftCheck(); }); // A failure here is a no-op regardless of generation — the retry timer keeps trying the // current config; only a (generation-matched) InitializeSucceeded transitions state. @@ -798,10 +762,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } - /// Stops the drift check. Called on every Connected exit — browsing a disconnected driver would - /// fail every pass, and a failed browse is deliberately NOT treated as drift. - private void StopDriftCheck() => Timers.Cancel("drift-check"); - /// Tear down the data-change + native-alarm event handlers + null the handle. Called from the /// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't /// push data-change / alarm events to an actor that has lost its driver connection. @@ -849,119 +809,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _rediscoveryHandler = null; } - /// - /// True when this driver is worth drift-checking: it must genuinely BROWSE its remote - /// () and must report the refs its authored tags - /// bind (). - /// The first condition is what makes the check meaningful rather than reassuring. Most drivers - /// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of DiscoverAsync - /// rather than enumerating the device, so diffing the two sets for them is a tautology that would - /// report "no drift" forever. A check that cannot fail is worse than no check: it looks like - /// coverage. - /// - private bool QualifiesForDriftCheck() => - _driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null }; - - /// Starts the slow drift check on a Connected entry, for qualifying drivers only. - private void StartDriftCheck() - { - if (!QualifiesForDriftCheck()) return; - // Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after - // connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and - // report phantom drift on every reconnect. - Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval); - } - - /// - /// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises - /// the same operator prompt an raise does — this is the signal for the - /// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic - /// compare is the only way to notice a PLC program re-download. - /// Advisory, like every rediscovery signal: the served address space is not touched. Raw tags - /// are authored through the /raw browse-commit flow, so an operator re-browses and commits. - /// - private async Task HandleDriftCheckAsync(DriftCheckTick _) - { - if (_driver is not ITagDiscovery discovery) return; - var authored = discovery.AuthoredDiscoveryRefs; - if (authored is null) return; - - CapturedTree tree; - try - { - var builder = new CapturingAddressSpaceBuilder(); - // Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would - // block writes, reconnects and health polls behind it. - using var cts = new CancellationTokenSource(DriftBrowseTimeout); - await _invoker.ExecuteAsync( - DriverCapability.Discover, - _driverInstanceId, - async ct => await discovery.DiscoverAsync(builder, ct), - cts.Token); - tree = builder.Build(); - } - catch (Exception ex) - { - // A failed browse is not drift — the device may simply be unreachable, which the health surface - // already reports. Reporting drift here would turn every comms blip into "all your tags vanished". - _log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId); - return; - } - - if (tree.Truncated) - { - // A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished. - _log.Warning( - "DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial", - _driverInstanceId); - return; - } - - var discovered = new List(); - CollectLeafRefs(tree.Root, discovered); - var drift = TagSetDriftDetector.Compare( - discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags); - - if (!drift.HasDrift) - { - // Recovered: the device matches the authored set again, so drop the prompt and let the next - // genuine drift re-raise with a fresh timestamp. - if (_lastDriftSignature is not null) - { - _log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set", - _driverInstanceId); - _lastDriftSignature = null; - _rediscoveryNeededUtc = null; - _rediscoveryReason = null; - PublishHealthSnapshot(); - } - - return; - } - - // Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read - // as "it just happened again" rather than "it is still true". - if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return; - _lastDriftSignature = drift.Signature; - _rediscoveryNeededUtc = DateTime.UtcNow; - _rediscoveryReason = "device tag set differs from authored: " + drift.Describe(); - _log.Info( - "DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged", - _driverInstanceId, drift.Describe()); - PublishHealthSnapshot(); - } - - /// Per-pass timeout for the drift browse. Bounds the mailbox suspension. - private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30); - - /// Flattens a captured tree to its leaf ids — the driver-side FullNames, which is the - /// vocabulary AuthoredDiscoveryRefs is defined in. - private static void CollectLeafRefs(CapturedNode node, List into) - { - if (node.IsLeaf) into.Add(node.Id); - foreach (var child in node.Children) CollectLeafRefs(child, into); - } - /// Records the driver's rediscovery raise and re-publishes health so the signal reaches the /// AdminUI promptly rather than waiting for the next 30 s heartbeat. /// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags @@ -1137,7 +984,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// protected override void PostStop() { - StopDriftCheck(); DetachSubscription(); // MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs deleted file mode 100644 index 7a2e0015..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// The difference between what a driver discovers on its remote and what an operator -/// authored — i.e. whether the device's tag set has moved out from under the deployed config. -/// -/// Refs the remote now offers that no authored tag binds. -/// -/// Refs authored tags bind that the remote no longer offers. Always empty when -/// is false — see that parameter. -/// -/// -/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived -/// ones (ITagDiscovery.DiscoveryStreamIncludesAuthoredTags). The discovered set then always -/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so -/// can say "new-on-device only" rather than implying a clean bill of health. -/// -public sealed record TagSetDrift( - IReadOnlyList Appeared, - IReadOnlyList Vanished, - bool VanishedDetectable) -{ - /// True when the two sets differ in either direction. - public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0; - - /// - /// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an - /// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical - /// drift look new. - /// - public string Signature { get; } = - string.Join('', Appeared) + '' + string.Join('', Vanished); - - /// An operator-facing one-liner naming what moved, suitable for a UI tooltip. - /// A short human-readable description, or "no drift" when there is none. - public string Describe() - { - if (!HasDrift) return "no drift"; - var parts = new List(3); - if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})"); - if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})"); - // Say so rather than let the absence of a "missing" clause read as "nothing is missing". - if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver"); - return string.Join("; ", parts); - } - - /// Names at most two refs so the message stays readable when a whole PLC program is swapped. - private static string Sample(IReadOnlyList refs) => - refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more"; -} - -/// -/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the -/// actor's periodic check stays cheap; kept out of DriverInstanceActor so it is directly testable -/// without an actor system. -/// -public static class TagSetDriftDetector -{ - /// - /// Computes the two-way difference. - /// Ordinal comparison, matching how every driver keys its tag tables — a device that - /// genuinely exposes both Speed and speed must not have them collapsed. - /// - /// Refs streamed by the driver's most recent discovery pass. - /// Refs the driver's authored tags bind (ITagDiscovery.AuthoredDiscoveryRefs). - /// - /// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag - /// structurally invisible. The vanished half is then not computed at all rather than computed to a - /// misleading empty — see . - /// - /// The drift, which may be empty. - public static TagSetDrift Compare( - IReadOnlyCollection discovered, - IReadOnlyCollection authored, - bool detectVanished = true) - { - ArgumentNullException.ThrowIfNull(discovered); - ArgumentNullException.ThrowIfNull(authored); - - var authoredSet = new HashSet(authored, StringComparer.Ordinal); - var discoveredSet = new HashSet(discovered, StringComparer.Ordinal); - - var appeared = discoveredSet.Where(r => !authoredSet.Contains(r)) - .OrderBy(r => r, StringComparer.Ordinal).ToArray(); - var vanished = detectVanished - ? authoredSet.Where(r => !discoveredSet.Contains(r)) - .OrderBy(r => r, StringComparer.Ordinal).ToArray() - : []; - - return new TagSetDrift(appeared, vanished, detectVanished); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs deleted file mode 100644 index f49e25b6..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -using Akka.Actor; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the -/// remote offers against what an operator authored. -/// Why this is gated rather than run for every driver. Modbus, S7, AbLegacy, Sql and Mqtt -/// all stream their AUTHORED tags back out of DiscoverAsync rather than enumerating the device, so -/// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail -/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that -/// declare SupportsOnlineDiscovery AND report AuthoredDiscoveryRefs. -/// The signal is advisory and shares the Stage-2 surfacing: it raises the same /hosts -/// re-browse prompt an raise does. The served address space is never -/// rebuilt — raw tags are authored through the /raw browse-commit flow. -/// -[Trait("Category", "Unit")] -public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase -{ - private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60); - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3); - - /// A device offering a tag nothing binds raises the operator prompt, naming what appeared. - [Fact] - public void A_new_ref_on_the_device_raises_the_re_browse_prompt() - { - var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]); - var publisher = new RecordingHealthPublisher(); - var actor = SpawnConnected(driver, publisher); - - AwaitAssert( - () => - { - var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); - signalled.ShouldNotBeNull(); - signalled!.RediscoveryReason.ShouldNotBeNull(); - signalled.RediscoveryReason!.ShouldContain("1 new on device"); - signalled.RediscoveryReason!.ShouldContain("c"); - }, - Budget); - - Sys.Stop(actor); - } - - /// - /// The gate that makes this feature honest. A driver whose DiscoverAsync replays - /// authored config rather than browsing the device declares SupportsOnlineDiscovery == false, - /// and must never be drift-checked — for it the comparison could only ever say "no drift". - /// Positive control: the driver is handed a set that WOULD read as drift, and the assertion is - /// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong - /// reason (e.g. if the timer never fired at all). - /// - [Fact] - public void A_driver_that_replays_authored_config_is_never_drift_checked() - { - var driver = new BrowsableStubDriver( - discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false); - var publisher = new RecordingHealthPublisher(); - var actor = SpawnConnected(driver, publisher); - - // Long enough for several ticks had the timer been scheduled. - ExpectNoMsg(TimeSpan.FromMilliseconds(400)); - - driver.DiscoverCount.ShouldBe( - 0, - "a driver that replays authored config was browsed for drift — the comparison is a tautology " - + "for it and would report 'no drift' forever, which reads as coverage"); - publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); - - Sys.Stop(actor); - } - - /// A driver that browses but does NOT report its authored refs opts out — the default. An empty - /// collection would mean "nothing authored, everything is new", so null must not be read that way. - [Fact] - public void A_driver_that_reports_no_authored_refs_is_never_drift_checked() - { - var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null); - var publisher = new RecordingHealthPublisher(); - var actor = SpawnConnected(driver, publisher); - - ExpectNoMsg(TimeSpan.FromMilliseconds(400)); - - driver.DiscoverCount.ShouldBe(0); - publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); - - Sys.Stop(actor); - } - - /// - /// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp - /// keeps moving reads as "it just happened again" rather than "it is still true". - /// - [Fact] - public void An_unchanged_drift_is_reported_once() - { - var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); - var publisher = new RecordingHealthPublisher(); - var actor = SpawnConnected(driver, publisher); - - AwaitAssert( - () => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1), - Budget); - - // Several more ticks pass with the same drift. - AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget); - - publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe( - 1, - "an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp"); - - Sys.Stop(actor); - } - - /// A failed browse is not drift. A device that is merely unreachable already shows on the health - /// surface; reporting drift would turn every comms blip into "all your tags vanished". - [Fact] - public void A_failed_browse_is_not_reported_as_drift() - { - var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true }; - var publisher = new RecordingHealthPublisher(); - var actor = SpawnConnected(driver, publisher); - - AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget); - - publisher.Published.ShouldAllBe( - p => p.RediscoveryNeededUtc == null, - "a browse that threw was treated as drift — an unreachable device would report every authored " - + "tag as vanished"); - - Sys.Stop(actor); - } - - /// Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next - /// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one. - [Fact] - public void Drift_that_resolves_clears_the_prompt() - { - var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); - var publisher = new RecordingHealthPublisher(); - var actor = SpawnConnected(driver, publisher); - - AwaitAssert( - () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), - Budget); - - // The operator commits the new tag: authored now matches the device. - driver.SetAuthored(["a", "b"]); - - AwaitAssert( - () => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(), - Budget); - - Sys.Stop(actor); - } - - private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher) - { - var parent = CreateTestProbe(); - parent.IgnoreMessages(_ => true); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, healthPublisher: publisher, driftCheckInterval: Tick)); - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - return actor; - } - - /// Captured health publishes, so a test can read the rediscovery fields. - private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason); - - private sealed class RecordingHealthPublisher : IDriverHealthPublisher - { - private readonly List _published = []; - - public IReadOnlyList Published - { - get { lock (_published) return _published.ToArray(); } - } - - /// - public void Publish( - string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min, - DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null) - { - lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason)); - } - } - - /// - /// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages — - /// the shared StubDriver returns DateTime.UtcNow, which would make every publish look - /// changed and let a dedup-related bug pass unnoticed. - /// - private sealed class BrowsableStubDriver( - string[] discovered, - string[]? authored, - bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery - { - private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc); - private volatile string[]? _authored = authored; - private int _discoverCount; - - /// Number of discovery passes the actor has driven — the direct read of whether the drift - /// check ran at all. - public int DiscoverCount => Volatile.Read(ref _discoverCount); - - /// When true, every browse throws — models an unreachable device. - public bool DiscoverThrows { get; init; } - - /// - public string DriverInstanceId => "browsable-stub-1"; - - /// - public string DriverType => "Stub"; - - /// - public bool SupportsOnlineDiscovery => supportsOnlineDiscovery; - - /// - public IReadOnlyCollection? AuthoredDiscoveryRefs => _authored; - - /// Models the operator committing (or removing) tags between checks. - public void SetAuthored(string[] refs) => _authored = refs; - - /// - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - Interlocked.Increment(ref _discoverCount); - if (DiscoverThrows) throw new IOException("device unreachable"); - - var folder = builder.Folder("Stub", "Stub"); - foreach (var r in discovered) - { - folder.Variable(r, r, new DriverAttributeInfo( - FullName: r, - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - - return Task.CompletedTask; - } - - /// - public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - /// - public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null); - - /// - public long GetMemoryFootprint() => 0; - - /// - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs deleted file mode 100644 index b48f8d22..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored. -/// Kept out of the actor so the comparison rules are testable without an actor system. -/// -[Trait("Category", "Unit")] -public sealed class TagSetDriftDetectorTests -{ - /// Matching sets are not drift, and must not raise an operator prompt. - [Fact] - public void Identical_sets_are_not_drift() - { - var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]); - - drift.HasDrift.ShouldBeFalse(); - drift.Describe().ShouldBe("no drift"); - } - - /// A tag the device now offers that nothing binds — the common case after a PLC re-download. - [Fact] - public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared() - { - var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]); - - drift.Appeared.ShouldBe(["c"]); - drift.Vanished.ShouldBeEmpty(); - drift.HasDrift.ShouldBeTrue(); - } - - /// An authored tag the device no longer offers — detectable only for a driver whose discovery - /// stream is purely device-derived. - [Fact] - public void An_authored_ref_missing_from_the_device_is_reported_as_vanished() - { - var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]); - - drift.Vanished.ShouldBe(["b"]); - drift.Appeared.ShouldBeEmpty(); - } - - /// - /// The honesty guard. When a driver re-emits its authored tags into the discovery stream — - /// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a - /// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty - /// Vanished list; it must flag that the direction is undetectable, and say so in the description. - /// - [Fact] - public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty() - { - // "b" is authored and absent from the device — but this driver would have re-emitted it, so its - // absence here cannot occur in practice and must not be presented as a clean result either way. - var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false); - - drift.VanishedDetectable.ShouldBeFalse(); - drift.Vanished.ShouldBeEmpty(); - drift.Appeared.ShouldBe(["c"]); - drift.Describe().ShouldContain( - "missing-tag detection unavailable", - customMessage: - "a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' " - + "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes"); - } - - /// Comparison is ordinal: a device legitimately exposing both Speed and speed - /// must not have them collapsed, which would hide a genuinely new tag. - [Fact] - public void Comparison_is_case_sensitive() - { - var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]); - - drift.Appeared.ShouldBe(["speed"]); - } - - /// The signature identifies a drift so an UNCHANGED one is not re-announced every check. - /// Enumeration order must not make an identical drift look new. - [Fact] - public void Signature_is_stable_across_enumeration_order() - { - var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]); - var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]); - - a.Signature.ShouldBe(b.Signature); - } - - /// A different drift must get a different signature, or the second one is never reported. - [Fact] - public void Signature_changes_when_the_drift_changes() - { - var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]); - var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]); - - a.Signature.ShouldNotBe(b.Signature); - } - - /// The description names a couple of refs and then counts, so swapping a whole PLC program - /// yields a readable message rather than a wall of tag names. - [Fact] - public void Describe_samples_rather_than_listing_everything() - { - var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray(); - var drift = TagSetDriftDetector.Compare(discovered, []); - - var text = drift.Describe(); - text.ShouldContain("50 new on device"); - text.ShouldContain("+48 more"); - text.Length.ShouldBeLessThan(120); - } -}