/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.MachineTool.Client;
using Opc.Ua.MachineTool.Server;
using Opc.Ua.Tests;
using MonitoredItemOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
using SubscriptionOptions = Opc.Ua.Client.Subscriptions.SubscriptionOptions;
namespace Opc.Ua.MachineTool.Tests
{
///
/// Exercises the actual OPC 40501-1 notifier and modern lifetime-counter conformance surfaces.
///
[TestFixture]
[NonParallelizable]
[Category("MachineTool")]
[Category("Integration")]
public sealed class MachineToolConformanceSurfaceTests
{
///
/// Every required notifier in a two-job, four-program, four-part plan delivers a provider transition
/// when monitored directly, not merely through the machine root or a server-side callback.
///
[TestCase("ProductionPlan", 0, 0)]
[TestCase("Job", 0, 0)]
[TestCase("Job", 1, 0)]
[TestCase("Job.State", 0, 0)]
[TestCase("Job.State", 1, 0)]
[TestCase("ActiveProgram.State", 0, 0)]
[TestCase("Program.State", 0, 0)]
[TestCase("Program.State", 0, 1)]
[TestCase("Program.State", 1, 0)]
[TestCase("Program.State", 1, 1)]
[TestCase("Part.State", 0, 0)]
[TestCase("Part.State", 0, 1)]
[TestCase("Part.State", 1, 0)]
[TestCase("Part.State", 1, 1)]
public async Task ProductionNotifiersDeliverProviderTransitionsDirectlyAsync(
string surface, int jobIndex, int memberIndex)
{
await using var fixture = new MachineToolRuntimeFixture();
await fixture.StartAsync().ConfigureAwait(false);
var initialSample = new MachineToolSampleInfo(
new DateTimeUtc(2026, 10, 11, 8, 9, 10), StatusCodes.Good);
var finalSample = new MachineToolSampleInfo(
new DateTimeUtc(2026, 10, 11, 8, 10, 11), StatusCodes.UncertainLastUsableValue);
var provider = new InMemoryMachineToolProvider(ProductionSnapshot(false, initialSample));
MachineToolSnapshot finished = ProductionSnapshot(true, finalSample);
await using MachineToolRuntime runtime = await Builder(
fixture, provider, MachineToolFeatures.None, "DirectProduction")
.WithProduction(new MachineToolProductionOptions
{
Features = MachineToolFeatures.Production | MachineToolFeatures.ProductionParts
}).BuildAsync().ConfigureAwait(false);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
await using var transport = new ClientFixture(telemetry);
transport.UseSubscriptionEngineFactory(DefaultSubscriptionEngineFactory.Instance);
await transport.LoadClientConfigurationAsync(fixture.Root, "ProductionConformance").ConfigureAwait(false);
using ISession session = await transport.ConnectAsync(fixture.Endpoint, SecurityPolicies.None)
.ConfigureAwait(false);
try
{
await using MachineToolClient client = session.CreateMachineToolClient(telemetry);
MachineToolProductionReadSnapshot before = await client.ReadProductionAsync(runtime.NodeId)
.ConfigureAwait(false);
Assert.That(before.Jobs.Count, Is.EqualTo(2));
foreach (MachineToolJobReadSnapshot publishedJob in before.Jobs)
{
Assert.That(publishedJob.Programs.Count, Is.EqualTo(2));
Assert.That(publishedJob.PartSets.Count, Is.EqualTo(1));
Assert.That(publishedJob.PartSets[0].Parts.Count, Is.EqualTo(2));
}
MachineToolJobReadSnapshot job = before.Jobs[jobIndex];
NodeId notifier;
NodeId[] sources;
NodeId eventType;
EventFilter filter;
var registry = new EventRecordDecoderRegistry();
switch (surface)
{
case "ProductionPlan":
case "Job":
case "Job.State":
notifier = surface == "ProductionPlan" ? before.ProductionPlanId :
surface == "Job" ? job.NodeId : job.State.NodeId;
sources = surface == "ProductionPlan"
? before.Jobs.ToArray()!.Select(value => value.State.NodeId).ToArray()
: [job.State.NodeId];
eventType = ExpandedNodeId.ToNodeId(
ObjectTypeIds.ProductionJobTransitionEventType, session.NamespaceUris);
registry.Register(eventType,
ProductionJobTransitionEventTypeRecord.Decoder.GetStandardFields(session.NamespaceUris),
ProductionJobTransitionEventTypeRecord.Decoder.Decode);
filter = ProductionJobTransitionEventTypeRecord.EventFilters.Build(
session.NamespaceUris, registry);
break;
case "ActiveProgram.State":
case "Program.State":
notifier = surface == "ActiveProgram.State"
? before.ActiveProgram.State!.NodeId : job.Programs[memberIndex].State!.NodeId;
sources = [notifier];
eventType = ExpandedNodeId.ToNodeId(
ObjectTypeIds.ProductionProgramTransitionEventType, session.NamespaceUris);
registry.Register(eventType,
ProductionProgramTransitionEventTypeRecord.Decoder.GetStandardFields(session.NamespaceUris),
ProductionProgramTransitionEventTypeRecord.Decoder.Decode);
filter = ProductionProgramTransitionEventTypeRecord.EventFilters.Build(
session.NamespaceUris, registry);
break;
case "Part.State":
notifier = job.PartSets[0].Parts[memberIndex].State.NodeId;
sources = [notifier];
eventType = ExpandedNodeId.ToNodeId(
ObjectTypeIds.ProductionPartTransitionEventType, session.NamespaceUris);
registry.Register(eventType,
ProductionPartTransitionEventTypeRecord.Decoder.GetStandardFields(session.NamespaceUris),
ProductionPartTransitionEventTypeRecord.Decoder.Decode);
filter = ProductionPartTransitionEventTypeRecord.EventFilters.Build(
session.NamespaceUris, registry);
break;
default:
throw new ArgumentOutOfRangeException(nameof(surface));
}
await AssertNotifierAsync(session, notifier).ConfigureAwait(false);
Assert.That(notifier, Is.Not.EqualTo(runtime.NodeId));
Assert.That(session.TryGetSubscriptionManager(out ISubscriptionManager? manager), Is.True);
await using var streaming = new StreamingSubscription(manager!,
new SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(25) });
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using IAsyncEnumerator events = streaming.SubscribeEventsWhenReadyAsync(
notifier, filter, async ct =>
{
await provider.SetSnapshotAsync(finished, ct).ConfigureAwait(false);
await runtime.RefreshAsync(ct).ConfigureAwait(false);
},
new MonitoredItemOptions { QueueSize = 16, DiscardOldest = false },
ct: timeout.Token).GetAsyncEnumerator(timeout.Token);
var receivedSources = new HashSet();
for (int index = 0; index < sources.Length; index++)
{
Assert.That(await events.MoveNextAsync().ConfigureAwait(false), Is.True, surface);
Assert.That(events.Current.Fields.Count, Is.EqualTo(registry.StandardFields.Length));
var record = registry.DecodeAs(
eventType, events.Current.Fields.ToArray()!) as TransitionEventTypeRecord;
Assert.That(record, Is.Not.Null);
int sourceIndex = Array.IndexOf(sources, record!.SourceNode);
Assert.That(sourceIndex, Is.GreaterThanOrEqualTo(0), surface);
Assert.That(receivedSources.Add(record.SourceNode), Is.True,
"Duplicate delivery must not stand in for another required source.");
Assert.That(record.FromState, Is.EqualTo(new LocalizedText("Running")));
Assert.That(record.ToState, Is.EqualTo(new LocalizedText("Ended")));
Assert.That(record.Transition, Is.EqualTo(new LocalizedText("RunningToEnded")));
Assert.That(record.Message, Is.EqualTo(new LocalizedText("Running to Ended")));
Assert.That(record.Time, Is.EqualTo(finalSample.SourceTimestamp));
Assert.That(record.EventType, Is.Not.EqualTo(eventType),
"The abstract released event declaration must not be instantiated.");
Assert.That(await session.NodeCache.IsTypeOfAsync(
record.EventType, eventType, timeout.Token).ConfigureAwait(false), Is.True);
MachineToolProductionJob expected = finished.Production.Jobs[
surface == "ProductionPlan" ? sourceIndex : jobIndex];
switch (record)
{
case ProductionJobTransitionEventTypeRecord transition:
Assert.That(transition.Identifier, Is.EqualTo(expected.Identifier));
Assert.That(transition.CustomerOrderIdentifier, Is.EqualTo(expected.CustomerOrderIdentifier));
Assert.That(transition.RunsCompleted, Is.EqualTo(1u));
Assert.That(transition.RunsPlanned, Is.EqualTo(1u));
break;
case ProductionProgramTransitionEventTypeRecord transition:
Assert.That(transition.JobIdentifier, Is.EqualTo(expected.Identifier));
Assert.That(transition.Name, Is.EqualTo(expected.Programs[memberIndex].Name));
break;
case ProductionPartTransitionEventTypeRecord transition:
MachineToolProductionPart part = expected.PartSets[0].Parts[memberIndex];
Assert.That(transition.JobIdentifier, Is.EqualTo(expected.Identifier));
Assert.That(transition.Identifier, Is.EqualTo(part.Identifier));
Assert.That(transition.Name, Is.EqualTo(part.Name));
break;
default:
Assert.Fail("The generated decoder did not return a concrete production transition.");
break;
}
}
Assert.That(receivedSources, Is.EquivalentTo(sources));
Assert.That(streaming.DroppedNotificationCount, Is.Zero);
MachineToolProductionReadSnapshot after = await client.ReadProductionAsync(runtime.NodeId)
.ConfigureAwait(false);
Assert.That(after.Jobs[jobIndex].State.CurrentState.WrappedValue.TryGetValue(
out LocalizedText currentState), Is.True);
Assert.That(currentState.Text, Is.EqualTo("Ended"));
Assert.That(after.Jobs[jobIndex].State.CurrentState.StatusCode, Is.EqualTo(finalSample.StatusCode));
Assert.That(after.Jobs[jobIndex].State.CurrentState.SourceTimestamp,
Is.EqualTo(finalSample.SourceTimestamp));
Assert.That(after.Jobs[jobIndex].RunsCompleted.WrappedValue.TryGetValue(out uint runs), Is.True);
Assert.That(runs, Is.EqualTo(1u));
Assert.That(after.Jobs[jobIndex].PartsGood.WrappedValue.TryGetValue(out uint good), Is.True);
Assert.That(good, Is.EqualTo(2u));
}
finally
{
await session.CloseAsync(CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Messages itself accepts a real monitored item and delivers the provider's concrete message or alert,
/// including occurrence time and source identity, without substituting the machine-root event helper.
///
[TestCase(false)]
[TestCase(true)]
public async Task MessagesNotifierDeliversProviderEventsDirectlyAsync(bool condition)
{
await using var fixture = new MachineToolRuntimeFixture();
await fixture.StartAsync().ConfigureAwait(false);
var provider = new MachineToolNotificationTests.NotificationProvider(new MachineToolNotificationSnapshot());
await using MachineToolRuntime runtime =
await MachineToolNotificationTests.BuildAsync(fixture, provider, "DirectMessages").ConfigureAwait(false);
var sample = new MachineToolSampleInfo(
new DateTimeUtc(2026, 10, 11, 9, 10, 11), StatusCodes.UncertainLastUsableValue);
var alert = new MachineToolAlert
{
Key = "Coolant",
ErrorCode = "E-DIRECT",
Message = new LocalizedText("Coolant empty"),
Severity = 800,
IsActive = true,
Sample = sample
};
var message = new MachineToolMessage
{
Sequence = 1,
Identifier = "M-DIRECT",
Message = new LocalizedText("Program released"),
Severity = 200,
Time = sample.SourceTimestamp
};
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
await using var transport = new ClientFixture(telemetry);
transport.UseSubscriptionEngineFactory(DefaultSubscriptionEngineFactory.Instance);
await transport.LoadClientConfigurationAsync(fixture.Root, "MessagesConformance").ConfigureAwait(false);
using ISession session = await transport.ConnectAsync(fixture.Endpoint, SecurityPolicies.None)
.ConfigureAwait(false);
try
{
var machine = new MachineToolTypeClient(session, runtime.NodeId, telemetry);
NotificationTypeClient? notification = await machine.GetNotificationAsync(telemetry)
.ConfigureAwait(false);
Assert.That(notification, Is.Not.Null);
MessagesTypeClient? messages = await notification!.GetMessagesAsync(telemetry).ConfigureAwait(false);
Assert.That(messages, Is.Not.Null);
Assert.That(messages!.ObjectId, Is.Not.EqualTo(runtime.NodeId));
await AssertNotifierAsync(session, messages.ObjectId).ConfigureAwait(false);
Assert.That(session.TryGetSubscriptionManager(out ISubscriptionManager? manager), Is.True);
await using var streaming = new StreamingSubscription(manager!,
new SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(25) });
NodeId declaration = ExpandedNodeId.ToNodeId(
condition ? ObjectTypeIds.AlertType : ObjectTypeIds.NotificationEventType, session.NamespaceUris);
var registry = new EventRecordDecoderRegistry();
EventFilter filter;
if (condition)
{
registry.Register(declaration, AlertTypeRecord.Decoder.GetStandardFields(session.NamespaceUris),
AlertTypeRecord.Decoder.Decode);
filter = AlertTypeRecord.EventFilters.Build(session.NamespaceUris, registry);
}
else
{
registry.Register(declaration,
NotificationEventTypeRecord.Decoder.GetStandardFields(session.NamespaceUris),
NotificationEventTypeRecord.Decoder.Decode);
filter = NotificationEventTypeRecord.EventFilters.Build(session.NamespaceUris, registry);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using IAsyncEnumerator events = streaming.SubscribeEventsWhenReadyAsync(
messages.ObjectId, filter, async ct =>
{
provider.Snapshot = new MachineToolNotificationSnapshot
{
Revision = 1,
Messages = [message],
Alerts = [alert]
};
await runtime.RefreshNotificationsAsync(ct).ConfigureAwait(false);
},
new MonitoredItemOptions { QueueSize = 8, DiscardOldest = false },
ct: timeout.Token).GetAsyncEnumerator(timeout.Token);
Assert.That(await events.MoveNextAsync().ConfigureAwait(false), Is.True);
Assert.That(events.Current.Fields.Count, Is.EqualTo(registry.StandardFields.Length));
var record = registry.DecodeAs(
declaration, events.Current.Fields.ToArray()!) as BaseEventTypeRecord;
Assert.That(record, Is.Not.Null);
Assert.That(record!.SourceNode, Is.EqualTo(messages.ObjectId));
Assert.That(record.SourceName, Is.EqualTo("DirectMessages"));
Assert.That(record.Time, Is.EqualTo(sample.SourceTimestamp));
if (condition)
{
Assert.That(record, Is.TypeOf());
var received = (AlertTypeRecord)record;
Assert.That(received.ErrorCode, Is.EqualTo("E-DIRECT"));
Assert.That(received.Message, Is.EqualTo(alert.Message));
Assert.That(received.Severity, Is.EqualTo(800));
Assert.That(received.Quality, Is.EqualTo(sample.StatusCode));
Assert.That(received.ActiveStateId, Is.True);
Assert.That(received.AckedStateId, Is.False);
Assert.That(received.Retain, Is.True);
Assert.That(runtime.Notifications!.TryGetAlert(alert.Key, out AlertState? state), Is.True);
Assert.That(received.ConditionId, Is.EqualTo(state!.NodeId));
Assert.That(state.AckedState!.Id!.Value, Is.False,
"A direct passive event subscription must not acknowledge the retained condition.");
}
else
{
Assert.That(record, Is.TypeOf());
var received = (NotificationEventTypeRecord)record;
Assert.That(received.Identifier, Is.EqualTo("M-DIRECT"));
Assert.That(received.Message, Is.EqualTo(message.Message));
Assert.That(received.Severity, Is.EqualTo(200));
Assert.That(received.EventType, Is.Not.EqualTo(declaration));
Assert.That(await session.NodeCache.IsTypeOfAsync(
received.EventType, declaration, timeout.Token).ConfigureAwait(false), Is.True);
}
Assert.That(streaming.DroppedNotificationCount, Is.Zero);
}
finally
{
await session.CloseAsync(CancellationToken.None).ConfigureAwait(false);
}
}
///
/// The modern Machinery operation-counter component is UInt64 on the wire, retains independent
/// acquisition metadata and updates across the UInt32 boundary without relying on legacy statistics.
///
[Test]
public async Task LifetimePartsUseModernUInt64ComponentAndPreserveUpdatedWireSamplesAsync()
{
await using var fixture = new MachineToolRuntimeFixture();
await fixture.StartAsync().ConfigureAwait(false);
var initialSample = new MachineToolSampleInfo(
new DateTimeUtc(2026, 10, 11, 10, 11, 12), StatusCodes.UncertainLastUsableValue);
MachineToolSnapshot initial = CounterSnapshot(412, 9_000_123, initialSample);
var provider = new InMemoryMachineToolProvider(initial);
await using MachineToolRuntime runtime = await Builder(
fixture, provider,
MachineToolFeatures.OperationCounters | MachineToolFeatures.PartsProducedInLifetime,
"ModernLifetime").BuildAsync().ConfigureAwait(false);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
await using var transport = new ClientFixture(telemetry);
await transport.LoadClientConfigurationAsync(fixture.Root, "LifetimeConformance").ConfigureAwait(false);
using ISession session = await transport.ConnectAsync(fixture.Endpoint, SecurityPolicies.None)
.ConfigureAwait(false);
try
{
MachineOperationMonitoringTypeClient operation = await OperationAsync(
session, runtime.NodeId, telemetry).ConfigureAwait(false);
var counters = await operation.GetOperationCountersAsync(telemetry).ConfigureAwait(false);
Assert.That(counters, Is.Not.Null);
DataValue counterName = await ReadAttributeAsync(session, counters!.ObjectId, Attributes.BrowseName)
.ConfigureAwait(false);
Assert.That(counterName.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(counterName.WrappedValue.TryGetValue(out QualifiedName publishedName), Is.True);
Assert.That(publishedName, Is.EqualTo(new QualifiedName("OperationCounters",
(ushort)session.NamespaceUris.GetIndex(Opc.Ua.Di.Namespaces.OpcUaDi))));
ArrayOf components = await ComponentsAsync(session, counters.ObjectId)
.ConfigureAwait(false);
var canonicalName = new QualifiedName(BrowseNames.PartsProducedInLifetime,
(ushort)session.NamespaceUris.GetIndex(Namespaces.MachineTool));
ReferenceDescription[] matches = components.ToArray()!
.Where(reference => reference.BrowseName == canonicalName).ToArray();
Assert.That(matches, Has.Length.EqualTo(1));
Assert.That(matches[0].ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.HasComponent));
Assert.That(matches[0].NodeClass, Is.EqualTo(NodeClass.Variable));
NodeId counterId = ExpandedNodeId.ToNodeId(matches[0].NodeId, session.NamespaceUris);
DataValue dataType = await ReadAttributeAsync(session, counterId, Attributes.DataType)
.ConfigureAwait(false);
Assert.That(dataType.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(dataType.WrappedValue.TryGetValue(out NodeId declaredType), Is.True);
Assert.That(declaredType, Is.EqualTo(Opc.Ua.DataTypeIds.UInt64));
AssertLifetimeSample(await ReadAttributeAsync(session, counterId, Attributes.Value)
.ConfigureAwait(false), 412, initialSample);
await using MachineToolClient client = session.CreateMachineToolClient(telemetry);
MachineToolProductionReadSnapshot production = await client.ReadProductionAsync(runtime.NodeId)
.ConfigureAwait(false);
Assert.That(production.PartsProducedInLifetime.IsNull, Is.True,
"The deprecated UInt32 Production.Statistics counter must not satisfy this modern CU.");
const ulong updatedParts = (ulong)uint.MaxValue + 41;
var updatedSample = new MachineToolSampleInfo(
new DateTimeUtc(2026, 10, 11, 10, 12, 13), StatusCodes.UncertainDataSubNormal);
await provider.SetSnapshotAsync(CounterSnapshot(updatedParts, 9_060_456, updatedSample))
.ConfigureAwait(false);
await runtime.RefreshAsync().ConfigureAwait(false);
DataValue updated = await ReadAttributeAsync(session, counterId, Attributes.Value)
.ConfigureAwait(false);
AssertLifetimeSample(updated, updatedParts, updatedSample);
Assert.That(updated.WrappedValue.TryGetValue(out ulong actualParts), Is.True);
Assert.That(actualParts, Is.GreaterThan((ulong)uint.MaxValue));
ArrayOf refreshed = await ComponentsAsync(session, counters.ObjectId)
.ConfigureAwait(false);
Assert.That(ExpandedNodeId.ToNodeId(refreshed.ToArray()!
.Single(reference => reference.BrowseName == canonicalName).NodeId, session.NamespaceUris),
Is.EqualTo(counterId), "A counter update must retain the published component identity.");
await AssertPowerOnSampleAsync(session, counters.ObjectId, 9_060_456, updatedSample)
.ConfigureAwait(false);
}
finally
{
await session.CloseAsync(CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Provider data cannot implicitly select the lifetime-parts feature, with or without its counter parent.
/// The selected neighboring observations remain available with the equipment's quality and timestamp.
///
[TestCase(false)]
[TestCase(true)]
public async Task LifetimePartsRemainAbsentWhenFeatureNotSelectedAsync(bool operationCounters)
{
await using var fixture = new MachineToolRuntimeFixture();
await fixture.StartAsync().ConfigureAwait(false);
var sample = new MachineToolSampleInfo(
new DateTimeUtc(2026, 10, 11, 11, 12, 13), StatusCodes.UncertainLastUsableValue);
var provider = new InMemoryMachineToolProvider(
CounterSnapshot((ulong)uint.MaxValue + 41, 9_000_123, sample));
await using MachineToolRuntime runtime = await Builder(
fixture, provider,
operationCounters ? MachineToolFeatures.OperationCounters : MachineToolFeatures.None,
"UnselectedLifetime").BuildAsync().ConfigureAwait(false);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
await using var transport = new ClientFixture(telemetry);
await transport.LoadClientConfigurationAsync(fixture.Root, "LifetimeAbsence").ConfigureAwait(false);
using ISession session = await transport.ConnectAsync(fixture.Endpoint, SecurityPolicies.None)
.ConfigureAwait(false);
try
{
MachineOperationMonitoringTypeClient operation = await OperationAsync(
session, runtime.NodeId, telemetry).ConfigureAwait(false);
var counters = await operation.GetOperationCountersAsync(telemetry).ConfigureAwait(false);
Assert.That(counters == null, Is.EqualTo(!operationCounters));
ArrayOf children = await ComponentsAsync(
session, counters?.ObjectId ?? operation.ObjectId).ConfigureAwait(false);
Assert.That(children.ToArray()!.Any(reference => reference.BrowseName.Name ==
BrowseNames.PartsProducedInLifetime), Is.False);
if (operationCounters)
{
await AssertPowerOnSampleAsync(session, counters!.ObjectId, 9_000_123, sample)
.ConfigureAwait(false);
}
await using MachineToolClient client = session.CreateMachineToolClient(telemetry);
MachineToolReadSnapshot read = await client.ReadSnapshotAsync(runtime.NodeId).ConfigureAwait(false);
Assert.That(read.OperationMode.WrappedValue.TryGetValue(out int mode), Is.True);
Assert.That(mode, Is.EqualTo((int)MachineOperationMode.Automatic));
Assert.That(read.OperationMode.StatusCode, Is.EqualTo(sample.StatusCode));
Assert.That(read.OperationMode.SourceTimestamp, Is.EqualTo(sample.SourceTimestamp));
}
finally
{
await session.CloseAsync(CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Uses the public provider builder and only the explicitly selected optional features.
///
private static MachineToolBuilder Builder(
MachineToolRuntimeFixture fixture, IMachineToolProvider provider, MachineToolFeatures features, string name)
{
return fixture.Context.AddMachineTool(name).WithProvider(provider).WithFeatures(features)
.WithIdentification(identity =>
{
identity.Manufacturer = new LocalizedText("Conformance surface tests");
identity.SerialNumber = name;
identity.ProductInstanceUri = "urn:conformance-surface:" + name;
});
}
///
/// Supplies distinct jobs, programs and workpieces, with coherent completed-run and part counts.
///
private static MachineToolSnapshot ProductionSnapshot(bool completed, MachineToolSampleInfo sample)
{
var jobs = new MachineToolProductionJob[2];
MachineToolProductionPhase phase = completed
? MachineToolProductionPhase.Ended : MachineToolProductionPhase.Running;
for (int jobIndex = 0; jobIndex < jobs.Length; jobIndex++)
{
string identifier = jobIndex == 0 ? "Order-A" : "Order-B";
var programs = new MachineToolProgram[2];
var parts = new MachineToolProductionPart[2];
for (int memberIndex = 0; memberIndex < programs.Length; memberIndex++)
{
programs[memberIndex] = new MachineToolProgram
{
Name = $"{identifier}-P{memberIndex}.NC",
JobIdentifier = identifier,
Phase = phase,
TransitionSequence = completed ? 1u : 0u,
Sample = sample
};
parts[memberIndex] = new MachineToolProductionPart
{
Key = $"Part-{memberIndex}",
Name = $"{identifier}-Housing-{memberIndex}",
Identifier = $"{identifier}-H{memberIndex}",
Phase = phase,
TransitionSequence = completed ? 1u : 0u,
Quality = completed ? PartQuality.Good : PartQuality.NotYetMeasured,
Sample = sample
};
}
jobs[jobIndex] = new MachineToolProductionJob
{
Identifier = identifier,
CustomerOrderIdentifier = "Customer-" + identifier,
Phase = phase,
TransitionSequence = completed ? 1u : 0u,
RunsPlanned = 1,
RunsPlannedIsValid = true,
RunsCompleted = completed ? 1u : 0u,
PartsCompleted = completed ? 2u : 0u,
PartsGood = completed ? 2u : 0u,
Programs = programs,
PartSets =
[
new MachineToolProductionPartSet
{
Key = "Lot",
Name = "Housings",
PartsPlannedPerRun = 2,
PartsCompletedPerRun = completed ? 2u : 0u,
Parts = parts,
Sample = sample
}
],
Sample = sample
};
}
return new MachineToolSnapshot
{
ActiveProgram = jobs[0].Programs[0],
Production = new MachineToolProductionObservation { Jobs = jobs }
};
}
///
/// Supplies the modern UInt64 observation without enabling or populating deprecated statistics.
///
private static MachineToolSnapshot CounterSnapshot(
ulong parts, double milliseconds, MachineToolSampleInfo sample) => new()
{
Operation = new MachineToolOperation
{
Mode = MachineOperationMode.Automatic,
PartsProducedInLifetime = parts,
PowerOnDurationMilliseconds = milliseconds,
Sample = sample
}
};
///
/// Reads the actual attribute through the connected session, never from NodeState.EventNotifier.
///
private static async ValueTask AssertNotifierAsync(ISession session, NodeId node)
{
DataValue attribute = await ReadAttributeAsync(session, node, Attributes.EventNotifier)
.ConfigureAwait(false);
Assert.That(attribute.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(attribute.WrappedValue.TryGetValue(out byte notifier), Is.True);
Assert.That(notifier & EventNotifiers.SubscribeToEvents, Is.EqualTo(EventNotifiers.SubscribeToEvents));
}
///
/// Preserves the raw DataValue, including uncertain quality, rather than using a value-only convenience read.
///
private static async ValueTask ReadAttributeAsync(ISession session, NodeId node, uint attribute)
{
ReadResponse response = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
[new ReadValueId { NodeId = node, AttributeId = attribute }], CancellationToken.None)
.ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(1));
return response.Results[0];
}
///
/// Navigates the canonical Monitoring/MachineTool path using generated typed proxies.
///
private static async ValueTask OperationAsync(
ISession session, NodeId machineId, ITelemetryContext telemetry)
{
var machine = new MachineToolTypeClient(session, machineId, telemetry);
MonitoringTypeClient? monitoring = await machine.GetMonitoringAsync(telemetry).ConfigureAwait(false);
Assert.That(monitoring, Is.Not.Null);
MachineOperationMonitoringTypeClient? operation = await monitoring!.GetMachineToolAsync(telemetry)
.ConfigureAwait(false);
Assert.That(operation, Is.Not.Null);
return operation!;
}
///
/// Uses the existing browser to enumerate exact HasComponent references, including continuation handling.
///
private static ValueTask> ComponentsAsync(ISession session, NodeId parent)
{
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
IncludeSubtypes = false,
ResultMask = (uint)BrowseResultMask.All,
ContinueUntilDone = true
};
return browser.BrowseAsync(parent);
}
///
/// Asserts both UInt64 decoding and original acquisition metadata for a lifetime observation.
///
private static void AssertLifetimeSample(DataValue value, ulong expected, MachineToolSampleInfo sample)
{
Assert.That(value.WrappedValue.TryGetValue(out ulong actual), Is.True);
Assert.That(actual, Is.EqualTo(expected));
Assert.That(value.StatusCode, Is.EqualTo(sample.StatusCode));
Assert.That(value.SourceTimestamp, Is.EqualTo(sample.SourceTimestamp));
}
///
/// Checks a second, independently typed observation under the same modern counter building block.
///
private static async ValueTask AssertPowerOnSampleAsync(
ISession session, NodeId counters, double expected, MachineToolSampleInfo sample)
{
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
IncludeSubtypes = false,
ResultMask = (uint)BrowseResultMask.All,
ContinueUntilDone = true
};
ArrayOf children = await browser.BrowseAsync(counters).ConfigureAwait(false);
var name = new QualifiedName(BrowseNames.PowerOnDuration,
(ushort)session.NamespaceUris.GetIndex(Opc.Ua.Di.Namespaces.OpcUaDi));
ReferenceDescription reference = children.ToArray()!.Single(child => child.BrowseName == name);
Assert.That(reference.ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.HasProperty));
DataValue value = await ReadAttributeAsync(
session, ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris), Attributes.Value)
.ConfigureAwait(false);
Assert.That(value.WrappedValue.TryGetValue(out double actual), Is.True);
Assert.That(actual, Is.EqualTo(expected));
Assert.That(value.StatusCode, Is.EqualTo(sample.StatusCode));
Assert.That(value.SourceTimestamp, Is.EqualTo(sample.SourceTimestamp));
}
}
}