fix(core): resolve Low code-review findings (Core-004,008,009,010,011,012)

- Core-004: add ConfigureAwait(false) to DriverHost.RegisterAsync /
  UnregisterAsync / DisposeAsync.
- Core-008: rewrite the BuildAddressSpaceAsync XML doc to correctly name
  the caller (OpcUaApplicationHost.PopulateAddressSpaces) that owns the
  per-driver isolation.
- Core-009: snapshot DriverResilienceOptions once per non-idempotent write
  in CapabilityInvoker.ExecuteWriteAsync.
- Core-010: switch DriverResilienceOptions.Resolve to TryGetValue with a
  diagnostic error message when a tier table is missing a capability.
- Core-011: add an optional diagnostic callback to PermissionTrieBuilder
  so production callers can surface scope-path mismatches.
- Core-012: correct the stale WedgeDetector ctor summary and add the
  Reconnecting row to DriverHealthReport's state matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 05:38:09 -04:00
parent ff2e75ab98
commit 8be6afbda4
15 changed files with 656 additions and 28 deletions

View File

@@ -0,0 +1,155 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
/// <c>Descend</c> helper:
/// <list type="bullet">
/// <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
/// trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
/// ONLY when the requested scope walks the same namespace/area/line chain.</item>
/// <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
/// the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
/// this fallback (warning callback) so callers know a grant was placed where the
/// walker can't reach it for production hierarchies — silently dropping the grant
/// is the Core-011 production hazard.</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class PermissionTrieBuilderTests
{
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = $"acl-{Guid.NewGuid():N}",
GenerationId = 1,
ClusterId = clusterId,
LdapGroup = group,
ScopeKind = scope,
ScopeId = scopeId,
PermissionFlags = flags,
};
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
new()
{
ClusterId = cluster,
NamespaceId = ns,
UnsAreaId = area,
UnsLineId = line,
EquipmentId = equip,
TagId = tag,
Kind = NodeHierarchyKind.Equipment,
};
[Fact]
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
{
// Scope path mirrors the production hierarchy: namespace → area → line.
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
};
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
// Walk through the same chain — the grant must be reachable.
var matchOnLine = trie.CollectMatches(
EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
["cn=ops"]);
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
// A different line under the same area must not pick up the grant.
var matchOnOtherLine = trie.CollectMatches(
EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
["cn=ops"]);
matchOnOtherLine.ShouldBeEmpty(
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
}
[Fact]
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
{
// Fallback path — deterministic tests pass without a scope-path lookup. The row
// is placed as a direct child of the trie root keyed by ScopeId.
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
// Root has one child — "line-42".
trie.Root.Children.ShouldContainKey("line-42");
var node = trie.Root.Children["line-42"];
node.Grants.Count.ShouldBe(1);
}
/// <summary>
/// Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
/// <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
/// surface a warning. Silently dropping the grant under the wrong trie level is the
/// production hazard the finding flagged.
/// </summary>
[Fact]
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
};
// Row references a line that is NOT in the path lookup.
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
};
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
diagnostics[0].ScopeId.ShouldBe("line-orphan");
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
}
[Fact]
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
};
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — no descent
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
};
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
}
[Fact]
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
{
// No diagnostics callback should fire when scopePaths itself is null — that's the
// "deterministic-test fallback" mode, not a production drop.
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);
diagnostics.ShouldBeEmpty(
"scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
}
}

View File

