Files
UA-.NETStandard/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConversionEdgeTests.cs
Marc SchierandCopilot fb160a8f39 OpenUSD: the OPC UA to OpenUSD binding, scene materialization and connector (#4126)
**Stack 4/5** — base #4125. Part of the #4096 split; see #4096 for the
overall picture.

> Review after #4123, #4124 and #4125.

## What this is

The generic OPC UA to OpenUSD binding, scene materialization, and the
connector that drives a live stage from a running server.

## Contents

**Binding (Part 1)** — `Opc.Ua.OpenUsd` and `Opc.Ua.OpenUsd.Server`. A
server describes how its Variables drive USD prims, and serves the USD
layers it refers to as Part 5 files with a content digest.

**Scene materialization (Part 2)** — `Opc.Ua.OpenUsdScene` and
`Opc.Ua.OpenUsdScene.Server`, including the USDA writer.
- **Escaping fix**: string values, composition-arc references, variant
set names and selections, `doc` and prim names were all emitted
unescaped, so a value carrying a quote or a newline could author its own
layer syntax. Every emit site now goes through the escapers. The tests
assert the right property — not that the payload is absent (it
legitimately survives inside the escaped literal) but that no payload
becomes its own line of layer syntax.

**Client** — `Opc.Ua.OpenUsd.Client` streams live values into an
`IUsdSink`. `CompositeUsdSink` fans out to several sinks; the file sink
interpolates between server samples so motion is continuous rather than
stepped at the publishing interval.

**Connector** — `tools/Opc.Ua.OpenUsd.Connector` composes a stage from a
live server. The optional in-process viewport lives in a separate
assembly probed at run time, so the connector still runs — and says why
— when it is absent.
- **Fix**: the asset cache and PKI root moved off the shared temp
directory to `LocalApplicationData`.

**Sample** — `PumpDeviceIntegrationServer` gains an OpenUSD
representation of the pump.

## Notes for the reviewer

- The viewport assembly is net10.0-only. It is what exercises the
`Directory.Build.targets` change from 1/5: without it, the `net8.0` leg
of the per-TFM CI build fails with `CS0433` because the
`System.Threading.Lock` polyfill collides with the real type.
- `UsdAssetDelivery` hashes through `SHA256.Create` rather than the
static `HashData`, which does not exist on the net472/net48 targets this
test project also builds for.

## Validation

`dotnet build UA.slnx` — 0 errors, 0 warnings.

| Suite | Result |
| --- | --- |
| Opc.Ua.OpenUsd.Tests | 621 / 0 |
| Opc.Ua.Di.Tests | 321 / 0 |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
2026-08-01 07:55:58 +02:00

253 lines
8.9 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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 NUnit.Framework;
namespace Opc.Ua.OpenUsd.Client.Tests
{
/// <summary>
/// Fail-closed edges of the §5.8 conversion profile and of the §5.2 stage-digest
/// guards that do not require a session.
/// </summary>
[TestFixture]
[Category("OpenUsd")]
public sealed class OpenUsdConnectorConversionEdgeTests
{
private static EUInformation Unit(string code)
{
int id = 0;
foreach (char c in code)
{
id = (id << 8) | (byte)c;
}
return new EUInformation { UnitId = id, DisplayName = new LocalizedText(code) };
}
private static OpenUsdConnector.BindingInfo Binding(
OpenUsdRenderTargetKind kind, string? source, string? target)
{
return new OpenUsdConnector.BindingInfo
{
Kind = kind,
PrimPath = "/World/Robot",
PropertyName = "value",
SourceEngineeringUnits = source == null ? null : Unit(source),
TargetEngineeringUnits = target == null ? null : Unit(target)
};
}
[Test]
public void ConvertLeavesDisplayColorUnresolvedForANonScalarSource()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.DisplayColor, null, null);
Variant result = OpenUsdConnector.Convert(b, new Variant("warm"));
Assert.That(result.IsNull, Is.True);
}
[Test]
public void ConvertLeavesEmissiveColorUnresolvedForANonScalarSource()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.EmissiveColor, null, null);
Variant result = OpenUsdConnector.Convert(b, new Variant("bright"));
Assert.That(result.IsNull, Is.True);
}
[Test]
public void ConvertLeavesTranslationUnresolvedForAnUnhonourableLengthUnit()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation, "MTR", "FOT");
Variant result = OpenUsdConnector.Convert(b, new Variant(1.0));
Assert.That(result.IsNull, Is.True);
}
[Test]
public void ConvertLeavesGeoreferenceHeightUnresolvedForAnUnhonourableLengthUnit()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Georeference, "MTR", "FOT");
b.PropertyName = "cesium:anchor:height";
Variant result = OpenUsdConnector.Convert(b, new Variant(12.0));
Assert.That(result.IsNull, Is.True);
}
[Test]
public void ConvertLeavesGeoreferenceUnresolvedWhenTheTargetAttributeIsUnnamed()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Georeference, null, null);
b.PropertyName = string.Empty;
Variant result = OpenUsdConnector.Convert(b, new Variant(12.0));
Assert.That(result.IsNull, Is.True);
}
[Test]
public void ConvertScalesRadiansToDegreesForAScalarTarget()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "C81", "DD");
Variant result = OpenUsdConnector.Convert(b, new Variant(Math.PI));
Assert.That(result.TryGetValue(out double actual), Is.True);
Assert.That(actual, Is.EqualTo(180.0).Within(1e-9));
}
[Test]
public void ConvertScalesDegreesToRadiansForAScalarTarget()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "DD", "C81");
Variant result = OpenUsdConnector.Convert(b, new Variant(180.0));
Assert.That(result.TryGetValue(out double actual), Is.True);
Assert.That(actual, Is.EqualTo(Math.PI).Within(1e-9));
}
[Test]
public void ConvertLeavesAScalarTargetUnresolvedForAnUnknownUnitPair()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "MTR", "FOT");
Variant result = OpenUsdConnector.Convert(b, new Variant(1.0));
Assert.That(result.IsNull, Is.True);
}
[Test]
public void UnitCodeFallsBackToTheDisplayNameWhenNoUnitIdIsSet()
{
var units = new EUInformation { UnitId = 0, DisplayName = new LocalizedText("rad") };
string code = OpenUsdConnector.UnitCode(units);
Assert.That(code, Is.EqualTo("rad"));
}
[Test]
public void UnitCodeIsEmptyForAnUndeclaredUnit()
{
Assert.That(OpenUsdConnector.UnitCode(null), Is.Empty);
}
[Test]
public void VerifyStageDigestRejectsARepresentationWithoutADigest()
{
var rep = new OpenUsdConnector.RepresentationInfo();
bool verified = OpenUsdConnector.VerifyStageDigest(rep, [1, 2, 3]);
Assert.That(verified, Is.False);
}
[Test]
public void VerifyStageDigestRejectsARepresentationWithoutADigestAlgorithm()
{
var rep = new OpenUsdConnector.RepresentationInfo
{
RootLayerDigest = new ByteString(new byte[] { 1, 2, 3 }),
DigestAlgorithm = OpenUsdDigestAlgorithm.None
};
bool verified = OpenUsdConnector.VerifyStageDigest(rep, [1, 2, 3]);
Assert.That(verified, Is.False);
}
[Test]
public void VerifyStageDigestRejectsNullContent()
{
var rep = new OpenUsdConnector.RepresentationInfo
{
RootLayerDigest = new ByteString(new byte[] { 1, 2, 3 }),
DigestAlgorithm = OpenUsdDigestAlgorithm.Sha256
};
bool verified = OpenUsdConnector.VerifyStageDigest(rep, null!);
Assert.That(verified, Is.False);
}
[Test]
public void TryInvertConversionRejectsAZeroScale()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, null, null);
b.Scale = 0.0;
bool inverted = OpenUsdConnector.TryInvertConversion(b, 1.0, out double uaValue);
Assert.That(inverted, Is.False);
Assert.That(uaValue, Is.Zero);
}
[Test]
public void TryInvertConversionRejectsAnUnhonourableUnitPair()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "MTR", "FOT");
bool inverted = OpenUsdConnector.TryInvertConversion(b, 1.0, out _);
Assert.That(inverted, Is.False);
}
[Test]
public void TryInvertConversionUndoesTheAngleFactorForARotationTarget()
{
OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation, "C81", "DD");
bool inverted = OpenUsdConnector.TryInvertConversion(b, 180.0, out double uaValue);
Assert.That(inverted, Is.True);
Assert.That(uaValue, Is.EqualTo(Math.PI).Within(1e-9));
}
[Test]
public void SelectCommandBindingIgnoresABindingWithoutATargetOrMethod()
{
var rep = new OpenUsdConnector.RepresentationInfo();
rep.Bindings.Add(new OpenUsdConnector.BindingInfo
{
Intent = OpenUsdIntentProfile.UsdToUaCommand,
SignalRole = OpenUsdSignalRole.Controllable
});
OpenUsdConnector.BindingInfo? selected = OpenUsdConnector.SelectCommandBinding([rep]);
Assert.That(selected, Is.Null);
}
}
}