Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/Location/InMemoryGeoLocationProviderTests.cs
Marc SchierandCopilot f0ab7878ea Core stack: reusable file directory binder, geolocation seam and state machines (#4123)
**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
2026-07-31 13:13:47 +02:00

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);
}
}
}