@@ -77,4 +77,162 @@ public sealed class DriverHostTests
host.RegisteredDriverIds.ShouldNotContain("d-1");
driver.ShutDown.ShouldBeTrue();
}
/// <summary>
/// Core-004 regression — DriverHost is a library type whose async calls must use
/// ConfigureAwait(false) to match the convention used by CapabilityInvoker /
/// AlarmSurfaceInvoker. Asserts the awaited driver call does not post its
/// continuation back to a captured SynchronizationContext.
/// The driver awaits an unsettled TaskCompletionSource so it does not introduce its
/// own capture — only DriverHost's await of the returned Task can drive a post.
/// </summary>
[Fact]
public async Task RegisterAsync_Does_Not_Capture_SynchronizationContext()
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-1", tcs);
var ctx = new TrackingSynchronizationContext();
// Run the DriverHost call on a dedicated thread that has our tracking SyncContext installed.
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
var registerTask = host.RegisterAsync(driver, "{}", CancellationToken.None);
// Complete the driver's InitializeAsync from a background thread so DriverHost's
// await must resume via the captured context if ConfigureAwait(false) was missing.
_ = Task.Run(() => tcs.SetResult());
await registerTask.ConfigureAwait(false);
await host.DisposeAsync().ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"RegisterAsync's awaited driver call must use ConfigureAwait(false) so the continuation does not post back to the captured context");
}
[Fact]
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
{
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
initTcs.SetResult();
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-2", initTcs, shutdownTcs);
var ctx = new TrackingSynchronizationContext();
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
// After RegisterAsync we re-enter the context. Reset the post counter so we only
// observe UnregisterAsync's behaviour from here on.
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
var task = host.UnregisterAsync("d-cfg-2", CancellationToken.None);
_ = Task.Run(() => shutdownTcs.SetResult());
await task.ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"UnregisterAsync's awaited shutdown call must use ConfigureAwait(false)");
}
[Fact]
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
{
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
initTcs.SetResult();
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-3", initTcs, shutdownTcs);
var ctx = new TrackingSynchronizationContext();
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
var task = host.DisposeAsync();
_ = Task.Run(() => shutdownTcs.SetResult());
await task.ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"DisposeAsync's awaited shutdown call must use ConfigureAwait(false)");
}
/// <summary>
/// Run <paramref name="body"/> on a dedicated thread with <paramref name="ctx"/>
/// installed as the current SynchronizationContext, and return <paramref name="ctx"/>
/// after the body completes. The dedicated thread guarantees that resuming via the
/// captured context observably routes through our Post hook (the ThreadPool would
/// otherwise clear the context on the resuming worker).
/// </summary>
private static Task<TrackingSynchronizationContext> RunOnContextAsync(TrackingSynchronizationContext ctx, Func<Task> body)
{
var done = new TaskCompletionSource<TrackingSynchronizationContext>(TaskCreationOptions.RunContinuationsAsynchronously);
var t = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(ctx);
try
{
// Pump posted continuations until the body completes.
var task = body();
while (!task.IsCompleted)
{
if (ctx.TryDequeue(out var work)) work();
else Thread.Sleep(1);
}
// Drain any tail continuations.
while (ctx.TryDequeue(out var work)) work();
task.GetAwaiter().GetResult();
done.SetResult(ctx);
}
catch (Exception ex) { done.SetException(ex); }
}) { IsBackground = true };
t.Start();
return done.Task;
}
/// <summary>Driver whose Initialize / Shutdown completions are caller-controlled via TCS.</summary>
private sealed class TcsDriver(string id, TaskCompletionSource initTcs, TaskCompletionSource? shutdownTcs = null) : IDriver
{
public string DriverInstanceId { get; } = id;
public string DriverType => "Tcs";
public Task InitializeAsync(string _, CancellationToken ct) => initTcs.Task;
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
private static TaskCompletionSource MakeCompleted()
{
var t = new TaskCompletionSource();
t.SetResult();
return t;
}
}
/// <summary>SynchronizationContext that queues posts to a thread-safe work list and counts them.</summary>
private sealed class TrackingSynchronizationContext : SynchronizationContext
{
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _queue = new();
public int PostCount;
public int SendCount;
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostCount);
_queue.Enqueue(() => d(state));
}
public override void Send(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref SendCount);
d(state);
}
public bool TryDequeue(out Action work) => _queue.TryDequeue(out work!);
public void Reset() { Interlocked.Exchange(ref PostCount, 0); Interlocked.Exchange(ref SendCount, 0); }
}
}

View File

@@ -143,6 +143,41 @@ public sealed class GenericDriverNodeManagerTests
nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None));
}
/// <summary>
/// Core-008 regression: the XML doc states exception isolation is the caller's
/// responsibility — exceptions from <see cref="ITagDiscovery.DiscoverAsync"/> must propagate
/// out of <c>BuildAddressSpaceAsync</c> unhandled so the Server layer's per-driver try/catch
/// (<c>OpcUaApplicationHost.PopulateAddressSpaces</c>) can mark the subtree Faulted.
/// </summary>
[Fact]
public async Task BuildAddressSpaceAsync_Propagates_Discovery_Exceptions_To_Caller()
{
var driver = new ThrowingDiscoveryDriver();
using var nm = new GenericDriverNodeManager(driver);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None));
ex.Message.ShouldBe("discovery boom",
"exceptions from DiscoverAsync must propagate unhandled — exception isolation is the caller's responsibility (e.g. OpcUaApplicationHost)");
}
/// <summary>Driver whose DiscoverAsync throws — exercises the exception-isolation boundary.</summary>
private sealed class ThrowingDiscoveryDriver : IDriver, ITagDiscovery
{
public string DriverInstanceId => "throwing";
public string DriverType => "Throwing";
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
=> throw new InvalidOperationException("discovery boom");
}
// --- test doubles ---
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource

View File

