Files
UA-.NETStandard/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkValidationTests.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

161 lines
5.4 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.IO;
using NUnit.Framework;
namespace Opc.Ua.OpenUsd.Client.Tests
{
/// <summary>
/// Unit tests for the defensive edges of <see cref="UsdFileSink"/>: every write path
/// silently drops a prim path or property name that is not a valid USD identifier chain,
/// the token escaper neutralises every character that could break out of a quoted token,
/// and a value of an unmapped kind degrades to a typed zero rather than producing invalid
/// USD text.
/// </summary>
[TestFixture]
[Category("OpenUsd")]
public sealed class UsdFileSinkValidationTests
{
private string m_path = string.Empty;
[SetUp]
public void SetUp()
{
m_path = Path.Combine(
Path.GetTempPath(), "usdfilesink-validation-" + Guid.NewGuid().ToString("N") + ".usda");
}
[TearDown]
public void TearDown()
{
if (File.Exists(m_path))
{
File.Delete(m_path);
}
}
[TestCase("")]
[TestCase("/")]
[TestCase("///")]
[TestCase("/9Pump")]
[TestCase("/Pump/../Secret")]
[TestCase("/Pump Body")]
public void ComposePrimRejectsAnInvalidPrimPath(string primPath)
{
var sink = new UsdFileSink(m_path);
sink.ComposePrim(primPath, OpenUsdCompositionArc.Reference, "@pump.usda@", true);
Assert.That(File.Exists(m_path), Is.False);
}
[Test]
public void ComposePrimAcceptsAValidPrimPath()
{
var sink = new UsdFileSink(m_path);
sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "@pump.usda@", true);
Assert.That(File.ReadAllText(m_path), Does.Contain("prepend references"));
}
[TestCase("")]
[TestCase("/")]
[TestCase("/Pump/../Secret")]
public void SetTimeSampleRejectsAnInvalidPrimPath(string primPath)
{
var sink = new UsdFileSink(m_path);
sink.SetTimeSample(primPath, "radius", DateTime.UtcNow, new Variant(1.0));
Assert.That(File.Exists(m_path), Is.False);
}
[TestCase("")]
[TestCase("xformOp::translate")]
[TestCase("2fast")]
[TestCase("has space")]
public void SetTimeSampleRejectsAnInvalidPropertyName(string propertyName)
{
var sink = new UsdFileSink(m_path);
sink.SetTimeSample("/Pump", propertyName, DateTime.UtcNow, new Variant(1.0));
Assert.That(File.Exists(m_path), Is.False);
}
[Test]
public void SetTimeSampleAcceptsANamespacedPropertyName()
{
var sink = new UsdFileSink(m_path);
sink.SetTimeSample(
"/Pump", "xformOp:translate", new DateTime(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc),
new Variant(4.0));
Assert.That(File.ReadAllText(m_path), Does.Contain("xformOp:translate.timeSamples"));
}
[Test]
public void SetAttributeRejectsAnEmptyPropertyName()
{
var sink = new UsdFileSink(m_path);
sink.SetAttribute("/Pump", string.Empty, new Variant(1.0));
Assert.That(File.Exists(m_path), Is.False);
}
[Test]
public void SetAttributeEscapesEveryTokenBreakoutCharacter()
{
var sink = new UsdFileSink(m_path);
sink.SetAttribute(
"/Pump", "visibility", new Variant("a\\b\"c\rd\te"));
string layer = File.ReadAllText(m_path);
Assert.That(layer, Does.Contain("token visibility = \"a\\\\b\\\"c\\rd\\te\""));
Assert.That(layer, Does.Not.Contain("\t"));
}
[Test]
public void SetAttributeWritesATypedZeroForAnUnmappedValueKind()
{
var sink = new UsdFileSink(m_path);
sink.SetAttribute("/Pump", "enabled", new Variant(true));
Assert.That(File.ReadAllText(m_path), Does.Contain("double enabled = 0.0000"));
}
}
}