mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# 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.
431 lines
17 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|