@@ -67,4 +67,53 @@ public sealed class DriverHealthReportTests
{
DriverHealthReport.HttpStatus(verdict).ShouldBe(expected);
}
/// <summary>
/// Core-012 regression: <see cref="DriverState.Reconnecting"/> must aggregate to
/// <see cref="ReadinessVerdict.Degraded"/> — the doc remarks state matrix lists this
/// mapping (after the Core-012 doc fix that added the Reconnecting row).
/// </summary>
[Fact]
public void Any_Reconnecting_WithoutFaultedOrNotReady_IsDegraded()
{
var verdict = DriverHealthReport.Aggregate([
new DriverHealthSnapshot("a", DriverState.Healthy),
new DriverHealthSnapshot("b", DriverState.Reconnecting),
]);
verdict.ShouldBe(ReadinessVerdict.Degraded,
"Reconnecting = driver alive but not serving live data → /readyz stays 200 while operators see the affected driver in the body");
}
/// <summary>
/// Core-012 regression: assert the XML <c>&lt;remarks&gt;</c> on
/// <see cref="DriverHealthReport"/> names <see cref="DriverState.Reconnecting"/> in its
/// state matrix. Catches a future doc-drift if someone re-aliases Reconnecting without
/// updating the matrix.
/// </summary>
[Fact]
public void Doc_State_Matrix_Includes_Reconnecting()
{
var xmlPath = Path.Combine(
AppContext.BaseDirectory,
"ZB.MOM.WW.OtOpcUa.Core.xml");
File.Exists(xmlPath).ShouldBeTrue($"expected XML doc file at {xmlPath}");
var content = File.ReadAllText(xmlPath);
var driverHealthReportRemarks = ExtractRemarksFor(content, "T:ZB.MOM.WW.OtOpcUa.Core.Observability.DriverHealthReport");
driverHealthReportRemarks.ShouldContain("Reconnecting");
}
private static string ExtractRemarksFor(string xml, string member)
{
var memberStart = xml.IndexOf($"<member name=\"{member}\"", StringComparison.Ordinal);
if (memberStart < 0) return string.Empty;
var memberEnd = xml.IndexOf("</member>", memberStart, StringComparison.Ordinal);
if (memberEnd < 0) return string.Empty;
var slice = xml.Substring(memberStart, memberEnd - memberStart);
var remarksStart = slice.IndexOf("<remarks>", StringComparison.Ordinal);
if (remarksStart < 0) return string.Empty;
var remarksEnd = slice.IndexOf("</remarks>", remarksStart, StringComparison.Ordinal);
return remarksEnd < 0 ? string.Empty : slice.Substring(remarksStart, remarksEnd - remarksStart);
}
}

View File

@@ -148,4 +148,66 @@ public sealed class CapabilityInvokerTests
builder.CachedPipelineCount.ShouldBe(2);
}
/// <summary>
/// Core-009 regression: ExecuteWriteAsync's non-idempotent branch must snapshot
/// <c>_optionsAccessor</c> exactly once per call. Calling it multiple times allocates
/// redundant options objects on the per-write hot path and creates a consistency hazard
/// where an Admin edit mid-call could observe two different snapshots.
/// </summary>
[Fact]
public async Task ExecuteWriteAsync_NonIdempotent_Snapshots_Options_Once_Per_Call()
{
var options = new DriverResilienceOptions
{
Tier = DriverTier.A,
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
},
};
var accessorCalls = 0;
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => { Interlocked.Increment(ref accessorCalls); return options; });
await invoker.ExecuteWriteAsync(
"host-1",
isIdempotent: false,
_ => ValueTask.FromResult(0),
CancellationToken.None);
accessorCalls.ShouldBe(1,
"ExecuteWriteAsync's non-idempotent branch must capture the options snapshot exactly once per call");
}
/// <summary>
/// Core-009 regression — companion consistency assertion: the non-idempotent branch must
/// not observe two different option snapshots even if the accessor's returned value changes
/// between calls (simulating an Admin edit landing mid-flight). With a single snapshot the
/// two derived values (<c>with</c> base + <c>Resolve(Write)</c>) come from the same options
/// instance.
/// </summary>
[Fact]
public async Task ExecuteWriteAsync_NonIdempotent_Uses_Consistent_Options_Snapshot()
{
var a = new DriverResilienceOptions { Tier = DriverTier.A };
var b = new DriverResilienceOptions { Tier = DriverTier.B };
var alternating = new[] { a, b, a, b }.AsEnumerable().GetEnumerator();
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => { alternating.MoveNext(); return alternating.Current; });
// If options is read twice, the with-expression and Resolve(Write) come from
// different tier tables (A then B) — the resulting one-entry dictionary is
// inconsistent with the snapshot used for the rest of the options. Single-snapshot
// semantics guarantee the call sees a coherent view.
await Should.NotThrowAsync(async () => await invoker.ExecuteWriteAsync(
"host-1",
isIdempotent: false,
_ => ValueTask.FromResult(0),
CancellationToken.None));
}
}

