Files
Marc Schier 7e25e1a3d7 Extract remaining shared companion, rendering and packaging changes (#4806)
# Description

The shared AI deployment contracts and instance registration; DI
software-upload outcomes, digest handling and simulator; bounded
Positioning/Pumps reads; PubSub raw-data/matrix and security-provider
handling; Vision read-quality contracts; xRegistry resource writes and
instance projection; OpenUSD authoring, embedded viewport and
retained-frame capture APIs; standalone sample CLI controls; and the
generic Debug/RID package-policy and fork/Dependabot coverage-comment
fixes. The Linux native prerequisite step is retained for **Vision tests
only**.

## Related Issues

- Related tracking issue:
[#4461](https://github.com/OPCFoundation/UA-.NETStandard/issues/4461),
without automatic closure.
- Source work:
[#4462](https://github.com/OPCFoundation/UA-.NETStandard/pull/4462),
which remains intact for the coordinator's later integration.
- Immediate stacked predecessor:
[#4804](https://github.com/OPCFoundation/UA-.NETStandard/pull/4804).

## 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.
- [ ] 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.
- [ ] I have addressed **all** PR feedback received.
2026-10-17 07:52:02 +02:00

431 lines
17 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 System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Opc.Ua.Vision.OpenUsd;
using Opc.Ua.Vision.OpenUsd.Rendering;
namespace Opc.Ua.Vision.Tests
{
/// <summary>
/// Coverage tests for <see cref="OpenUsdSceneCameraCaptureProvider"/>
/// paths that are not exercised by <see cref="SceneCameraCaptureProviderTests"/>
/// and for the internal <see cref="DeviceSelector"/> guard clauses.
/// </summary>
/// <remarks>
/// Some paths in this provider are host-topology-dependent
/// and therefore cannot be honestly covered by a unit test on this host:
/// <list type="bullet">
/// <item><description>The <c>else</c> branch of the constructor at
/// <c>OpenUsdSceneCameraCaptureProvider.cs</c> lines 96-106 (device
/// probe failed → <c>m_device = null</c>) is only reachable on hosts
/// where every backend probe fails; on this Windows host D3D12 always
/// succeeds. The complementary <see cref="OpenUsdSceneCameraCaptureProvider"/>
/// method <c>NoBackend</c> at lines 366-378 is only reached from that
/// branch, so it inherits the same restriction. See
/// <see cref="SceneCameraCaptureProviderTests.CaptureAsyncReportsNoRenderingBackendWhenDeviceProbeFailedAndNeverReturnsSuccessWithEmptyImage"/>
/// for the CI-side invariants.</description></item>
/// <item><description>The success path of <c>CaptureCore</c>,
/// retained-scene replacement and missing-camera recovery are exercised by
/// <see cref="OpenUsdNativeCaptureTests"/>. Those integration tests register
/// both bundled runtime manifests before opening well-formed stages;
/// uninitialized native resolver state must not be confused with a missing
/// graphics backend.</description></item>
/// <item><description>Malformed-stage and native renderer failures are not
/// forced in this in-process fixture: a native access violation cannot be
/// caught by the provider's managed error handling.</description></item>
/// <item><description>Similarly, <c>DeviceSelector.FormatException</c>
/// is only invoked when a backend probe throws; on this host every probe
/// succeeds, so the helper is unreachable from a unit test.</description></item>
/// </list>
/// </remarks>
[TestFixture]
[Category("OpenUsd")]
public sealed class VisionOpenUsdCoverageTests
{
[Test]
public void ConstructorReturnsNullPluginPathWhenConfiguredPluginPathDoesNotExist()
{
string nonExistent = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"not-a-real-plugin-dir-" + Guid.NewGuid().ToString("N"));
var options = new OpenUsdSceneCaptureOptions { PluginPath = nonExistent };
using var provider = new OpenUsdSceneCameraCaptureProvider(options, telemetry: null);
Assert.That(
provider.PluginPath,
Is.Null,
"A configured PluginPath that does not exist must resolve to null so " +
"the provider falls back to the auto-discovery path.");
}
[Test]
public void ConstructorResolvesConfiguredPluginPathWhenDirectoryExists()
{
string tempPluginDir = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"temp-plugin-dir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempPluginDir);
try
{
var options = new OpenUsdSceneCaptureOptions { PluginPath = tempPluginDir };
using var provider = new OpenUsdSceneCameraCaptureProvider(options, telemetry: null);
Assert.That(
provider.PluginPath,
Is.EqualTo(tempPluginDir),
"A configured PluginPath that exists on disk must be returned verbatim.");
}
finally
{
try
{
Directory.Delete(tempPluginDir, recursive: true);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
}
[Test]
public async Task CaptureAsyncRefusesLocalStagePathThatNamesNoReadableFile()
{
using var provider = new OpenUsdSceneCameraCaptureProvider();
string missingPath = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"does-not-exist-" + Guid.NewGuid().ToString("N") + ".usda");
var request = new SceneCameraCaptureRequest
{
StageIdentifier = missingPath,
Width = 64,
Height = 64,
Format = SceneCameraImageFormat.Png
};
SceneCameraCaptureResult result = await provider.CaptureAsync(request, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(
() =>
{
Assert.That(
result.Status,
Is.EqualTo(SceneCameraCaptureStatus.InvalidRequest),
"A missing local file must be refused in managed code so the native " +
"resolver never sees it; otherwise UsdStage.Open can tear the process down.");
Assert.That(result.Reason, Is.Not.Null);
Assert.That(result.Reason, Does.Contain("does not name a readable file"));
Assert.That(result.Image.IsNull, Is.True);
});
}
[Test]
public async Task CaptureAsyncRefusesUnknownImageFormat()
{
using var provider = new OpenUsdSceneCameraCaptureProvider();
var request = new SceneCameraCaptureRequest
{
StageIdentifier = "urn:test:stage",
Width = 64,
Height = 64,
Format = (SceneCameraImageFormat)999
};
SceneCameraCaptureResult result = await provider.CaptureAsync(request, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(
() =>
{
Assert.That(result.Status, Is.EqualTo(SceneCameraCaptureStatus.InvalidRequest));
Assert.That(result.Reason, Is.Not.Null);
Assert.That(result.Reason, Does.Contain("not supported"));
Assert.That(result.Image.IsNull, Is.True);
});
}
[Test]
public async Task CaptureResultEchoesRequestSuppliedTimestampOnFailurePaths()
{
using var provider = new OpenUsdSceneCameraCaptureProvider();
var ts = new DateTime(2024, 6, 15, 12, 34, 56, DateTimeKind.Utc);
var request = new SceneCameraCaptureRequest
{
StageIdentifier = string.Empty,
Width = 32,
Height = 32,
Format = SceneCameraImageFormat.Png,
TimestampUtc = ts
};
SceneCameraCaptureResult result = await provider.CaptureAsync(request, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
result.TimestampUtc,
Is.EqualTo(ts),
"A caller-supplied TimestampUtc must be echoed back on failure results " +
"so the caller can correlate the failure with the request it made.");
}
[Test]
public async Task CaptureResultCarriesBackendDescriptorOnEveryOutcome()
{
using var provider = new OpenUsdSceneCameraCaptureProvider();
var request = new SceneCameraCaptureRequest
{
StageIdentifier = string.Empty,
Width = 32,
Height = 32,
Format = SceneCameraImageFormat.Png
};
SceneCameraCaptureResult result = await provider.CaptureAsync(request, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(
() =>
{
Assert.That(result.Backend, Is.Not.Null);
Assert.That(
result.Backend,
Is.SameAs(provider.Backend),
"Every result must carry the provider's Backend descriptor unchanged so " +
"the caller can correlate a failure with the graphics backend it used.");
});
}
[Test]
public void SceneCameraCaptureRequestExposesEveryInitOnlyPropertyForCallers()
{
var ts = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var request = new SceneCameraCaptureRequest
{
StageIdentifier = "urn:test:cam",
PrimPath = "/World/Camera",
Width = 640,
Height = 480,
TimeCode = 42.5,
Format = SceneCameraImageFormat.Png,
TimestampUtc = ts
};
Assert.Multiple(
() =>
{
Assert.That(request.StageIdentifier, Is.EqualTo("urn:test:cam"));
Assert.That(request.PrimPath, Is.EqualTo("/World/Camera"));
Assert.That(request.Width, Is.EqualTo(640));
Assert.That(request.Height, Is.EqualTo(480));
Assert.That(request.TimeCode, Is.EqualTo(42.5));
Assert.That(request.Format, Is.EqualTo(SceneCameraImageFormat.Png));
Assert.That(request.TimestampUtc, Is.EqualTo(ts));
});
}
[Test]
public void ConstructorSucceedsWithPreferSoftwareOptionAndProducesBackendDescriptor()
{
using var provider = new OpenUsdSceneCameraCaptureProvider(
new OpenUsdSceneCaptureOptions { PreferSoftware = true },
telemetry: null);
Assert.Multiple(
() =>
{
Assert.That(provider.Backend, Is.Not.Null);
Assert.That(
provider.Backend.Name,
Is.Not.Empty,
"PreferSoftware=true changes the D3D12 probe order on Windows but must " +
"still resolve to a named backend descriptor.");
});
}
[Test]
public void ConstructorSucceedsWithAllowSoftwareFallbackFalseAndProducesBackendDescriptor()
{
using var provider = new OpenUsdSceneCameraCaptureProvider(
new OpenUsdSceneCaptureOptions { AllowSoftwareFallback = false },
telemetry: null);
Assert.Multiple(
() =>
{
Assert.That(provider.Backend, Is.Not.Null);
Assert.That(
provider.Backend.Name,
Is.Not.Empty,
"AllowSoftwareFallback=false drops the WARP probe on Windows but must " +
"still resolve to a named backend descriptor when hardware D3D12 is available.");
});
}
[Test]
public void DeviceSelectorTrySelectDeviceThrowsArgumentNullExceptionForNullOptions()
{
Assert.That(
() =>
DeviceSelector.TrySelectDevice(
null!,
NullLogger.Instance,
out _,
out _),
Throws.TypeOf<ArgumentNullException>());
}
[Test]
public void DeviceSelectorTrySelectDeviceThrowsArgumentNullExceptionForNullLogger()
{
Assert.That(
() =>
DeviceSelector.TrySelectDevice(
new OpenUsdSceneCaptureOptions(),
null!,
out _,
out _),
Throws.TypeOf<ArgumentNullException>());
}
[Test]
public void DeviceSelectorTrySelectDevicePopulatesBackendDescriptorWhenAnyProbeSucceeds()
{
bool selected = DeviceSelector.TrySelectDevice(
new OpenUsdSceneCaptureOptions(),
NullLogger.Instance,
out SelectedSilkDevice device,
out string reason);
if (!selected)
{
Assert.That(
reason,
Is.Not.Empty,
"TrySelectDevice must fill the aggregate reason when every probe fails, " +
"so the caller can surface it in a diagnostic.");
return;
}
try
{
Assert.Multiple(
() =>
{
Assert.That(device.Device, Is.Not.Null);
Assert.That(device.Backend, Is.Not.Null);
Assert.That(device.Backend.IsAvailable, Is.True);
Assert.That(device.Backend.Name, Is.Not.Empty);
Assert.That(
reason,
Is.Empty,
"aggregateReason must be empty on the success path so the caller " +
"does not log a stale unavailable message.");
});
}
finally
{
device.Device.Dispose();
}
}
[Test]
public async Task CaptureAsyncPropagatesCancellationRequestedBeforeCall()
{
using var provider = new OpenUsdSceneCameraCaptureProvider();
using var cts = new CancellationTokenSource();
cts.Cancel();
var request = new SceneCameraCaptureRequest
{
StageIdentifier = "urn:test:stage",
Width = 32,
Height = 32,
Format = SceneCameraImageFormat.Png
};
OperationCanceledException? thrown = null;
try
{
await provider.CaptureAsync(request, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex)
{
thrown = ex;
}
Assert.That(
thrown,
Is.Not.Null,
"A pre-cancelled token must cancel the capture before touching the render pipeline; " +
"the check runs before ValidateRequest so the stage identifier value is irrelevant.");
}
[Test]
public async Task DisposedProviderDisposeIsIdempotentAndFurtherCapturesRejected()
{
var provider = new OpenUsdSceneCameraCaptureProvider();
provider.Dispose();
Assert.DoesNotThrow(
provider.Dispose,
"Dispose must be idempotent - a defensive host may call it twice.");
var request = new SceneCameraCaptureRequest
{
StageIdentifier = "urn:test:stage",
Width = 32,
Height = 32,
Format = SceneCameraImageFormat.Png
};
ObjectDisposedException? thrown = null;
try
{
await provider.CaptureAsync(request, CancellationToken.None).ConfigureAwait(false);
}
catch (ObjectDisposedException ex)
{
thrown = ex;
}
Assert.That(
thrown,
Is.Not.Null,
"A capture request after Dispose must be refused with ObjectDisposedException, " +
"not swallowed and answered as a failure.");
}
}
}