2d20aeb779
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact. DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication, BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in logs only. Resolved by option (b): the raw uint rides AttributeValuePublished -> AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad). The surface was wider than the issue costed (41 files, not "the sink and 3 implementations"): OpcUaQuality had non-driver producers with no uint to carry. Each got its natural code — BadInternalError for a failed virtual-tag script, BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the engine already held. AddressSpaceApplier's removal writes deliberately keep the generic Bad: not losing a code the driver had is this issue; inventing one for the deploy path is a separate call. Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change rather than a severity-bucket change. While the annotation was 3-state those were the same predicate; now a move between two Bad reasons changes what a subscriber reads. New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so it can only see a named constant, never an inline literal. #517 — the symptom is real but the issue named the wrong path and its fix was unsafe. ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth, has no production call site.) InitializeAsync can: it runs on a fire-and-forget Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a catch which disposes the client before writing Faulted. Moving the write earlier, as suggested, inverts the terminal state — teardown drains in-flight loops that write health as they unwind, so a late loop could leave Healthy at rest with every connection closed. The fix is a bracket (conservative before, terminal after), applied to MTConnect's init-failure path only; shutdown keeps its single trailing write, and the rule plus the reachability table are documented on IDriver.ShutdownAsync. CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing — Healthy before initialize, after shutdown and after dispose. Now derived from _running. Its test, named Health_is_always_Connected, asserted the defect and is replaced. Every new guard was falsified by breaking the production code and watching it go red. The two sub-code tests each assert two different Bad codes, since a single-code assertion could pass against a hard-coded constant under the old projection. Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in Host.IntegrationTests. Closes #517 Closes #519 Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
193 lines
10 KiB
C#
193 lines
10 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Guards the failure-visibility surface added for archreview 01/S-1: the applier must report a
|
|
/// failed rebuild (<see cref="AddressSpaceApplyOutcome.RebuildFailed"/>) and tally swallowed per-node
|
|
/// sink failures (<see cref="AddressSpaceApplyOutcome.FailedNodes"/> for Apply's own passes; the
|
|
/// <c>Materialise*</c> passes return their own failed-node counts) instead of reporting optimistic
|
|
/// success while the running address space is stale or partial.
|
|
/// </summary>
|
|
public sealed class AddressSpaceApplierFailureSurfaceTests
|
|
{
|
|
// ---------------- T1: RebuildFailed ----------------
|
|
|
|
/// <summary>A rebuild whose sink call throws is reported as attempted-but-failed, not optimistic success.</summary>
|
|
[Fact]
|
|
public void Apply_WhenRebuildThrows_ReportsRebuildFailed()
|
|
{
|
|
var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true };
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var outcome = applier.Apply(RebuildingPlan("eq-1"));
|
|
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
outcome.RebuildFailed.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>A clean apply reports no failure on either channel — the regression guard for the new fields.</summary>
|
|
[Fact]
|
|
public void Apply_HappyPath_ReportsNoFailure()
|
|
{
|
|
var sink = new ConfigurableThrowingSink();
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var outcome = applier.Apply(RebuildingPlan("eq-1"));
|
|
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
outcome.RebuildFailed.ShouldBeFalse();
|
|
outcome.FailedNodes.ShouldBe(0);
|
|
}
|
|
|
|
// ---------------- T2: FailedNodes (removal-pass tally) ----------------
|
|
|
|
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).</summary>
|
|
[Fact]
|
|
public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes()
|
|
{
|
|
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var outcome = applier.Apply(EquipmentRemovalPlan("eq-1"));
|
|
|
|
outcome.RemovedNodes.ShouldBe(1);
|
|
outcome.FailedNodes.ShouldBe(1);
|
|
}
|
|
|
|
// ---------------- T3: Materialise* passes return failed-node counts ----------------
|
|
|
|
/// <summary>Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return.</summary>
|
|
[Fact]
|
|
public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount()
|
|
{
|
|
var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true };
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var composition = CompositionWithTags(
|
|
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
|
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
|
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null));
|
|
|
|
applier.MaterialiseEquipmentTags(composition).ShouldBe(3);
|
|
}
|
|
|
|
/// <summary>All five Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
|
|
[Fact]
|
|
public void Materialise_HappyPath_AllPassesReturnZero()
|
|
{
|
|
var sink = new ConfigurableThrowingSink();
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var tags = CompositionWithTags(
|
|
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
|
var vtags = CompositionWithVirtualTags(
|
|
new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>(), Historize: false));
|
|
var alarms = CompositionWithScriptedAlarms(
|
|
new EquipmentScriptedAlarmPlan("a1", "eq-1", "Alarm", "OffNormalAlarm", 500, "msg", "pred", "true",
|
|
Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true));
|
|
|
|
applier.MaterialiseHierarchy(tags).ShouldBe(0);
|
|
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
|
|
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
|
|
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
|
|
}
|
|
|
|
// ---------------- fixtures ----------------
|
|
|
|
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
|
|
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentTags = tags,
|
|
};
|
|
|
|
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
|
|
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = vtags,
|
|
};
|
|
|
|
private static AddressSpaceComposition CompositionWithScriptedAlarms(params EquipmentScriptedAlarmPlan[] alarms) =>
|
|
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentScriptedAlarms = alarms,
|
|
};
|
|
|
|
// A rebuild-forcing plan: a node-affecting ChangedEquipment delta classifies as Rebuild (R2-07), so
|
|
// these tests exercise the SafeRebuild path (RebuildCalled/RebuildFailed) regardless of the pure-add
|
|
// routing. A pure-add plan would no longer rebuild, so the failure-surface tests must use a change.
|
|
private static AddressSpacePlan RebuildingPlan(string id) => new(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
|
ChangedEquipment: new[]
|
|
{
|
|
new AddressSpacePlan.EquipmentDelta(
|
|
new EquipmentNode(id, id, "line-1"),
|
|
new EquipmentNode(id, id + "-renamed", "line-1")),
|
|
},
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
|
|
|
private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
|
|
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
|
|
|
/// <summary>An <see cref="IOpcUaAddressSpaceSink"/> whose every sink call can be independently made to
|
|
/// throw, so tests can drive each Safe* helper's catch branch. Non-throwing calls are no-ops.</summary>
|
|
private sealed class ConfigurableThrowingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
public bool ThrowOnRebuild { get; init; }
|
|
public bool ThrowOnEnsureVariable { get; init; }
|
|
public bool ThrowOnEnsureFolder { get; init; }
|
|
public bool ThrowOnAlarmWrite { get; init; }
|
|
public bool ThrowOnMaterialiseAlarm { get; init; }
|
|
|
|
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
|
|
|
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
|
|
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
|
{
|
|
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
|
}
|
|
|
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
|
{
|
|
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
|
|
}
|
|
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
|
{
|
|
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
|
|
}
|
|
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
|
{
|
|
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
|
|
}
|
|
|
|
public void RebuildAddressSpace()
|
|
{
|
|
if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault");
|
|
}
|
|
|
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
|
}
|
|
}
|