aaf869145a
Two bundle-review fixes + idempotency coverage: - CRITICAL: the planner ignored EquipmentTags, so an incremental deploy changing only equipment tags produced an empty plan and HandleRebuild short-circuited before materialising them. Add TagId to EquipmentTagPlan + Added/Removed/ChangedEquipmentTags to Phase7Plan (diffed by TagId, in IsEmpty, driving Apply's needsRebuild) — mirroring the GalaxyTags treatment. - IMPORTANT: equipment variable NodeId was the raw driver FullName, which collides across identical machines (e.g. two PLCs both exposing register 40001) — the second variable was silently dropped. NodeId is now folder-scoped (parent/Name); FullName stays on EquipmentTagPlan for the later values-routing milestone. - Task 4: SDK-backed idempotency test (double-apply -> single variable); restart-safety confirmed (RestoreApplied reuses the same RebuildAddressSpace -> HandleRebuild path). - Minor: align composer equipment-tag sort with the artifact decoder (coalesce FolderPath).
421 lines
21 KiB
C#
421 lines
21 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
public sealed class Phase7ApplierTests
|
|
{
|
|
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
|
|
[Fact]
|
|
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var outcome = applier.Apply(EmptyPlan);
|
|
|
|
outcome.RebuildCalled.ShouldBeFalse();
|
|
outcome.AddedNodes.ShouldBe(0);
|
|
outcome.RemovedNodes.ShouldBe(0);
|
|
outcome.ChangedNodes.ShouldBe(0);
|
|
sink.RebuildCalls.ShouldBe(0);
|
|
sink.AlarmWrites.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that removed equipment writes inactive alarm state and triggers rebuild.</summary>
|
|
[Fact]
|
|
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = WithEquipmentRemoval("eq-1", "eq-2");
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RemovedNodes.ShouldBe(2);
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
|
sink.AlarmWrites.All(a => a.Active == false && a.Acknowledged == false).ShouldBeTrue();
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that added equipment triggers rebuild without writing alarm state.</summary>
|
|
[Fact]
|
|
public void Added_equipment_triggers_rebuild_without_alarm_writes()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = new Phase7Plan(
|
|
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
|
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
|
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
outcome.AddedNodes.ShouldBe(1);
|
|
sink.AlarmWrites.ShouldBeEmpty();
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
|
|
[Fact]
|
|
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = new Phase7Plan(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: new[]
|
|
{
|
|
new Phase7Plan.DriverDelta(
|
|
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
|
|
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
|
|
},
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
|
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RebuildCalled.ShouldBeFalse();
|
|
sink.RebuildCalls.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires.</summary>
|
|
[Fact]
|
|
public void Sink_exception_in_WriteAlarmState_does_not_propagate_and_rebuild_still_fires()
|
|
{
|
|
var sink = new ThrowingSink(throwOnAlarmWrite: true);
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = WithEquipmentRemoval("eq-1");
|
|
|
|
var outcome = applier.Apply(plan); // should not throw
|
|
outcome.RemovedNodes.ShouldBe(1);
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies MaterialiseGalaxyTags creates one folder per distinct FolderPath and one
|
|
/// variable per tag, with root-level tags hung directly under the namespace root.</summary>
|
|
[Fact]
|
|
public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
|
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
|
GalaxyTags: new[]
|
|
{
|
|
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
|
|
new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"),
|
|
});
|
|
|
|
applier.MaterialiseGalaxyTags(composition);
|
|
|
|
// One folder for the single distinct non-empty FolderPath; the root-level tag adds none.
|
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area"));
|
|
|
|
// Foldered tag → NodeId is its MxAccessRef under the FolderPath parent.
|
|
// Root-level tag → NodeId is its DisplayName under the root (null parent).
|
|
sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float"));
|
|
sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32"));
|
|
sink.VariableCalls.Count.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>Verifies that two tags sharing a FolderPath produce a single EnsureFolder call
|
|
/// (deduped) but one EnsureVariable per tag.</summary>
|
|
[Fact]
|
|
public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
|
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
|
GalaxyTags: new[]
|
|
{
|
|
new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"),
|
|
new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"),
|
|
});
|
|
|
|
applier.MaterialiseGalaxyTags(composition);
|
|
|
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell"));
|
|
sink.VariableCalls.Count.ShouldBe(2);
|
|
sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float"));
|
|
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
|
|
}
|
|
|
|
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
|
|
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
|
|
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
|
|
/// folder (decision #4).</summary>
|
|
[Fact]
|
|
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
|
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
|
GalaxyTags: Array.Empty<GalaxyTagPlan>())
|
|
{
|
|
EquipmentTags = new[]
|
|
{
|
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
|
},
|
|
};
|
|
|
|
applier.MaterialiseEquipmentTags(composition);
|
|
|
|
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
|
|
}
|
|
|
|
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
|
/// folder (not the namespace root), with the variable parented to that sub-folder and a
|
|
/// folder-scoped NodeId.</summary>
|
|
[Fact]
|
|
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentTags = new[]
|
|
{
|
|
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
|
|
},
|
|
};
|
|
|
|
applier.MaterialiseEquipmentTags(composition);
|
|
|
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
|
|
}
|
|
|
|
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
|
/// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
|
|
/// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.</summary>
|
|
[Fact]
|
|
public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var composition = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentTags = new[]
|
|
{
|
|
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
|
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
|
},
|
|
};
|
|
|
|
applier.MaterialiseEquipmentTags(composition);
|
|
|
|
sink.VariableCalls.Count.ShouldBe(2);
|
|
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float"));
|
|
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
|
|
}
|
|
|
|
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
|
|
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
|
|
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
|
|
[Fact]
|
|
public void Added_equipment_tags_trigger_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = EmptyPlan with
|
|
{
|
|
AddedEquipmentTags = new[]
|
|
{
|
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
|
},
|
|
};
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
outcome.AddedNodes.ShouldBe(1);
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
|
|
[Fact]
|
|
public void Added_galaxy_tags_trigger_rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
var plan = new Phase7Plan(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
|
AddedGalaxyTags: new[]
|
|
{
|
|
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
|
|
},
|
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
outcome.RebuildCalled.ShouldBeTrue();
|
|
outcome.AddedNodes.ShouldBe(1);
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
private static Phase7Plan EmptyPlan => new(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
|
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>(),
|
|
Array.Empty<GalaxyTagPlan>(), Array.Empty<GalaxyTagPlan>(), Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
|
|
|
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
|
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
|
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
|
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
|
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
|
|
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
/// <summary>Gets the queue of alarm state write calls.</summary>
|
|
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
|
/// <summary>Gets the queue of folder creation calls.</summary>
|
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
|
/// <summary>Gets the queue of variable creation calls.</summary>
|
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
|
|
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
|
public int RebuildCalls;
|
|
|
|
/// <summary>Gets the list of recorded alarm writes.</summary>
|
|
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
|
/// <summary>Gets the list of recorded folder creation calls.</summary>
|
|
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
|
/// <summary>Gets the list of recorded variable creation calls.</summary>
|
|
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
|
|
|
|
/// <summary>Records a value write (no-op in this recording sink).</summary>
|
|
/// <param name="nodeId">The node ID.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="quality">The OPC UA quality.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
|
/// <summary>Records an alarm state write call.</summary>
|
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
|
/// <param name="active">Whether the alarm is active.</param>
|
|
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
|
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
|
/// <summary>Records a folder creation call.</summary>
|
|
/// <param name="folderNodeId">The folder node ID.</param>
|
|
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
|
/// <param name="displayName">The display name for the folder.</param>
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
|
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
|
/// <summary>Records a variable creation call.</summary>
|
|
/// <param name="variableNodeId">The variable node ID.</param>
|
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
|
/// <param name="displayName">The display name for the variable.</param>
|
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
|
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
|
|
/// <summary>Records a rebuild address space call.</summary>
|
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
|
}
|
|
|
|
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
private readonly bool _throwOnAlarmWrite;
|
|
/// <summary>Initializes a new instance of the ThrowingSink class.</summary>
|
|
/// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
|
|
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
|
|
|
|
/// <summary>Records a value write (no-op in this sink).</summary>
|
|
/// <param name="nodeId">The node ID.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="quality">The OPC UA quality.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
|
/// <summary>Throws an exception if configured to do so.</summary>
|
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
|
/// <param name="active">Whether the alarm is active.</param>
|
|
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
|
{
|
|
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
|
}
|
|
/// <summary>No-op folder creation call.</summary>
|
|
/// <param name="folderNodeId">The folder node ID.</param>
|
|
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
|
/// <param name="displayName">The display name for the folder.</param>
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
|
/// <summary>No-op variable creation call.</summary>
|
|
/// <param name="variableNodeId">The variable node ID.</param>
|
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
|
/// <param name="displayName">The display name for the variable.</param>
|
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
|
/// <summary>No-op rebuild address space call.</summary>
|
|
public void RebuildAddressSpace() { }
|
|
}
|
|
}
|