mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
Source generator: a NodeSet2 Variable without a Value has a null Value (#4798)
# Description A Variable or VariableType in a NodeSet2 that has no Value element has a null Value (OPC 10000-6 F.5); the runtime NodeSet2 importer loads it that way. The source generator initialized such a node with the default value of its DataType instead ((int)0, (double)0, ...), so a server built from the generated code reported values the model never declared. The NodeSet2 importer now records that the node declares no Value (NodeSetValueIsNull), the flag follows the node into the merged instance hierarchy, and the node state generator emits Variant.Null for it when no value was merged in from elsewhere. ModelDesign inputs, where a missing DefaultValue implies the DataType default, are unchanged. The tests generate a DI-style NodeSet2 together with the DI NodeSet2 and check the generated values of a type's InstanceDeclaration, of an instance's children with and without a Value, and of a VariableType. ## Related Issues None. ## Checklist - [x] 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. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [x] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
This commit is contained in:
@@ -181,6 +181,12 @@ Common controllers are supplied through `ExtrusionDeviceOptions.Common`:
|
||||
The common package owns their protocols. Selecting a command-bearing capability
|
||||
without its authority fails creation.
|
||||
|
||||
A line's MaterialList is bound to `ExtrusionDeviceOptions.Common.Materials`.
|
||||
OPC 40083 declares its `DensityUnit` without a value, so a bound catalogue also
|
||||
needs `Common.MaterialDensityUnit`, the unit every material density is given in;
|
||||
creation fails without it. An unbound list publishes the configured unit, or a
|
||||
null `DensityUnit` when none is configured.
|
||||
|
||||
Observation publication is independent of an awaited hardware command, allowing
|
||||
an authoritative coordinator to deliver its committed state before replying to
|
||||
a forwarded command.
|
||||
|
||||
@@ -844,6 +844,15 @@ modality (`WithModality`), and the nested builders — `WithOptics(...)`,
|
||||
`AddExtrinsicCalibration(...)`, `AddStreamEndpoint(...)`,
|
||||
`AddClipEndpoint(...)`, `UseMediaProvider(...)`.
|
||||
|
||||
The model declares no value for the mandatory properties of sensors,
|
||||
frames, calibrations, media endpoints and pipelines. The builders publish one
|
||||
for the following when the configuration leaves them unset: the builder's
|
||||
reality kind (`Physical`), modality (`Area2D`) and frame role (`Other`);
|
||||
`Inactive` for endpoint and pipeline `State`, `None` for `Authentication`,
|
||||
`false` for `SecureTransport`; for calibrations an unknown `PerformedAt` (the
|
||||
null `DateTime`) and `Valid` `true`; and the null `NodeId` for the
|
||||
`SourceFrame`, `TargetFrame`, `Sensor` and `Deployment` references.
|
||||
|
||||
`WithFrameId(frameId)` also adds a `MountedOn` reference to the
|
||||
`CoordinateFrameType` instance with the matching `FrameId` when one has
|
||||
been registered under `Vision/Frames`. This is the recommended way to
|
||||
|
||||
@@ -78,6 +78,13 @@ namespace Opc.Ua.Extrusion.Server
|
||||
/// </summary>
|
||||
public IMaterialListProvider? Materials { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the engineering unit the MaterialList publishes as its DensityUnit and in which
|
||||
/// every material density is given. OPC 40083 declares DensityUnit without a value, so a list
|
||||
/// bound to <see cref="Materials"/> or with selected mutation methods requires this unit.
|
||||
/// </summary>
|
||||
public EUInformation? MaterialDensityUnit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the instance-specific production-dataset persistence/activation authority.
|
||||
/// </summary>
|
||||
|
||||
@@ -104,12 +104,28 @@ namespace Opc.Ua.Extrusion.Server
|
||||
?? throw MissingProvider(nameof(m_common.MachineConfiguration)),
|
||||
RequireAccessPolicy()));
|
||||
break;
|
||||
case MaterialListState materials when m_common.Materials != null ||
|
||||
materials.AddMaterial != null || materials.RemoveMaterialById != null:
|
||||
m_commonBindings.Add(new MaterialListBinding(
|
||||
m_runtime.Manager, materials,
|
||||
m_common.Materials ?? throw MissingProvider(nameof(m_common.Materials)),
|
||||
RequireAccessPolicy()));
|
||||
case MaterialListState materials:
|
||||
if (materials.DensityUnit != null && m_common.MaterialDensityUnit != null)
|
||||
{
|
||||
SetProtocolValue(materials.DensityUnit, new DataValue(
|
||||
Variant.FromStructure((EUInformation)m_common.MaterialDensityUnit.Clone()),
|
||||
StatusCodes.Good, DateTimeUtc.Now));
|
||||
}
|
||||
if (m_common.Materials != null ||
|
||||
materials.AddMaterial != null || materials.RemoveMaterialById != null)
|
||||
{
|
||||
IMaterialListProvider materialProvider =
|
||||
m_common.Materials ?? throw MissingProvider(nameof(m_common.Materials));
|
||||
if (materials.DensityUnit?.Value == null)
|
||||
{
|
||||
throw ServiceResultException.Create(
|
||||
StatusCodes.BadConfigurationError,
|
||||
"The selected material catalogue needs an explicit '{0}'.",
|
||||
nameof(m_common.MaterialDensityUnit));
|
||||
}
|
||||
m_commonBindings.Add(new MaterialListBinding(
|
||||
m_runtime.Manager, materials, materialProvider, RequireAccessPolicy()));
|
||||
}
|
||||
break;
|
||||
case ProductionDatasetManagementState datasets:
|
||||
ProductionDatasetBinding datasetBinding = await ProductionDatasetBinding.CreateAsync(
|
||||
|
||||
@@ -2552,6 +2552,12 @@ namespace Opc.Ua.RobotIntent.Server
|
||||
SetValue(node.MissionId, entry.MissionId);
|
||||
SetValue(node.Progress, -1.0);
|
||||
SetValue(node.QueuePosition, (uint)0);
|
||||
// The model declares Result and CurrentPose without a Value, so they
|
||||
// start out null; publish empty structures until the executor
|
||||
// reports a pose and the intent reaches a terminal result, so
|
||||
// clients always read a value of the declared DataType.
|
||||
SetValue(node.CurrentPose, new Pose3DDataType());
|
||||
SetValue(node.Result, new IntentResultDataType());
|
||||
SetValue(node.Deletable, true);
|
||||
SetValue(node.AutoDelete, false);
|
||||
SetValue(node.RecycleCount, 0);
|
||||
|
||||
@@ -163,6 +163,7 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
pipelinesFolder,
|
||||
qualifiedName);
|
||||
pipeline.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
||||
VisionMandatoryProperties.Initialize(m_context.Context, pipeline);
|
||||
var builder = new VisionPipelineBuilder(m_context, m_registry, m_dispatcher, pipeline, browseName);
|
||||
configure(builder);
|
||||
builder.Finalize(pipelinesFolder);
|
||||
@@ -288,6 +289,7 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
m_frame.CreateOrReplaceParentFrame(m_context.Context, null);
|
||||
m_frame.ParentFrame!.Value = parentNodeId;
|
||||
}
|
||||
m_frame.CreateOrReplaceRole(m_context.Context, null).Value = m_role;
|
||||
parent.AddChild(m_frame);
|
||||
var registration = new FrameRegistration(
|
||||
m_browseName,
|
||||
@@ -465,6 +467,7 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
IntrinsicCalibrationState calibration = BuildContext.Context
|
||||
.CreateInstanceOfIntrinsicCalibrationType(calibrations, qualifiedName);
|
||||
calibration.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
||||
VisionMandatoryProperties.Initialize(BuildContext.Context, calibration);
|
||||
var builder = new VisionIntrinsicCalibrationBuilder(BuildContext, calibration);
|
||||
configure(builder);
|
||||
calibrations.AddChild(calibration);
|
||||
@@ -492,6 +495,7 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
ExtrinsicCalibrationState calibration = BuildContext.Context
|
||||
.CreateInstanceOfExtrinsicCalibrationType(calibrations, qualifiedName);
|
||||
calibration.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
||||
VisionMandatoryProperties.Initialize(BuildContext.Context, calibration);
|
||||
var builder = new VisionExtrinsicCalibrationBuilder(BuildContext, calibration);
|
||||
configure(builder);
|
||||
calibrations.AddChild(calibration);
|
||||
@@ -523,6 +527,7 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
endpoints,
|
||||
qualifiedName);
|
||||
endpoint.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
||||
VisionMandatoryProperties.Initialize(BuildContext.Context, endpoint);
|
||||
var builder = new VisionStreamEndpointBuilder(BuildContext, endpoint);
|
||||
configure(builder);
|
||||
endpoints.AddChild(endpoint);
|
||||
@@ -550,6 +555,7 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
endpoints,
|
||||
qualifiedName);
|
||||
endpoint.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
||||
VisionMandatoryProperties.Initialize(BuildContext.Context, endpoint);
|
||||
var builder = new VisionClipEndpointBuilder(BuildContext, endpoint);
|
||||
configure(builder);
|
||||
endpoints.AddChild(endpoint);
|
||||
@@ -566,6 +572,8 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
|
||||
internal void Finalize(FolderState parent)
|
||||
{
|
||||
Sensor.CreateOrReplaceRealityKind(BuildContext.Context, null).Value = m_realityKind;
|
||||
Sensor.CreateOrReplaceModality(BuildContext.Context, null).Value = m_modality;
|
||||
parent.AddChild(Sensor);
|
||||
HashSet<string> facets = ComputeFacets();
|
||||
var registration = new SensorRegistration(
|
||||
@@ -1375,4 +1383,49 @@ namespace Opc.Ua.Vision.Server.Builders
|
||||
private IVisionFeedbackSink? m_feedbackSink;
|
||||
private bool m_inferenceOnServer = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a value for the mandatory Perception properties that no
|
||||
/// builder method configures, and the null NodeId for the references a
|
||||
/// configuration may leave unset. The model declares no Value for them,
|
||||
/// so a created instance starts with a null Value.
|
||||
/// </summary>
|
||||
internal static class VisionMandatoryProperties
|
||||
{
|
||||
/// <summary>
|
||||
/// The Server keeps no calibration time or staleness information:
|
||||
/// PerformedAt is unknown and the calibration is not known to be stale.
|
||||
/// </summary>
|
||||
public static void Initialize(ISystemContext context, VisionCalibrationState calibration)
|
||||
{
|
||||
calibration.CreateOrReplacePerformedAt(context, null).Value = DateTimeUtc.MinValue;
|
||||
calibration.CreateOrReplaceValid(context, null).Value = true;
|
||||
if (calibration is ExtrinsicCalibrationState extrinsic)
|
||||
{
|
||||
extrinsic.CreateOrReplaceSourceFrame(context, null).Value = NodeId.Null;
|
||||
extrinsic.CreateOrReplaceTargetFrame(context, null).Value = NodeId.Null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Server keeps no endpoint state and does not authenticate media access.
|
||||
/// </summary>
|
||||
public static void Initialize(ISystemContext context, MediaEndpointState endpoint)
|
||||
{
|
||||
endpoint.CreateOrReplaceState(context, null).Value = VisionEndpointStateEnum.Inactive;
|
||||
endpoint.CreateOrReplaceAuthentication(context, null).Value = VisionEndpointAuthenticationEnum.None;
|
||||
endpoint.CreateOrReplaceSecureTransport(context, null).Value = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Server keeps no pipeline state; a pipeline without a sensor or a
|
||||
/// deployment references the null NodeId.
|
||||
/// </summary>
|
||||
public static void Initialize(ISystemContext context, InferencePipelineState pipeline)
|
||||
{
|
||||
pipeline.CreateOrReplaceState(context, null).Value = VisionEndpointStateEnum.Inactive;
|
||||
pipeline.CreateOrReplaceSensor(context, null).Value = NodeId.Null;
|
||||
pipeline.CreateOrReplaceDeployment(context, null).Value = NodeId.Null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +128,23 @@ namespace Opc.Ua.Extrusion.Tests
|
||||
if (all)
|
||||
{
|
||||
options.Common.Materials = new Mock<IMaterialListProvider>().Object;
|
||||
options.Common.MaterialDensityUnit = KilogramPerCubicMetre;
|
||||
options.Features.IncludeAll();
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UNECE kilogram per cubic metre (KMQ) a test material catalogue publishes as its DensityUnit.
|
||||
/// </summary>
|
||||
internal static EUInformation KilogramPerCubicMetre => new()
|
||||
{
|
||||
NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact",
|
||||
UnitId = 4934993,
|
||||
DisplayName = new LocalizedText("en", "kg/m³"),
|
||||
Description = new LocalizedText("en", "kilogram per cubic metre")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Exercises every concrete public constructor without creating an abstract ExtrusionDevice.
|
||||
/// </summary>
|
||||
|
||||
@@ -202,6 +202,56 @@ namespace Opc.Ua.Extrusion.Tests
|
||||
Assert.That(failure.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC 40083 declares the MaterialList DensityUnit without a value: an unbound list keeps a null
|
||||
/// unit, and a bound catalogue publishes the configured unit its densities are given in.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task MaterialListPublishesOnlyTheConfiguredDensityUnitAsync()
|
||||
{
|
||||
ExtrusionDeviceState unbound = await ExtrusionFixture.CreateAsync(
|
||||
m_fixture.Runtime, 2, ExtrusionFixture.Options("density-unit-unbound")).ConfigureAwait(false);
|
||||
var unboundLine = (Line.ExtrusionLine_InterfaceState)unbound;
|
||||
|
||||
ExtrusionDeviceOptions options = ExtrusionFixture.Options("density-unit-bound", all: true);
|
||||
ExtrusionDeviceState bound = await ExtrusionFixture.CreateAsync(m_fixture.Runtime, 2, options)
|
||||
.ConfigureAwait(false);
|
||||
var boundLine = (Line.ExtrusionLine_InterfaceState)bound;
|
||||
(ServiceResult result, DataValue read) = await boundLine.MaterialList!.DensityUnit!.ReadAttributeAsync(
|
||||
m_fixture.Runtime.SystemContext, Attributes.Value, NumericRange.Null,
|
||||
QualifiedName.Null, new DataValue()).ConfigureAwait(false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unboundLine.MaterialList!.DensityUnit!.Value, Is.Null);
|
||||
Assert.That(ServiceResult.IsBad(result), Is.False);
|
||||
Assert.That(read.StatusCode, Is.EqualTo((StatusCode)StatusCodes.Good));
|
||||
Assert.That(read.WrappedValue.TryGetStructure<EUInformation>(out EUInformation? unit), Is.True);
|
||||
Assert.That(unit?.UnitId, Is.EqualTo(ExtrusionFixture.KilogramPerCubicMetre.UnitId));
|
||||
Assert.That(boundLine.MaterialList.DensityUnit.Value,
|
||||
Is.Not.SameAs(options.Common.MaterialDensityUnit));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bound material catalogue without a configured DensityUnit is rejected during creation.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void BoundMaterialListWithoutDensityUnitIsRejected()
|
||||
{
|
||||
ExtrusionDeviceOptions options = ExtrusionFixture.Options("density-unit-missing", all: true);
|
||||
options.Common.MaterialDensityUnit = null;
|
||||
|
||||
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
||||
await ExtrusionFixture.CreateAsync(m_fixture.Runtime, 2, options).ConfigureAwait(false))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(failure.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError));
|
||||
Assert.That(failure.Message, Does.Contain(nameof(ExtrusionCommonProviders.MaterialDensityUnit)));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes concurrent creation across the duplicate check and registration.
|
||||
/// </summary>
|
||||
|
||||
@@ -422,6 +422,56 @@ namespace Opc.Ua.SourceGeneration.Generator.Tests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EmitVariableSerializesAbsentNodeSetValue()
|
||||
{
|
||||
Namespace target = ConfigureSelf();
|
||||
var withoutValue = new VariableDesign
|
||||
{
|
||||
BrowseName = "RatedFlow",
|
||||
SymbolicId = new XmlQualifiedName("PumpType_RatedFlow", TestUri),
|
||||
SymbolicName = new XmlQualifiedName("RatedFlow", TestUri),
|
||||
TypeDefinition = new XmlQualifiedName("PropertyType", Types.Namespaces.OpcUa),
|
||||
DataType = new XmlQualifiedName("Double", Types.Namespaces.OpcUa),
|
||||
NodeSetValueIsNull = true
|
||||
};
|
||||
var withDataTypeDefault = new VariableDesign
|
||||
{
|
||||
BrowseName = "Count",
|
||||
SymbolicId = new XmlQualifiedName("PumpType_Count", TestUri),
|
||||
SymbolicName = new XmlQualifiedName("Count", TestUri),
|
||||
TypeDefinition = new XmlQualifiedName("PropertyType", Types.Namespaces.OpcUa),
|
||||
DataType = new XmlQualifiedName("Int32", Types.Namespaces.OpcUa)
|
||||
};
|
||||
var objectType = new ObjectTypeDesign
|
||||
{
|
||||
ClassName = "Pump",
|
||||
SymbolicId = new XmlQualifiedName("PumpType", TestUri),
|
||||
SymbolicName = new XmlQualifiedName("PumpType", TestUri),
|
||||
HasChildren = true,
|
||||
Children = new ListOfChildren { Items = [withoutValue, withDataTypeDefault] }
|
||||
};
|
||||
m_mockModelDesign.Setup(m => m.TargetNamespace).Returns(target);
|
||||
m_mockModelDesign.Setup(m => m.Nodes).Returns([objectType]);
|
||||
|
||||
var generator = new ModelDependencyGenerator(BuildContext());
|
||||
generator.Emit();
|
||||
|
||||
ModelDependencyV1 payload = ReadSelfPayload();
|
||||
IReadOnlyList<DependencyChild> children = payload.Nodes
|
||||
.Single(node => node.SymbolicName == "PumpType")
|
||||
.Children;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
children.Single(child => child.SymbolicName == "RatedFlow").NodeSetValueIsNull,
|
||||
Is.True);
|
||||
Assert.That(
|
||||
children.Single(child => child.SymbolicName == "Count").NodeSetValueIsNull,
|
||||
Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PayloadWithoutMethodIdentityRemainsReadable()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
A DeviceType subtype and an instance of it that declares none of the
|
||||
children it inherits from DI. DI declares RevisionCounter without a Value,
|
||||
so the inherited child of the instance has a null Value whether DI is
|
||||
supplied as a NodeSet2 or by a referenced assembly.
|
||||
-->
|
||||
<UANodeSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
||||
<NamespaceUris>
|
||||
<Uri>http://test.org/UA/InheritedValueAbsent/</Uri>
|
||||
<Uri>http://opcfoundation.org/UA/DI/</Uri>
|
||||
</NamespaceUris>
|
||||
<Models>
|
||||
<Model ModelUri="http://test.org/UA/InheritedValueAbsent/" Version="1.0.0" PublicationDate="2026-10-01T00:00:00Z">
|
||||
<RequiredModel ModelUri="http://opcfoundation.org/UA/" Version="1.05.04" PublicationDate="2025-01-08T00:00:00Z" />
|
||||
<RequiredModel ModelUri="http://opcfoundation.org/UA/DI/" Version="1.05.0" PublicationDate="2025-11-15T00:00:00Z" />
|
||||
</Model>
|
||||
</Models>
|
||||
<Aliases>
|
||||
<Alias Alias="HasTypeDefinition">i=40</Alias>
|
||||
<Alias Alias="HasSubtype">i=45</Alias>
|
||||
</Aliases>
|
||||
<UAObjectType NodeId="ns=1;i=1000" BrowseName="1:InheritingPumpType">
|
||||
<DisplayName>InheritingPumpType</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasSubtype" IsForward="false">ns=2;i=1002</Reference>
|
||||
</References>
|
||||
</UAObjectType>
|
||||
<UAObject NodeId="ns=1;i=5000" BrowseName="1:InheritingPump">
|
||||
<DisplayName>InheritingPump</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=1000</Reference>
|
||||
</References>
|
||||
</UAObject>
|
||||
</UANodeSet>
|
||||
@@ -0,0 +1,334 @@
|
||||
/* ========================================================================
|
||||
* 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.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.SourceGeneration;
|
||||
using Opc.Ua.SourceGeneration.Dependency;
|
||||
using Opc.Ua.Tests;
|
||||
|
||||
namespace Opc.Ua.Schema.Model.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// A NodeSet2 Variable or VariableType without a Value element has a
|
||||
/// null Value (OPC 10000-6 F.5). The generator used to initialize such
|
||||
/// a node with the default value of its DataType ((int)0, (double)0),
|
||||
/// so a server built from the NodeSet reported a value the model never
|
||||
/// declared.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("ModelDesign")]
|
||||
[SetCulture("en-us")]
|
||||
[SetUICulture("en-us")]
|
||||
public class NodeSetValueAbsentTests
|
||||
{
|
||||
private const string NullValue = "baseState.WrappedValue = global::Opc.Ua.Variant.Null;";
|
||||
|
||||
[TestCase("CreatePump1_RevisionCounter", "(int)0")]
|
||||
[TestCase("CreatePump1_RatedFlow", "(double)0")]
|
||||
[TestCase("CreatePumpType_RatedFlow", "(double)0")]
|
||||
public void VariableWithoutValueIsNull(string method, string dataTypeDefault)
|
||||
{
|
||||
string body = ExtractMethodBody(GenerateDeviceSetPump(), method);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(body, Does.Contain(NullValue));
|
||||
Assert.That(body, Does.Not.Contain(dataTypeDefault));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VariableWithValueKeepsIt()
|
||||
{
|
||||
string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreatePump1_SerialNumber");
|
||||
|
||||
Assert.That(
|
||||
body,
|
||||
Does.Contain("baseState.WrappedValue = global::Opc.Ua.Variant.From(\"SN-0001\");"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VariableTypeWithoutValueIsNull()
|
||||
{
|
||||
string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreateFlowVariableType");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(body, Does.Contain(NullValue));
|
||||
Assert.That(body, Does.Not.Contain("(double)0"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InstanceOfVariableTypeWithoutValueIsNull()
|
||||
{
|
||||
string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreateInstanceOfFlowVariableType");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(body, Does.Contain(NullValue));
|
||||
Assert.That(body, Does.Not.Contain("(double)0"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SubtypeWithoutValueDoesNotInheritTheValueOfItsSupertype()
|
||||
{
|
||||
string generated = GenerateDeviceSetPump();
|
||||
string supertype = ExtractMethodBody(generated, "CreateInstanceOfRatedFlowVariableType");
|
||||
string subtype = ExtractMethodBody(generated, "CreateInstanceOfUnratedFlowVariableType");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(supertype, Does.Contain("42.5"), "the supertype keeps its declared Value");
|
||||
Assert.That(subtype, Does.Contain(NullValue));
|
||||
Assert.That(subtype, Does.Not.Contain("42.5"));
|
||||
Assert.That(subtype, Does.Not.Contain("(double)0"));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("CreateUnsetRatedFlow")]
|
||||
[TestCase("CreateRatedFlowMeter_UnsetFlow")]
|
||||
[TestCase("CreateRatedFlowMeter1_RatedFlow")]
|
||||
public void VariableWithoutValueDoesNotInheritTheValueOfItsVariableType(string method)
|
||||
{
|
||||
string body = ExtractMethodBody(GenerateDeviceSetPump(), method);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(body, Does.Contain(NullValue));
|
||||
Assert.That(body, Does.Not.Contain("42.5"));
|
||||
Assert.That(body, Does.Not.Contain("12.5"));
|
||||
Assert.That(body, Does.Not.Contain("(double)0"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VariableWithValueOverridesTheValueOfItsVariableType()
|
||||
{
|
||||
string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreateRatedFlowMeter_SetFlow");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(body, Does.Contain("7.25"));
|
||||
Assert.That(body, Does.Not.Contain("42.5"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DependencyPayloadCarriesTheAbsentValue()
|
||||
{
|
||||
var dependency = new ModelDependencyV1 { ModelUri = "http://test.org/UA/ValueAbsent/" };
|
||||
dependency.Nodes.Add(new DependencyNode
|
||||
{
|
||||
SymbolicName = "PumpType",
|
||||
SymbolicNamespace = "http://test.org/UA/ValueAbsent/",
|
||||
ClassName = "PumpType",
|
||||
Kind = DependencyNodeKind.ObjectType,
|
||||
Children =
|
||||
[
|
||||
new DependencyChild
|
||||
{
|
||||
BrowseName = "RatedFlow",
|
||||
SymbolicName = "RatedFlow",
|
||||
InstanceKind = 3,
|
||||
NodeSetValueIsNull = true
|
||||
},
|
||||
new DependencyChild
|
||||
{
|
||||
BrowseName = "SerialNumber",
|
||||
SymbolicName = "SerialNumber",
|
||||
InstanceKind = 3,
|
||||
DefaultValueXml = "<String xmlns=\"http://opcfoundation.org/UA/2008/02/Types.xsd\">SN</String>"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
ModelDependencyV1? decoded = ModelDependencyV1.FromBase64Payload(dependency.ToBase64Payload());
|
||||
|
||||
Assert.That(decoded, Is.Not.Null);
|
||||
IReadOnlyList<DependencyChild> children = decoded!.Nodes.Single().Children;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(children[0].NodeSetValueIsNull, Is.True);
|
||||
Assert.That(children[1].NodeSetValueIsNull, Is.False);
|
||||
Assert.That(children[1].DefaultValueXml, Does.Contain("SN"));
|
||||
});
|
||||
}
|
||||
|
||||
private static string GenerateDeviceSetPump()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
|
||||
using var fileSystem = new VirtualFileSystem();
|
||||
string resources = Path.Combine(Directory.GetCurrentDirectory(), "Resources");
|
||||
const string flowTypePath = "memory://FlowVariableType.NodeSet2.xml";
|
||||
fileSystem.Add(flowTypePath, Encoding.UTF8.GetBytes(FlowVariableTypeNodeSet));
|
||||
var nodesets = new NodesetFileCollection(
|
||||
[
|
||||
(Path.Combine(resources, "Opc.Ua.Di.NodeSet2.xml"), new NodesetFileOptions()),
|
||||
(Path.Combine(resources, "DeviceSetPump.NodeSet2.xml"), new NodesetFileOptions()),
|
||||
(flowTypePath, new NodesetFileOptions())
|
||||
],
|
||||
[],
|
||||
fileSystem,
|
||||
telemetry);
|
||||
nodesets.GenerateCode(
|
||||
fileSystem,
|
||||
string.Empty,
|
||||
telemetry,
|
||||
new GeneratorOptions { OmitFluentApi = true });
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
fileSystem.CreatedFiles
|
||||
.Where(c => Path.GetExtension(c) == ".cs")
|
||||
.Select(c => Encoding.UTF8.GetString(fileSystem.Get(c))));
|
||||
}
|
||||
|
||||
private static string ExtractMethodBody(string code, string methodName)
|
||||
{
|
||||
Match match = Regex.Match(code, @"\s" + Regex.Escape(methodName) + @"\(\s*this\s");
|
||||
Assert.That(match.Success, Is.True, $"Method {methodName} was not generated.");
|
||||
int end = code.IndexOf("return state;", match.Index, StringComparison.Ordinal);
|
||||
Assert.That(end, Is.GreaterThan(match.Index));
|
||||
return code[match.Index..end];
|
||||
}
|
||||
|
||||
private const string FlowVariableTypeNodeSet = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UANodeSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
||||
<NamespaceUris>
|
||||
<Uri>http://test.org/UA/FlowVariableType/</Uri>
|
||||
</NamespaceUris>
|
||||
<Models>
|
||||
<Model ModelUri="http://test.org/UA/FlowVariableType/" PublicationDate="2026-10-01T00:00:00Z" Version="1.0.0" />
|
||||
</Models>
|
||||
<Aliases>
|
||||
<Alias Alias="Double">i=11</Alias>
|
||||
<Alias Alias="HasSubtype">i=45</Alias>
|
||||
<Alias Alias="HasTypeDefinition">i=40</Alias>
|
||||
<Alias Alias="Organizes">i=35</Alias>
|
||||
<Alias Alias="HasComponent">i=47</Alias>
|
||||
<Alias Alias="HasModellingRule">i=37</Alias>
|
||||
</Aliases>
|
||||
<UAVariableType NodeId="ns=1;i=2000" BrowseName="1:FlowVariableType" DataType="Double">
|
||||
<DisplayName>FlowVariableType</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasSubtype" IsForward="false">i=63</Reference>
|
||||
</References>
|
||||
</UAVariableType>
|
||||
<UAVariableType NodeId="ns=1;i=2001" BrowseName="1:RatedFlowVariableType" DataType="Double">
|
||||
<DisplayName>RatedFlowVariableType</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasSubtype" IsForward="false">i=63</Reference>
|
||||
</References>
|
||||
<Value>
|
||||
<Double xmlns="http://opcfoundation.org/UA/2008/02/Types.xsd">42.5</Double>
|
||||
</Value>
|
||||
</UAVariableType>
|
||||
<UAVariableType NodeId="ns=1;i=2002" BrowseName="1:UnratedFlowVariableType" DataType="Double">
|
||||
<DisplayName>UnratedFlowVariableType</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasSubtype" IsForward="false">ns=1;i=2001</Reference>
|
||||
</References>
|
||||
</UAVariableType>
|
||||
<UAVariable NodeId="ns=1;i=5000" BrowseName="1:UnsetRatedFlow" DataType="Double">
|
||||
<DisplayName>UnsetRatedFlow</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=2001</Reference>
|
||||
<Reference ReferenceType="Organizes" IsForward="false">i=85</Reference>
|
||||
</References>
|
||||
</UAVariable>
|
||||
<UAObject NodeId="ns=1;i=5001" BrowseName="1:RatedFlowMeter">
|
||||
<DisplayName>RatedFlowMeter</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">i=58</Reference>
|
||||
<Reference ReferenceType="Organizes" IsForward="false">i=85</Reference>
|
||||
<Reference ReferenceType="HasComponent">ns=1;i=5002</Reference>
|
||||
<Reference ReferenceType="HasComponent">ns=1;i=5003</Reference>
|
||||
</References>
|
||||
</UAObject>
|
||||
<UAVariable NodeId="ns=1;i=5002" BrowseName="1:UnsetFlow" ParentNodeId="ns=1;i=5001" DataType="Double">
|
||||
<DisplayName>UnsetFlow</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=2001</Reference>
|
||||
</References>
|
||||
</UAVariable>
|
||||
<UAVariable NodeId="ns=1;i=5003" BrowseName="1:SetFlow" ParentNodeId="ns=1;i=5001" DataType="Double">
|
||||
<DisplayName>SetFlow</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=2001</Reference>
|
||||
</References>
|
||||
<Value>
|
||||
<Double xmlns="http://opcfoundation.org/UA/2008/02/Types.xsd">7.25</Double>
|
||||
</Value>
|
||||
</UAVariable>
|
||||
<UAObjectType NodeId="ns=1;i=1001" BrowseName="1:RatedFlowMeterType">
|
||||
<DisplayName>RatedFlowMeterType</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasSubtype" IsForward="false">i=58</Reference>
|
||||
<Reference ReferenceType="HasComponent">ns=1;i=6000</Reference>
|
||||
</References>
|
||||
</UAObjectType>
|
||||
<UAVariable NodeId="ns=1;i=6000" BrowseName="1:RatedFlow" ParentNodeId="ns=1;i=1001" DataType="Double">
|
||||
<DisplayName>RatedFlow</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=2001</Reference>
|
||||
<Reference ReferenceType="HasModellingRule">i=78</Reference>
|
||||
</References>
|
||||
<Value>
|
||||
<Double xmlns="http://opcfoundation.org/UA/2008/02/Types.xsd">12.5</Double>
|
||||
</Value>
|
||||
</UAVariable>
|
||||
<UAObject NodeId="ns=1;i=5004" BrowseName="1:RatedFlowMeter1">
|
||||
<DisplayName>RatedFlowMeter1</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=1001</Reference>
|
||||
<Reference ReferenceType="Organizes" IsForward="false">i=85</Reference>
|
||||
<Reference ReferenceType="HasComponent">ns=1;i=5005</Reference>
|
||||
</References>
|
||||
</UAObject>
|
||||
<UAVariable NodeId="ns=1;i=5005" BrowseName="1:RatedFlow" ParentNodeId="ns=1;i=5004" DataType="Double">
|
||||
<DisplayName>RatedFlow</DisplayName>
|
||||
<References>
|
||||
<Reference ReferenceType="HasTypeDefinition">ns=1;i=2001</Reference>
|
||||
</References>
|
||||
</UAVariable>
|
||||
</UANodeSet>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/* ========================================================================
|
||||
* 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.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Opc.Ua.SourceGeneration
|
||||
{
|
||||
/// <summary>
|
||||
/// A Variable that a NodeSet2 declares without a Value has a null Value.
|
||||
/// An instance inherits such a child from its type; when the type's model
|
||||
/// is supplied by a referenced assembly, the child comes from the
|
||||
/// dependency payload, which did not carry that the Value is absent, so
|
||||
/// the inherited child fell back to the default value of its DataType.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("SourceGeneration")]
|
||||
[SetCulture("en-us")]
|
||||
[SetUICulture("en-us")]
|
||||
public class NodesetDependencyValueAbsentTests
|
||||
{
|
||||
private const string NullValue = "baseState.WrappedValue = global::Opc.Ua.Variant.Null;";
|
||||
|
||||
[Test]
|
||||
public void InheritedChildWithoutValueIsNullWhenTheTypeModelIsAnAdditionalFile()
|
||||
{
|
||||
string generated = Generate(
|
||||
ModelDependencyScannerTests.CreateStackCompilation("InheritedValueAbsentWithDi"),
|
||||
EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml"),
|
||||
EmbeddedText.From("InheritedValueAbsent.NodeSet2.xml"));
|
||||
|
||||
AssertRevisionCounterIsNull(generated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InheritedChildWithoutValueIsNullWhenTheTypeModelIsAReferencedAssembly()
|
||||
{
|
||||
string generated = Generate(
|
||||
ModelDependencyScannerTests
|
||||
.CreateStackCompilation("InheritedValueAbsent")
|
||||
.AddReferences(s_diProducer.Value.ToMetadataReference()),
|
||||
EmbeddedText.From("InheritedValueAbsent.NodeSet2.xml"));
|
||||
|
||||
AssertRevisionCounterIsNull(generated);
|
||||
}
|
||||
|
||||
private static void AssertRevisionCounterIsNull(string generated)
|
||||
{
|
||||
string body = ExtractMethodBody(generated, "CreateInheritingPump_RevisionCounter");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(body, Does.Contain(NullValue));
|
||||
Assert.That(body, Does.Not.Contain("(int)0"));
|
||||
});
|
||||
}
|
||||
|
||||
private static string Generate(CSharpCompilation compilation, params AdditionalText[] texts)
|
||||
{
|
||||
GeneratorDriver driver = CreateDriver(texts).RunGeneratorsAndUpdateCompilation(
|
||||
compilation,
|
||||
out Compilation output,
|
||||
out ImmutableArray<Diagnostic> diagnostics);
|
||||
Assert.That(
|
||||
diagnostics.Concat(output.GetDiagnostics()).Where(d => d.Severity == DiagnosticSeverity.Error),
|
||||
Is.Empty,
|
||||
string.Join(Environment.NewLine, diagnostics.Concat(output.GetDiagnostics())));
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
driver.GetRunResult().Results[0].GeneratedSources
|
||||
.Where(s => s.HintName.StartsWith("InheritedValueAbsent.", StringComparison.Ordinal))
|
||||
.Select(s => s.SourceText.ToString()));
|
||||
}
|
||||
|
||||
private static GeneratorDriver CreateDriver(params AdditionalText[] texts)
|
||||
{
|
||||
return CSharpGeneratorDriver.Create(new ModelSourceGenerator())
|
||||
.WithUpdatedParseOptions(new CSharpParseOptions()
|
||||
.WithKind(SourceCodeKind.Regular)
|
||||
.WithLanguageVersion(LanguageVersion.CSharp11))
|
||||
.AddAdditionalTexts([.. texts])
|
||||
.WithUpdatedAnalyzerConfigOptions(new AnalyzerOptionsProvider(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["build_property.ModelSourceGeneratorOmitFluentApi"] = "true"
|
||||
}));
|
||||
}
|
||||
|
||||
private static CSharpCompilation CreateDiProducer()
|
||||
{
|
||||
CSharpCompilation compilation = ModelDependencyScannerTests
|
||||
.CreateStackCompilation("InheritedValueAbsentDiProducer");
|
||||
CreateDriver(EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml")).RunGeneratorsAndUpdateCompilation(
|
||||
compilation,
|
||||
out Compilation output,
|
||||
out ImmutableArray<Diagnostic> diagnostics);
|
||||
Assert.That(
|
||||
diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error),
|
||||
Is.Empty,
|
||||
string.Join(Environment.NewLine, diagnostics));
|
||||
return (CSharpCompilation)output;
|
||||
}
|
||||
|
||||
private static string ExtractMethodBody(string code, string methodName)
|
||||
{
|
||||
Match match = Regex.Match(
|
||||
code,
|
||||
@"\s" + Regex.Escape(methodName) + @"\(\s*this\s");
|
||||
Assert.That(match.Success, Is.True, $"Method {methodName} was not generated.");
|
||||
int end = code.IndexOf("return state;", match.Index, StringComparison.Ordinal);
|
||||
Assert.That(end, Is.GreaterThan(match.Index));
|
||||
return code[match.Index..end];
|
||||
}
|
||||
|
||||
private static readonly Lazy<CSharpCompilation> s_diProducer = new(CreateDiProducer);
|
||||
}
|
||||
}
|
||||
@@ -673,6 +673,74 @@ namespace Opc.Ua.Vision.Tests
|
||||
Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MandatoryPropertiesTheConfigurationLeavesUnsetHaveAValue()
|
||||
{
|
||||
await using var fixture = new VisionServerFixture();
|
||||
await fixture.StartAsync().ConfigureAwait(false);
|
||||
IVisionBuildContext context = fixture.CreateBuildContext();
|
||||
|
||||
context.Nodes
|
||||
.AddFrame("Frame1", f => f.WithFrameId("frame1"))
|
||||
.AddImageSensor(
|
||||
"Cam1",
|
||||
s => s
|
||||
.WithSensorId("SN-CAM")
|
||||
.AddIntrinsicCalibration("Intr", _ => { })
|
||||
.AddExtrinsicCalibration("Extr", _ => { })
|
||||
.AddStreamEndpoint("Stream", _ => { })
|
||||
.AddClipEndpoint("Clip", _ => { }))
|
||||
.AddPipeline("Pipe1", _ => { });
|
||||
|
||||
var frame = (CoordinateFrameState)FindChild(fixture.Manager.Root.Frames!, "Frame1")!;
|
||||
var camera = (ImageSensorState)FindChild(fixture.Manager.Root.Sensors!, "Cam1")!;
|
||||
var intrinsic = (IntrinsicCalibrationState)FindChild(camera.Calibrations!, "Intr")!;
|
||||
var extrinsic = (ExtrinsicCalibrationState)FindChild(camera.Calibrations!, "Extr")!;
|
||||
var stream = (StreamEndpointState)FindChild(camera.Media!.StreamEndpoints!, "Stream")!;
|
||||
var clip = (ClipEndpointState)FindChild(camera.Media!.ClipEndpoints!, "Clip")!;
|
||||
var pipeline = (InferencePipelineState)FindChild(fixture.Manager.Root.Pipelines!, "Pipe1")!;
|
||||
BaseVariableState[] published =
|
||||
[
|
||||
frame.Role!,
|
||||
camera.RealityKind!,
|
||||
camera.Modality!,
|
||||
intrinsic.PerformedAt!,
|
||||
intrinsic.Valid!,
|
||||
extrinsic.PerformedAt!,
|
||||
extrinsic.Valid!,
|
||||
extrinsic.SourceFrame!,
|
||||
extrinsic.TargetFrame!,
|
||||
stream.State!,
|
||||
stream.Authentication!,
|
||||
stream.SecureTransport!,
|
||||
clip.State!,
|
||||
clip.Authentication!,
|
||||
clip.SecureTransport!,
|
||||
pipeline.State!,
|
||||
pipeline.Sensor!,
|
||||
pipeline.Deployment!
|
||||
];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (BaseVariableState variable in published)
|
||||
{
|
||||
Assert.That(variable.WrappedValue.IsNull, Is.False, variable.BrowseName.Name);
|
||||
}
|
||||
Assert.That(frame.Role!.Value, Is.EqualTo(VisionFrameRoleEnum.Other));
|
||||
Assert.That(camera.RealityKind!.Value, Is.EqualTo(VisionRealityKindEnum.Physical));
|
||||
Assert.That(camera.Modality!.Value, Is.EqualTo(VisionSensorModalityEnum.Area2D));
|
||||
Assert.That(intrinsic.PerformedAt!.Value, Is.EqualTo(DateTimeUtc.MinValue));
|
||||
Assert.That(intrinsic.Valid!.Value, Is.True);
|
||||
Assert.That(extrinsic.Valid!.Value, Is.True);
|
||||
Assert.That(extrinsic.SourceFrame!.Value, Is.EqualTo(NodeId.Null));
|
||||
Assert.That(stream.State!.Value, Is.EqualTo(VisionEndpointStateEnum.Inactive));
|
||||
Assert.That(clip.Authentication!.Value, Is.EqualTo(VisionEndpointAuthenticationEnum.None));
|
||||
Assert.That(pipeline.State!.Value, Is.EqualTo(VisionEndpointStateEnum.Inactive));
|
||||
Assert.That(pipeline.Deployment!.Value, Is.EqualTo(NodeId.Null));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BuildContextExposesVisionAndInstanceNamespaceIndexes()
|
||||
{
|
||||
|
||||
@@ -285,6 +285,14 @@ namespace Opc.Ua.SourceGeneration.Dependency
|
||||
/// </summary>
|
||||
public string? DefaultValueXml { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the variable was imported from a NodeSet2 that declares no
|
||||
/// Value for it, so that its Value is null rather than the default
|
||||
/// value of its DataType (variables only). Carried as a variable flag
|
||||
/// that older readers ignore.
|
||||
/// </summary>
|
||||
public bool NodeSetValueIsNull { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The namespace table the namespace indexes in
|
||||
/// <see cref="DefaultValueXml"/> refer to in the producing model
|
||||
@@ -494,6 +502,9 @@ namespace Opc.Ua.SourceGeneration.Dependency
|
||||
private const byte kRawUserAccessLevel = 0x04;
|
||||
private const byte kMinimumSamplingIntervalSpecified = 0x08;
|
||||
private const byte kHistorizingSpecified = 0x10;
|
||||
// A reader that predates this flag ignores it and keeps implying the
|
||||
// DataType default, which is what it did before.
|
||||
private const byte kNodeSetValueIsNull = 0x20;
|
||||
|
||||
/// <summary>
|
||||
/// Namespace URI that qualifies the standard reference types.
|
||||
@@ -886,6 +897,10 @@ namespace Opc.Ua.SourceGeneration.Dependency
|
||||
{
|
||||
variableFlags |= kHistorizingSpecified;
|
||||
}
|
||||
if (child.NodeSetValueIsNull)
|
||||
{
|
||||
variableFlags |= kNodeSetValueIsNull;
|
||||
}
|
||||
writer.Write(variableFlags);
|
||||
writer.Write(child.AccessLevel);
|
||||
writer.Write(child.RawAccessLevel.GetValueOrDefault());
|
||||
@@ -922,6 +937,7 @@ namespace Opc.Ua.SourceGeneration.Dependency
|
||||
child.MinimumSamplingIntervalSpecified = (flags & kMinimumSamplingIntervalSpecified) != 0;
|
||||
child.Historizing = reader.ReadBoolean();
|
||||
child.HistorizingSpecified = (flags & kHistorizingSpecified) != 0;
|
||||
child.NodeSetValueIsNull = (flags & kNodeSetValueIsNull) != 0;
|
||||
child.DefaultValueXml = ReadNullableString(reader);
|
||||
if (child.DefaultValueXml != null)
|
||||
{
|
||||
|
||||
@@ -382,6 +382,8 @@ namespace Opc.Ua.SourceGeneration
|
||||
effectiveVariable.HistorizingSpecified;
|
||||
entryChild.DefaultValueXml =
|
||||
effectiveVariable.DefaultValue?.OuterXml;
|
||||
entryChild.NodeSetValueIsNull =
|
||||
effectiveVariable.NodeSetValueIsNull;
|
||||
entryChild.DefaultValueNamespaceUris =
|
||||
GetDefaultValueNamespaceUris(effectiveVariable);
|
||||
}
|
||||
|
||||
@@ -3081,22 +3081,29 @@ namespace Opc.Ua.SourceGeneration
|
||||
Tokens.StateClassFactory,
|
||||
"new global::Opc.Ua.BaseDataVariableTypeState");
|
||||
|
||||
context.Template.AddReplacement(Tokens.ValueCode, CoreUtils.Format(
|
||||
"baseState.WrappedValue = {0};",
|
||||
node.DataTypeNode!.GetValueAsCode(
|
||||
node.ValueRank,
|
||||
node.DefaultValue,
|
||||
node.DecodedValue,
|
||||
true,
|
||||
m_context.ModelDesign.TargetNamespace.Value,
|
||||
m_context.ModelDesign.Namespaces,
|
||||
m_messageContext,
|
||||
() => AddXmlInitializerForComplexValue(
|
||||
node,
|
||||
node.DataTypeNode!,
|
||||
node.DefaultValue)!,
|
||||
node.DecodedValueNamespaceUris,
|
||||
kNamespaceTableContextVariable)));
|
||||
if (IsNodeSetValueNull(node.NodeSetValueIsNull, node.DefaultValue, node.DecodedValue))
|
||||
{
|
||||
context.Template.AddReplacement(Tokens.ValueCode, kNullValueCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Template.AddReplacement(Tokens.ValueCode, CoreUtils.Format(
|
||||
"baseState.WrappedValue = {0};",
|
||||
node.DataTypeNode!.GetValueAsCode(
|
||||
node.ValueRank,
|
||||
node.DefaultValue,
|
||||
node.DecodedValue,
|
||||
true,
|
||||
m_context.ModelDesign.TargetNamespace.Value,
|
||||
m_context.ModelDesign.Namespaces,
|
||||
m_messageContext,
|
||||
() => AddXmlInitializerForComplexValue(
|
||||
node,
|
||||
node.DataTypeNode!,
|
||||
node.DefaultValue)!,
|
||||
node.DecodedValueNamespaceUris,
|
||||
kNamespaceTableContextVariable)));
|
||||
}
|
||||
string dataTypeId =
|
||||
GetNodeIdConstantForDataType(node, m_context.ModelDesign.Namespaces);
|
||||
context.Template.AddReplacement(
|
||||
@@ -3225,6 +3232,10 @@ namespace Opc.Ua.SourceGeneration
|
||||
[arguments.ToList()],
|
||||
WriteTemplate_ArgumentCollection);
|
||||
}
|
||||
else if (IsNodeSetValueNull(node.NodeSetValueIsNull, node.DefaultValue, node.DecodedValue))
|
||||
{
|
||||
context.Template.AddReplacement(Tokens.ValueCode, kNullValueCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Template.AddReplacement(Tokens.ValueCode, CoreUtils.Format(
|
||||
@@ -3246,6 +3257,20 @@ namespace Opc.Ua.SourceGeneration
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Variable or VariableType imported from a NodeSet2 without a Value
|
||||
/// has a null Value. Only a ModelDesign implies the DataType default.
|
||||
/// </summary>
|
||||
private static bool IsNodeSetValueNull(
|
||||
bool nodeSetValueIsNull,
|
||||
System.Xml.XmlElement? defaultValue,
|
||||
object? decodedValue)
|
||||
{
|
||||
return nodeSetValueIsNull && defaultValue == null && decodedValue == null;
|
||||
}
|
||||
|
||||
private const string kNullValueCode = "baseState.WrappedValue = global::Opc.Ua.Variant.Null;";
|
||||
|
||||
private static Parameter[]? GetMethodArgumentParameters(NodeToGenerate node)
|
||||
{
|
||||
if (node.Parent?.Design is not MethodDesign method)
|
||||
|
||||
@@ -982,6 +982,14 @@ namespace Opc.Ua.Schema.Model
|
||||
[XmlIgnore]
|
||||
public DataTypeDesign? DataTypeNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the node was imported from a NodeSet2 that declares no
|
||||
/// Value for it. Its Value is then null (OPC 10000-6 F.5) rather than
|
||||
/// the default value of its DataType, which a ModelDesign implies.
|
||||
/// </summary>
|
||||
[XmlIgnore]
|
||||
public bool NodeSetValueIsNull { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The verbatim OPC UA AccessLevel bitmask imported from a
|
||||
/// NodeSet2 model. The ModelDesign <see cref="AccessLevel"/>
|
||||
@@ -1025,6 +1033,7 @@ namespace Opc.Ua.Schema.Model
|
||||
AccessLevelSpecified == other.AccessLevelSpecified &&
|
||||
RawAccessLevel == other.RawAccessLevel &&
|
||||
RawUserAccessLevel == other.RawUserAccessLevel &&
|
||||
NodeSetValueIsNull == other.NodeSetValueIsNull &&
|
||||
InstanceAccessLevel == other.InstanceAccessLevel &&
|
||||
InstanceAccessLevelSpecified == other.InstanceAccessLevelSpecified &&
|
||||
MinimumSamplingInterval == other.MinimumSamplingInterval &&
|
||||
@@ -1046,6 +1055,7 @@ namespace Opc.Ua.Schema.Model
|
||||
hash.Add(AccessLevelSpecified);
|
||||
hash.Add(RawAccessLevel);
|
||||
hash.Add(RawUserAccessLevel);
|
||||
hash.Add(NodeSetValueIsNull);
|
||||
hash.Add(InstanceAccessLevel);
|
||||
hash.Add(InstanceAccessLevelSpecified);
|
||||
hash.Add(MinimumSamplingInterval);
|
||||
@@ -1094,6 +1104,14 @@ namespace Opc.Ua.Schema.Model
|
||||
[XmlIgnore]
|
||||
public DataTypeDesign? DataTypeNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the node was imported from a NodeSet2 that declares no
|
||||
/// Value for it. Its Value is then null (OPC 10000-6 F.5) rather than
|
||||
/// the default value of its DataType, which a ModelDesign implies.
|
||||
/// </summary>
|
||||
[XmlIgnore]
|
||||
public bool NodeSetValueIsNull { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
@@ -1121,6 +1139,7 @@ namespace Opc.Ua.Schema.Model
|
||||
MinimumSamplingIntervalSpecified == other.MinimumSamplingIntervalSpecified &&
|
||||
Historizing == other.Historizing &&
|
||||
HistorizingSpecified == other.HistorizingSpecified &&
|
||||
NodeSetValueIsNull == other.NodeSetValueIsNull &&
|
||||
ExposesItsChildren == other.ExposesItsChildren;
|
||||
}
|
||||
|
||||
@@ -1140,6 +1159,7 @@ namespace Opc.Ua.Schema.Model
|
||||
hash.Add(MinimumSamplingIntervalSpecified);
|
||||
hash.Add(Historizing);
|
||||
hash.Add(HistorizingSpecified);
|
||||
hash.Add(NodeSetValueIsNull);
|
||||
hash.Add(ExposesItsChildren);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
@@ -5294,6 +5294,19 @@ namespace Opc.Ua.Schema.Model
|
||||
VariableTypeDesign mergedType,
|
||||
VariableTypeDesign variableType)
|
||||
{
|
||||
// A NodeSet2 VariableType without a Value has a null Value: it does
|
||||
// not take over the Value of its supertype, and neither do the
|
||||
// instances created from it.
|
||||
mergedType.NodeSetValueIsNull = variableType.NodeSetValueIsNull;
|
||||
if (HasAbsentNodeSetValue(
|
||||
variableType.NodeSetValueIsNull,
|
||||
variableType.DefaultValue,
|
||||
variableType.DecodedValue))
|
||||
{
|
||||
mergedType.DecodedValue = null;
|
||||
mergedType.DecodedValueNamespaceUris = null;
|
||||
mergedType.DefaultValue = null;
|
||||
}
|
||||
if (variableType.DecodedValue != null)
|
||||
{
|
||||
mergedType.DecodedValue = variableType.DecodedValue;
|
||||
@@ -5340,6 +5353,19 @@ namespace Opc.Ua.Schema.Model
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a node imported from a NodeSet2 declares no Value, so that
|
||||
/// the Value it would otherwise take over from its supertype, its
|
||||
/// TypeDefinition or the instance declaration it overrides is cleared.
|
||||
/// </summary>
|
||||
private static bool HasAbsentNodeSetValue(
|
||||
bool nodeSetValueIsNull,
|
||||
System.Xml.XmlElement? defaultValue,
|
||||
object? decodedValue)
|
||||
{
|
||||
return nodeSetValueIsNull && defaultValue == null && decodedValue == null;
|
||||
}
|
||||
|
||||
private static void MergeTypes(
|
||||
ObjectTypeDesign mergedType,
|
||||
ObjectTypeDesign objectType)
|
||||
@@ -5496,6 +5522,7 @@ namespace Opc.Ua.Schema.Model
|
||||
MinimumSamplingIntervalSpecified = type.MinimumSamplingIntervalSpecified,
|
||||
Historizing = type.Historizing,
|
||||
HistorizingSpecified = type.HistorizingSpecified,
|
||||
NodeSetValueIsNull = type.NodeSetValueIsNull,
|
||||
Category = type.Category,
|
||||
Purpose = type.Purpose,
|
||||
ReleaseStatus = type.ReleaseStatus,
|
||||
@@ -5700,6 +5727,20 @@ namespace Opc.Ua.Schema.Model
|
||||
VariableDesign mergedVariable,
|
||||
VariableDesign variable)
|
||||
{
|
||||
// A NodeSet2 node without a Value has a null Value, whatever its
|
||||
// DataType or TypeDefinition; a ModelDesign declaration implies the
|
||||
// DataType default.
|
||||
mergedVariable.NodeSetValueIsNull = variable.NodeSetValueIsNull;
|
||||
if (HasAbsentNodeSetValue(
|
||||
variable.NodeSetValueIsNull,
|
||||
variable.DefaultValue,
|
||||
variable.DecodedValue))
|
||||
{
|
||||
mergedVariable.DecodedValue = null;
|
||||
mergedVariable.DecodedValueNamespaceUris = null;
|
||||
mergedVariable.DefaultValue = null;
|
||||
}
|
||||
|
||||
if (variable.TypeDefinition != null &&
|
||||
variable.TypeDefinition != s_baseDataVariableTypeQn)
|
||||
{
|
||||
@@ -7313,6 +7354,7 @@ namespace Opc.Ua.Schema.Model
|
||||
c.MinimumSamplingIntervalSpecified;
|
||||
variable.Historizing = c.Historizing;
|
||||
variable.HistorizingSpecified = c.HistorizingSpecified;
|
||||
variable.NodeSetValueIsNull = c.NodeSetValueIsNull;
|
||||
if (c.DefaultValueXml != null)
|
||||
{
|
||||
variable.DefaultValue = ParseDependencyDefaultValue(
|
||||
|
||||
@@ -772,6 +772,7 @@ namespace Opc.Ua.Schema.Model
|
||||
UpdateTypeDesign(input, output);
|
||||
|
||||
output.DefaultValue = input.Value;
|
||||
output.NodeSetValueIsNull = input.Value == null;
|
||||
output.ValueRankSpecified = true;
|
||||
output.ValueRank = ImportValueRank(input.ValueRank);
|
||||
output.ValueRankSpecified = true;
|
||||
@@ -1118,6 +1119,7 @@ namespace Opc.Ua.Schema.Model
|
||||
UpdateInstanceDesign(input, output);
|
||||
|
||||
output.DefaultValue = input.Value;
|
||||
output.NodeSetValueIsNull = input.Value == null;
|
||||
output.ValueRank = ImportValueRank(input.ValueRank);
|
||||
output.ValueRankSpecified = true;
|
||||
output.ArrayDimensions = ImportArrayDimensions(input.ValueRank, input.ArrayDimensions);
|
||||
|
||||
@@ -53,6 +53,13 @@ An `.xml` `AdditionalFiles` item whose root element is neither a `ModelDesign`
|
||||
(`http://opcfoundation.org/UA/2011/03/UANodeSet.xsd`) is not a model input and is skipped,
|
||||
so XML files meant for other analyzers or tools can share the `AdditionalFiles` item group.
|
||||
|
||||
A Variable or VariableType that a `NodeSet2` declares without a `<Value>` element has a null
|
||||
Value (OPC 10000-6 F.5): the generated node, its `CreateInstanceOf*` factory and every instance
|
||||
that inherits it start with `Variant.Null`, also when the model is consumed from a referenced
|
||||
assembly. A `ModelDesign` declaration without a `DefaultValue` keeps the default value of its
|
||||
DataType. A server that wants such a variable to hold a value before it has one to publish sets
|
||||
it when it creates the node, for example an empty structure for a structured DataType.
|
||||
|
||||
Per-file behaviour is controlled with `AdditionalFiles` metadata:
|
||||
|
||||
| Metadata | Description |
|
||||
|
||||
Reference in New Issue
Block a user