View File

@@ -99,4 +99,49 @@ public sealed class DriverResilienceOptionsTests
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
/// <summary>
/// Core-010 regression: every <see cref="DriverCapability"/> value must successfully resolve
/// under every tier with a default <see cref="DriverResilienceOptions"/>. A future
/// enum-only addition that forgets to update <c>GetTierDefaults</c> would otherwise blow up
/// on the hot path with <see cref="KeyNotFoundException"/>.
/// </summary>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
[InlineData(DriverTier.C)]
public void Resolve_Returns_NonNull_Policy_For_Every_Capability(DriverTier tier)
{
var options = new DriverResilienceOptions { Tier = tier };
foreach (var capability in Enum.GetValues<DriverCapability>())
{
var policy = options.Resolve(capability);
policy.ShouldNotBeNull(
$"every DriverCapability must resolve to a non-null policy for tier {tier} — {capability} did not");
}
}
/// <summary>
/// Core-010 regression: when a capability is somehow missing from BOTH the override
/// map and the tier defaults (defensive — should be impossible thanks to the
/// <c>TierDefaults_Cover_EveryCapability</c> invariant, but is the failure mode the
/// finding flagged), <c>Resolve</c> must throw a diagnostic <see cref="KeyNotFoundException"/>
/// that names the missing capability and tier — not a bare lookup failure.
/// </summary>
[Fact]
public void Resolve_Throws_Diagnostic_When_Capability_Missing_From_Tier_Defaults()
{
// Use a CapabilityPolicies dict that purposely omits one capability and use reflection
// to confirm the message names the capability when the tier defaults also omit it.
// We can't easily mutate GetTierDefaults so we exercise the documented behavior on a
// synthetic non-tier-known capability (we cast an out-of-range enum value).
var options = new DriverResilienceOptions { Tier = DriverTier.A };
var bogus = (DriverCapability)int.MaxValue;
var ex = Should.Throw<KeyNotFoundException>(() => options.Resolve(bogus));
ex.Message.ShouldContain(bogus.ToString());
ex.Message.ShouldContain(DriverTier.A.ToString());
ex.Message.ShouldContain(nameof(DriverResilienceOptions.GetTierDefaults));
}
}

View File

@@ -109,4 +109,40 @@ public sealed class WedgeDetectorTests
new DemandSignal(0, 0, 1, Now).HasPendingWork.ShouldBeTrue();
new DemandSignal(0, 0, 0, Now).HasPendingWork.ShouldBeFalse();
}
/// <summary>
/// Core-012 regression: the XML <c>&lt;summary&gt;</c> on the <see cref="WedgeDetector"/>
/// constructor must accurately describe what the constructor does (take + clamp the
/// threshold). The previous text — "Whether the driver reported itself Healthy at
/// construction" — referenced behaviour the constructor doesn't perform.
/// </summary>
[Fact]
public void Doc_Constructor_Summary_Describes_Threshold_Clamp()
{
var xmlPath = Path.Combine(
AppContext.BaseDirectory,
"ZB.MOM.WW.OtOpcUa.Core.xml");
File.Exists(xmlPath).ShouldBeTrue($"expected XML doc file at {xmlPath}");
var content = File.ReadAllText(xmlPath);
var ctorSummary = ExtractSummaryFor(content,
"M:ZB.MOM.WW.OtOpcUa.Core.Stability.WedgeDetector.#ctor(System.TimeSpan)");
ctorSummary.ShouldNotBeNullOrWhiteSpace();
ctorSummary.ShouldNotContain("reported itself");
ctorSummary.ShouldContain("threshold");
}
private static string ExtractSummaryFor(string xml, string member)
{
var memberStart = xml.IndexOf($"<member name=\"{member}\"", StringComparison.Ordinal);
if (memberStart < 0) return string.Empty;
var memberEnd = xml.IndexOf("</member>", memberStart, StringComparison.Ordinal);
if (memberEnd < 0) return string.Empty;
var slice = xml.Substring(memberStart, memberEnd - memberStart);
var sumStart = slice.IndexOf("<summary>", StringComparison.Ordinal);
if (sumStart < 0) return string.Empty;
var sumEnd = slice.IndexOf("</summary>", sumStart, StringComparison.Ordinal);
return sumEnd < 0 ? string.Empty : slice.Substring(sumStart, sumEnd - sumStart);
}
}