mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-26 04:19:53 +08:00
**Stack 1/5** — base `master`. Part of the #4096 split; see #4096 for the overall picture. ## What this is The core stack changes the Robotics, OpenUSD and Positioning work depends on. No new projects; every change is to an existing one, so this is reviewable without any of the companion specs. ## Contents **`Opc.Ua.Server`** - `FileSystem` gains a reusable `IFileDirectoryBinder` and `IFileSystemHost`, so any node manager can serve a Part 5 `FileDirectoryType` over `IFileSystemProvider` instead of that binding living inside one companion model. - Entry names are validated before they reach the provider. The contract promises forward-slash provider-relative paths but never enforced it, so a backslash payload such as `..\..\..\Windows\System32\evil.txt` bypassed the obvious provider-side check on Windows. Defence in depth — the shipped `PhysicalFileSystemProvider` was already safe. - `Location` adds a single `IGeoLocationProvider` seam so one provider implementation serves both OPC 10000-211 GPOS and OPC 10030 ISA-95. The two specs disagree on representation, not on content, so the difference is a projection rather than a reason to write the provider twice. Ships a WKT text formatter and an in-memory provider. - `StateMachines` adds `FiniteStateMachineDispatcher`, the Part 16 transition guard used by DI software update and later by Robotics task control. **`Opc.Ua.Types`** - Node copies no longer consume NodeIds from the `NodeIdFactory`. `Initialize` materialises children through `CreateChild`, which assigns a per-instance NodeId, but the copy then overwrites every child NodeId from its source, so the assignment is discarded. A counting factory silently burned identifiers; a tracking one leaked them. - `JsonDecoder` resolves a relative encodeable type id against the namespace table before the factory lookup. **`Opc.Ua.Client`** — a file system path segment written without a namespace prefix is qualified with the namespace of the parent it is resolved against, matching the provider-owned BrowseNames. **`Directory.Build.targets`** — the `RestrictForLegacyTfm` no-op now also applies when a project pins a single `TargetFramework` and `CustomTestTarget` names a different one. That is the same situation it already handled for the legacy frameworks, and it is what keeps the per-TFM CI matrix green once net10.0-only projects appear later in the stack. ## Validation `dotnet build UA.slnx` — 0 errors. | Suite | Result | | --- | --- | | Opc.Ua.Types.Tests | 8088 / 0 | | Opc.Ua.Server.Tests | 3873 / 1 | | Opc.Ua.Client.Tests | 2105 / 0 | | Opc.Ua.Core.Tests | 4145 / 0 | | Opc.Ua.Features.Tests | 134 / 0 | The single `Opc.Ua.Server.Tests` failure is `ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`, which is pre-existing and environmental: the PKI path in my working directory exceeds MAX_PATH. It reproduces on clean `master` and passes from a shorter directory. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
182 lines
7.2 KiB
C#
182 lines
7.2 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.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
|
|
namespace Opc.Ua.Server.Tests.Location
|
|
{
|
|
/// <summary>
|
|
/// Tests for <see cref="InMemoryGeoLocationProvider"/>, the reference
|
|
/// implementation of the shared <see cref="IGeoLocationProvider"/> seam.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("GeoLocation")]
|
|
public class InMemoryGeoLocationProviderTests
|
|
{
|
|
private const string SourceId = "plant";
|
|
|
|
[Test]
|
|
public async Task ReadReturnsTheCurrentSampleAsync()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
provider.Update(SourceId, new GeoPosition(47.0, 8.0));
|
|
|
|
GeoLocationSample sample = await provider
|
|
.ReadAsync(SourceId, CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(sample.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(sample.Position, Is.Not.Null);
|
|
Assert.That(sample.Position!.Value.Latitude, Is.EqualTo(47.0));
|
|
Assert.That(sample.Position!.Value.Longitude, Is.EqualTo(8.0));
|
|
}
|
|
|
|
[Test]
|
|
public void ReadingAnUnknownSourceFails()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
|
|
Assert.That(
|
|
async () => await provider
|
|
.ReadAsync("missing", CancellationToken.None)
|
|
.ConfigureAwait(false),
|
|
Throws.TypeOf<ServiceResultException>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task UpdatesReachWatchersInOrderAsync()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
provider.Update(SourceId, new GeoPosition(47.0, 8.0));
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
await using IAsyncEnumerator<GeoLocationSample> enumerator = provider
|
|
.WatchAsync(SourceId, cts.Token)
|
|
.GetAsyncEnumerator(cts.Token);
|
|
|
|
ValueTask<bool> first = enumerator.MoveNextAsync();
|
|
provider.Update(SourceId, new GeoPosition(48.0, 8.0));
|
|
Assert.That(await first.ConfigureAwait(false), Is.True);
|
|
Assert.That(enumerator.Current.Position!.Value.Latitude, Is.EqualTo(48.0));
|
|
|
|
ValueTask<bool> second = enumerator.MoveNextAsync();
|
|
provider.Update(SourceId, new GeoPosition(49.0, 8.0));
|
|
Assert.That(await second.ConfigureAwait(false), Is.True);
|
|
Assert.That(enumerator.Current.Position!.Value.Latitude, Is.EqualTo(49.0));
|
|
|
|
await cts.CancelAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
public async Task WatchersOnlySeeTheirOwnSourceAsync()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
provider.Update("a", new GeoPosition(1.0, 1.0));
|
|
provider.Update("b", new GeoPosition(2.0, 2.0));
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
await using IAsyncEnumerator<GeoLocationSample> enumerator = provider
|
|
.WatchAsync("a", cts.Token)
|
|
.GetAsyncEnumerator(cts.Token);
|
|
|
|
ValueTask<bool> next = enumerator.MoveNextAsync();
|
|
// An update to the other source must not wake this watcher.
|
|
provider.Update("b", new GeoPosition(3.0, 3.0));
|
|
provider.Update("a", new GeoPosition(4.0, 4.0));
|
|
|
|
Assert.That(await next.ConfigureAwait(false), Is.True);
|
|
Assert.That(enumerator.Current.Position!.Value.Latitude, Is.EqualTo(4.0));
|
|
|
|
await cts.CancelAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
public async Task WatchEndsOnCancellationAsync()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
provider.Update(SourceId, new GeoPosition(47.0, 8.0));
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
var consume = Task.Run(async () =>
|
|
{
|
|
await foreach (GeoLocationSample _ in provider
|
|
.WatchAsync(SourceId, cts.Token).ConfigureAwait(false))
|
|
{
|
|
// Drain until cancelled.
|
|
}
|
|
});
|
|
|
|
await cts.CancelAsync().ConfigureAwait(false);
|
|
await consume.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
|
|
Assert.That(consume.Status, Is.EqualTo(TaskStatus.RanToCompletion));
|
|
}
|
|
|
|
[Test]
|
|
public void AFaultPropagatesToReaders()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
provider.Update(SourceId, new GeoPosition(47.0, 8.0));
|
|
provider.Fault(SourceId, new InvalidOperationException("boom"));
|
|
|
|
Assert.That(
|
|
async () => await provider
|
|
.ReadAsync(SourceId, CancellationToken.None)
|
|
.ConfigureAwait(false),
|
|
Throws.InvalidOperationException);
|
|
}
|
|
|
|
[Test]
|
|
public async Task AnUpdateClearsAFaultAsync()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
provider.Update(SourceId, new GeoPosition(47.0, 8.0));
|
|
provider.Fault(SourceId, new InvalidOperationException("boom"));
|
|
|
|
provider.Update(SourceId, new GeoPosition(50.0, 8.0));
|
|
GeoLocationSample sample = await provider
|
|
.ReadAsync(SourceId, CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(sample.Position!.Value.Latitude, Is.EqualTo(50.0));
|
|
}
|
|
|
|
[Test]
|
|
public void AnEmptySourceIdIsRejected()
|
|
{
|
|
using var provider = new InMemoryGeoLocationProvider();
|
|
|
|
Assert.That(
|
|
() => provider.Update(" ", new GeoPosition(47.0, 8.0)),
|
|
Throws.ArgumentException);
|
|
}
|
|
}
|
|
} |