Files
UA-.NETStandard/tests/Opc.Ua.MachineTool.Tests/MachineToolConformanceSurfaceTests.cs
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists
companions using the existing Machinery/MachineVision fluent, DI and
typed-client approach. **The PR is open/non-draft by user choice.
Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and
has 108 successful checks, six intentionally skipped checks, zero
failures and zero pending checks. All five primary workflows completed
successfully at that exact head.**

| Family | Released specification/model | Packaging |
| --- | --- | --- |
| MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP;
normal root versions |
| WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire
namespace and independent WMTP-only hosting |
| GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model,
Server, Client and MCP; normal root versions |
| CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all
four plus Debug variants forced preview |

There are **12 new packable projects**. No separate WMTP
package/profile/guide, root version bump, tag, release or package
publication is included. Mandatory instance structure is the default;
selected optional features require explicit providers. The
implementation reuses Machinery results/jobs/files/ProcessValues and
Robotics topology, including retained MachineTool Production, defined
GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and
isolated instances alongside MachineVision.

Runtime and integration fixes preserve raw quality/timestamps, coherent
acquisition, namespace ownership, accepted-configuration state,
borrowed-resource lifetime, session/node/file cleanup, terminal-value
publication, event propagation and endpoint-aware capability
publication. Firmware/licence transfer means provider acceptance, not
installation/activation. Cancellation does not silently abort another
caller's operation. The later upstream MCP expansion is integrated
without losing either family set or its default-off privileged/live
execution policy.

### Conformance and scope

Canonical model inputs are pinned to UA-Nodeset revision
`96a08945425d0295edb3607d2a38f6841ef44046`.

**GMS upstream exception:** [Annex
A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes
the released NodeSet normative, but §12.3.2.7 requires undefined
`GMS_ProductionStatisticsType`. Inherited statistics and defined
jobs/parts are implemented without inventing a standard type/NodeId; the
complete GMS Production facet is withheld.

The requirement ledger records **102 positively evidenced CU rows, 96
directly negative-tested rows and six explicit negative-evidence
limitations**. All 648 test-class/name/TRX links were independently
resolved to passing cases. The six limitations concern Stacklight,
retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit,
Obligation and MaintenanceMode. They are not claims that every
invalid-input or hardware-control behavior was tested. The undefined GMS
requirement is outside the 102 defined CUs. No external certification
claim is made.

Retained baseline complete-assembly line coverage, with generated code
retained and no source exclusions:

| Family | Model | Server | Client |
| --- | ---: | ---: | ---: |
| MachineTool/WMTP | 83.80% | 91.98% | 84.74% |
| GMS | 95.51% | 90.63% | 88.38% |
| CranesHoists | 92.76% | 94.45% | 92.24% |

These nine measurements predate the later focused review fixes and
merges; they are not relabelled as new `ecc4a7d6c` percentages. The
current head's separate CI code-coverage check succeeded. No new
exclusions or threshold relaxations were introduced.

## Related Issues

- Fixes
[#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764),
the implementation/design and acceptance tracker.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-15 15:15:46 +02:00

693 lines
40 KiB
C#

/* ========================================================================
* 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
{
/// <summary>
/// Exercises the actual OPC 40501-1 notifier and modern lifetime-counter conformance surfaces.
/// </summary>
[TestFixture]
[NonParallelizable]
[Category("MachineTool")]
[Category("Integration")]
public sealed class MachineToolConformanceSurfaceTests
{
/// <summary>
/// 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.
/// </summary>
[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<MachineToolState> 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<EventNotification> 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<NodeId>();
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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<MachineToolState> 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<EventNotification> 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<AlertTypeRecord>());
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<NotificationEventTypeRecord>());
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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<MachineToolState> 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<ReferenceDescription> 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<ReferenceDescription> 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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<MachineToolState> 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<ReferenceDescription> 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);
}
}
/// <summary>
/// Uses the public provider builder and only the explicitly selected optional features.
/// </summary>
private static MachineToolBuilder<MachineToolState> 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;
});
}
/// <summary>
/// Supplies distinct jobs, programs and workpieces, with coherent completed-run and part counts.
/// </summary>
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 }
};
}
/// <summary>
/// Supplies the modern UInt64 observation without enabling or populating deprecated statistics.
/// </summary>
private static MachineToolSnapshot CounterSnapshot(
ulong parts, double milliseconds, MachineToolSampleInfo sample) => new()
{
Operation = new MachineToolOperation
{
Mode = MachineOperationMode.Automatic,
PartsProducedInLifetime = parts,
PowerOnDurationMilliseconds = milliseconds,
Sample = sample
}
};
/// <summary>
/// Reads the actual attribute through the connected session, never from NodeState.EventNotifier.
/// </summary>
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));
}
/// <summary>
/// Preserves the raw DataValue, including uncertain quality, rather than using a value-only convenience read.
/// </summary>
private static async ValueTask<DataValue> 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];
}
/// <summary>
/// Navigates the canonical Monitoring/MachineTool path using generated typed proxies.
/// </summary>
private static async ValueTask<MachineOperationMonitoringTypeClient> 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!;
}
/// <summary>
/// Uses the existing browser to enumerate exact HasComponent references, including continuation handling.
/// </summary>
private static ValueTask<ArrayOf<ReferenceDescription>> 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);
}
/// <summary>
/// Asserts both UInt64 decoding and original acquisition metadata for a lifetime observation.
/// </summary>
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));
}
/// <summary>
/// Checks a second, independently typed observation under the same modern counter building block.
/// </summary>
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<ReferenceDescription> 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));
}
}
}