mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
# Description _Describe the changes here to communicate to the maintainers why they should accept this pull request. By default - this will become the Commit message after merging and thus define history._ Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists companions using the existing Machinery/MachineVision fluent, DI and typed-client approach. **The PR is open/non-draft by user choice. Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and has 108 successful checks, six intentionally skipped checks, zero failures and zero pending checks. All five primary workflows completed successfully at that exact head.** | Family | Released specification/model | Packaging | | --- | --- | --- | | MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP; normal root versions | | WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire namespace and independent WMTP-only hosting | | GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model, Server, Client and MCP; normal root versions | | CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all four plus Debug variants forced preview | There are **12 new packable projects**. No separate WMTP package/profile/guide, root version bump, tag, release or package publication is included. Mandatory instance structure is the default; selected optional features require explicit providers. The implementation reuses Machinery results/jobs/files/ProcessValues and Robotics topology, including retained MachineTool Production, defined GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and isolated instances alongside MachineVision. Runtime and integration fixes preserve raw quality/timestamps, coherent acquisition, namespace ownership, accepted-configuration state, borrowed-resource lifetime, session/node/file cleanup, terminal-value publication, event propagation and endpoint-aware capability publication. Firmware/licence transfer means provider acceptance, not installation/activation. Cancellation does not silently abort another caller's operation. The later upstream MCP expansion is integrated without losing either family set or its default-off privileged/live execution policy. ### Conformance and scope Canonical model inputs are pinned to UA-Nodeset revision `96a08945425d0295edb3607d2a38f6841ef44046`. **GMS upstream exception:** [Annex A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes the released NodeSet normative, but §12.3.2.7 requires undefined `GMS_ProductionStatisticsType`. Inherited statistics and defined jobs/parts are implemented without inventing a standard type/NodeId; the complete GMS Production facet is withheld. The requirement ledger records **102 positively evidenced CU rows, 96 directly negative-tested rows and six explicit negative-evidence limitations**. All 648 test-class/name/TRX links were independently resolved to passing cases. The six limitations concern Stacklight, retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit, Obligation and MaintenanceMode. They are not claims that every invalid-input or hardware-control behavior was tested. The undefined GMS requirement is outside the 102 defined CUs. No external certification claim is made. Retained baseline complete-assembly line coverage, with generated code retained and no source exclusions: | Family | Model | Server | Client | | --- | ---: | ---: | ---: | | MachineTool/WMTP | 83.80% | 91.98% | 84.74% | | GMS | 95.51% | 90.63% | 88.38% | | CranesHoists | 92.76% | 94.45% | 92.24% | These nine measurements predate the later focused review fixes and merges; they are not relabelled as new `ecc4a7d6c` percentages. The current head's separate CI code-coverage check succeeded. No new exclusions or threshold relaxations were introduced. ## Related Issues - Fixes [#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764), the implementation/design and acceptance tracker. ## 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. - [x] 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. - [x] I have addressed **all** PR feedback received.
650 lines
29 KiB
C#
650 lines
29 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/
|
|
* ======================================================================*/
|
|
|
|
#if NET10_0
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.TestFramework;
|
|
using Opc.Ua.ISA95.Server.Providers;
|
|
using Opc.Ua.MachineTool;
|
|
using Opc.Ua.MachineTool.Server;
|
|
using Opc.Ua.Machinery;
|
|
using Opc.Ua.Machinery.Jobs;
|
|
using Opc.Ua.Machinery.Server;
|
|
using Opc.Ua.Machinery.Server.Builders;
|
|
using Opc.Ua.Server;
|
|
using Opc.Ua.Server.FileSystem;
|
|
using Opc.Ua.Server.TestFramework;
|
|
using Opc.Ua.Tests;
|
|
using ISession = Opc.Ua.Client.ISession;
|
|
using V2 = Opc.Ua.ISA95.JobControl.V2;
|
|
|
|
namespace Opc.Ua.Tools.Tests
|
|
{
|
|
/// <summary>
|
|
/// Composes actual MachineTool job sources and file providers behind real authenticated opc.tcp sessions.
|
|
/// </summary>
|
|
internal sealed class MachineToolServiceMcpFixture : IAsyncDisposable
|
|
{
|
|
/// <summary>
|
|
/// Creates isolated source engines, bounded provider mocks forwarding to real storage, and short local PKI.
|
|
/// </summary>
|
|
internal MachineToolServiceMcpFixture()
|
|
{
|
|
m_server = new ServerFixture<ServiceServer>(telemetry => new ServiceServer(telemetry, m_password))
|
|
{
|
|
SecurityNone = true,
|
|
AutoAccept = true
|
|
};
|
|
Source = CreateSource();
|
|
IndependentSource = CreateSource();
|
|
m_receiver.Setup(receiver => receiver.ReceiveJobOrderAsync(
|
|
It.IsAny<Isa95JobOrderOperationV2>(), It.IsAny<V2.ISA95JobOrderDataType>(),
|
|
It.IsAny<ArrayOf<LocalizedText>>(), It.IsAny<CancellationToken>()))
|
|
.Returns((Isa95JobOrderOperationV2 operation, V2.ISA95JobOrderDataType order,
|
|
ArrayOf<LocalizedText> comments, CancellationToken ct) =>
|
|
{
|
|
ReceivedEncodingMask = order.EncodingMask;
|
|
return Source.ReceiveJobOrderAsync(operation, order, comments, ct);
|
|
});
|
|
Physical = new PhysicalFileSystemProvider(Path.Combine(m_root, "content"));
|
|
IndependentFiles = new PhysicalFileSystemProvider(Path.Combine(m_root, "other"));
|
|
Provider.SetupGet(provider => provider.MountName).Returns("Services");
|
|
Provider.SetupGet(provider => provider.IsWritable).Returns(() => !DenyWrites);
|
|
Provider.Setup(provider => provider.GetEntryAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => Physical.GetEntryAsync(path, ct));
|
|
Provider.Setup(provider => provider.EnumerateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => Physical.EnumerateAsync(path, ct));
|
|
Provider.Setup(provider => provider.OpenReadAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => OpenAsync(path, null, ct));
|
|
Provider.Setup(provider => provider.OpenWriteAsync(
|
|
It.IsAny<string>(), It.IsAny<FileWriteMode>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, FileWriteMode mode, CancellationToken ct) => OpenAsync(path, mode, ct));
|
|
Provider.Setup(provider => provider.CreateDirectoryAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => Physical.CreateDirectoryAsync(path, ct));
|
|
Provider.Setup(provider => provider.CreateFileAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) => Physical.CreateFileAsync(path, ct));
|
|
Provider.Setup(provider => provider.DeleteAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string path, CancellationToken ct) =>
|
|
{
|
|
Mutations.Enqueue("Delete");
|
|
return Physical.DeleteAsync(path, ct);
|
|
});
|
|
Provider.Setup(provider => provider.MoveAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string from, string to, CancellationToken ct) => Physical.MoveAsync(from, to, ct));
|
|
Provider.Setup(provider => provider.CopyAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.Returns((string from, string to, CancellationToken ct) => Physical.CopyAsync(from, to, ct));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the authorized real UA session.
|
|
/// </summary>
|
|
internal ISession Session { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Gets another authorized session with a different session identity and file-handle ownership.
|
|
/// </summary>
|
|
internal ISession Peer { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Gets an actual anonymous UA session, denied standard file mutations and configured job controls.
|
|
/// </summary>
|
|
internal ISession Anonymous { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Gets the full machine and its actual nested services.
|
|
/// </summary>
|
|
internal MachineToolRuntime<MachineToolState> Machine { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Gets another machine with independent jobs/files and no WorkMasters selection.
|
|
/// </summary>
|
|
internal MachineToolRuntime<MachineToolState> Independent { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Gets a machine without either optional service.
|
|
/// </summary>
|
|
internal MachineToolRuntime<MachineToolState> Minimal { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Gets the real source that composes the existing ISA-95 engine and explicit responses/catalog.
|
|
/// </summary>
|
|
internal MachineToolJobSource Source { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the isolated second source.
|
|
/// </summary>
|
|
internal MachineToolJobSource IndependentSource { get; }
|
|
|
|
/// <summary>
|
|
/// Gets real storage exposed only through the standard runtime's file binding.
|
|
/// </summary>
|
|
internal PhysicalFileSystemProvider Physical { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the second root's independent storage.
|
|
/// </summary>
|
|
internal PhysicalFileSystemProvider IndependentFiles { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the forwarding provider used to observe resource lifetime and simulate delayed storage.
|
|
/// </summary>
|
|
internal Mock<IFileSystemProvider> Provider { get; } = new(MockBehavior.Strict);
|
|
|
|
/// <summary>
|
|
/// Gets or sets a provider refusal policy independent of caller consent and authenticated identity.
|
|
/// </summary>
|
|
internal bool DenyWrites { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets the number of provider streams not yet closed by the standard file handle binder.
|
|
/// </summary>
|
|
internal int ActiveStreams => Volatile.Read(ref m_activeStreams);
|
|
|
|
/// <summary>
|
|
/// Gets the actual decoded wire mask before the reference engine's canonical V1/V2 normalization.
|
|
/// </summary>
|
|
internal uint ReceivedEncodingMask { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets observed writes/deletes to detect implicit retry or compensation.
|
|
/// </summary>
|
|
internal ConcurrentQueue<string> Mutations { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Gets the actual generated Jobs state, for asserting namespace-qualified service binding.
|
|
/// </summary>
|
|
internal JobManagementState Management { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Starts isolated storage, the real endpoint, full/minimal machine instances, and three real sessions.
|
|
/// </summary>
|
|
internal async Task StartAsync()
|
|
{
|
|
await Physical.CreateDirectoryAsync("WorkMasters", default).ConfigureAwait(false);
|
|
await SeedAsync(Physical, "file.bin", new byte[] { 1, 2, 3, 4 }).ConfigureAwait(false);
|
|
await SeedAsync(Physical, "WorkMasters/program.nc", new byte[] { 5, 6, 7 }).ConfigureAwait(false);
|
|
await SeedAsync(Physical, "large.bin", new byte[131072]).ConfigureAwait(false);
|
|
await SeedAsync(Physical, "pause.bin", new byte[] { 8, 9, 10 }).ConfigureAwait(false);
|
|
await SeedAsync(Physical, "empty.bin", ReadOnlyMemory<byte>.Empty).ConfigureAwait(false);
|
|
await SeedAsync(IndependentFiles, "file.bin", new byte[] { 99 }).ConfigureAwait(false);
|
|
await m_server.LoadConfigurationAsync(Path.Combine(m_root, "s")).ConfigureAwait(false);
|
|
m_server.Config.ServerConfiguration!.UserTokenPolicies =
|
|
[
|
|
new UserTokenPolicy(UserTokenType.Anonymous),
|
|
new UserTokenPolicy(UserTokenType.UserName) { SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }
|
|
];
|
|
ServiceServer server = await m_server.StartAsync().ConfigureAwait(false);
|
|
IMachineryBuildContext context = server.Manager.CreateMachineryBuildContext();
|
|
Machine = await Define(context, "Services", Source)
|
|
.WithProduction(new MachineToolProductionOptions
|
|
{
|
|
Features = MachineToolFeatures.Production | MachineToolFeatures.DynamicProductionPlan
|
|
})
|
|
.WithJobManagement(new MachineToolJobManagementOptions
|
|
{
|
|
Receiver = m_receiver.Object,
|
|
ResponseProvider = Source,
|
|
Catalog = Source
|
|
})
|
|
.WithFileSystem(Provider.Object, includeWorkMasters: true)
|
|
.BuildAsync().ConfigureAwait(false);
|
|
var fileChildren = new List<BaseInstanceState>();
|
|
Machine.State.FileSystem!.GetChildren(context.Context, fileChildren);
|
|
FileState paused = fileChildren.OfType<FileState>()
|
|
.Single(file => file.BrowseName.Name == "pause.bin");
|
|
paused.MaxByteStringLength!.Value = 1;
|
|
AuthorizeFiles(Machine.State.FileSystem, context.Context);
|
|
Independent = await Define(context, "Independent", IndependentSource)
|
|
.WithJobManagement(new MachineToolJobManagementOptions
|
|
{
|
|
Receiver = IndependentSource,
|
|
ResponseProvider = IndependentSource,
|
|
Catalog = IndependentSource
|
|
})
|
|
.WithFileSystem(IndependentFiles)
|
|
.BuildAsync().ConfigureAwait(false);
|
|
Minimal = await Define(context, "Minimal", new InMemoryMachineToolProvider(new MachineToolSnapshot()))
|
|
.BuildAsync().ConfigureAwait(false);
|
|
BaseInstanceState blocks = Machine.State.FindChild(context.Context,
|
|
new QualifiedName("MachineryBuildingBlocks",
|
|
server.Manager.SystemContext.NamespaceUris.GetIndexOrAppend(
|
|
Opc.Ua.Machinery.Namespaces.Machinery)))!;
|
|
Management = (JobManagementState)blocks.FindChild(context.Context,
|
|
new QualifiedName(Opc.Ua.Machinery.Jobs.BrowseNames.JobManagement,
|
|
server.Manager.SystemContext.NamespaceUris.GetIndexOrAppend(
|
|
Opc.Ua.Machinery.Jobs.Namespaces.MachineryJobs)))!;
|
|
var methods = new List<BaseInstanceState>();
|
|
Management.JobOrderControl!.GetChildren(context.Context, methods);
|
|
foreach (MethodState method in methods.OfType<MethodState>())
|
|
{
|
|
method.OnReadUserExecutable = AuthorizeCaller;
|
|
}
|
|
Session = await ConnectAsync("a", true).ConfigureAwait(false);
|
|
Peer = await ConnectAsync("b", true).ConfigureAwait(false);
|
|
Anonymous = await ConnectAsync("c", false).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Observes the first real write or pauses a real asynchronous read until the test explicitly releases it.
|
|
/// </summary>
|
|
internal IoGate Pause(bool write)
|
|
{
|
|
var gate = new IoGate(write);
|
|
m_gate = gate;
|
|
return gate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates standard orders with an explicit known WorkMaster and optional priority field.
|
|
/// </summary>
|
|
internal static V2.ISA95JobOrderDataType Order(string id) => new()
|
|
{
|
|
EncodingMask = (uint)(V2.ISA95JobOrderDataTypeFields.WorkMasterID |
|
|
V2.ISA95JobOrderDataTypeFields.Priority),
|
|
JobOrderID = id,
|
|
WorkMasterID = [new V2.ISA95WorkMasterDataType { ID = "WM-1" }]
|
|
};
|
|
|
|
/// <summary>
|
|
/// Seeds application-owned provider data, not a second OPC UA file protocol.
|
|
/// </summary>
|
|
internal static async Task SeedAsync(IFileSystemProvider provider, string path, ReadOnlyMemory<byte> content)
|
|
{
|
|
Stream stream = await provider.OpenWriteAsync(path, FileWriteMode.Truncate, default).ConfigureAwait(false);
|
|
await using (stream.ConfigureAwait(false))
|
|
{
|
|
await stream.WriteAsync(content).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
m_gate?.Release.TrySetResult(true);
|
|
foreach (ISession session in m_sessions)
|
|
{
|
|
if (session.Connected)
|
|
{
|
|
await session.CloseAsync(1000, true).ConfigureAwait(false);
|
|
}
|
|
if (session is IAsyncDisposable asynchronous)
|
|
{
|
|
await asynchronous.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
session.Dispose();
|
|
}
|
|
}
|
|
foreach (ClientFixture client in m_clients)
|
|
{
|
|
await client.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
if (Machine != null)
|
|
{
|
|
await Machine.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
if (Independent != null)
|
|
{
|
|
await Independent.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
if (Minimal != null)
|
|
{
|
|
await Minimal.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
await Source.DisposeAsync().ConfigureAwait(false);
|
|
await IndependentSource.DisposeAsync().ConfigureAwait(false);
|
|
await m_server.StopAsync().ConfigureAwait(false);
|
|
CryptographicOperations.ZeroMemory(m_password);
|
|
if (Directory.Exists(m_root))
|
|
{
|
|
Directory.Delete(m_root, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates both command sessions with ephemeral fixture-only credentials.
|
|
/// </summary>
|
|
private async Task<ISession> ConnectAsync(string name, bool authenticated)
|
|
{
|
|
var client = new ClientFixture(NUnitTelemetryContext.Create());
|
|
m_clients.Add(client);
|
|
await client.LoadClientConfigurationAsync(Path.Combine(m_root, name), name).ConfigureAwait(false);
|
|
ISession session = await client.ConnectAsync(new Uri($"opc.tcp://localhost:{m_server.Port}"),
|
|
SecurityPolicies.Basic256Sha256,
|
|
userIdentity: authenticated ? new UserIdentity("operator", m_password) : new UserIdentity())
|
|
.ConfigureAwait(false);
|
|
m_sessions.Add(session);
|
|
return session;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uses server-side UserExecutable policy; MCP consent cannot turn an anonymous session into an operator.
|
|
/// </summary>
|
|
private static ServiceResult AuthorizeCaller(ISystemContext context, NodeState node, ref bool value)
|
|
{
|
|
value = context is ISessionSystemContext { UserIdentity.TokenType: UserTokenType.UserName };
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures the integrator-owned directory's server policy, independently of the stock provider policy.
|
|
/// </summary>
|
|
private static void AuthorizeFiles(FileDirectoryState directory, ISystemContext context)
|
|
{
|
|
var children = new List<BaseInstanceState>();
|
|
directory.GetChildren(context, children);
|
|
foreach (BaseInstanceState child in children)
|
|
{
|
|
if (child is MethodState method)
|
|
{
|
|
method.OnReadUserExecutable = AuthorizeCaller;
|
|
}
|
|
else if (child is FileDirectoryState nested)
|
|
{
|
|
AuthorizeFiles(nested, context);
|
|
}
|
|
else if (child is FileState file)
|
|
{
|
|
file.Open!.OnReadUserExecutable = AuthorizeCaller;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defines ordinary public MachineTool builder instances with application-owned providers.
|
|
/// </summary>
|
|
private static MachineToolBuilder<MachineToolState> Define(
|
|
IMachineryBuildContext context, string name, IMachineToolProvider provider)
|
|
{
|
|
return context.AddMachineTool(name).WithProvider(provider).WithIdentification(identity =>
|
|
{
|
|
identity.Manufacturer = new LocalizedText("Service MCP tests");
|
|
identity.SerialNumber = name;
|
|
identity.ProductInstanceUri = "urn:service-mcp:" + name;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares independent source mappings, not implicit job-to-program guesses.
|
|
/// </summary>
|
|
private static MachineToolJobSource CreateSource()
|
|
{
|
|
return new MachineToolJobSource(new MachineToolJobSourceOptions
|
|
{
|
|
Jobs = Enumerable.Range(0, 32).Select(index => new MachineToolJobSourceDescriptor
|
|
{
|
|
JobOrderId = "Job-" + index,
|
|
WorkMasterId = "WM-1",
|
|
ProgramName = "PROGRAM" + index + ".NC"
|
|
}).ToArrayOf(),
|
|
MaximumPendingRevisions = 1024
|
|
}, new MachineToolSnapshot
|
|
{
|
|
Operation = new MachineToolOperation { Mode = MachineOperationMode.Manual }
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Observes actual provider streams without bypassing any standard server or client method.
|
|
/// </summary>
|
|
private async ValueTask<Stream> OpenAsync(string path, FileWriteMode? mode, CancellationToken ct)
|
|
{
|
|
Stream stream;
|
|
if (mode.HasValue)
|
|
{
|
|
if (DenyWrites)
|
|
{
|
|
throw new UnauthorizedAccessException("The application file provider refuses this write.");
|
|
}
|
|
Mutations.Enqueue("Write");
|
|
stream = await Physical.OpenWriteAsync(path, mode.Value, ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
stream = await Physical.OpenReadAsync(path, ct).ConfigureAwait(false);
|
|
}
|
|
Interlocked.Increment(ref m_activeStreams);
|
|
IoGate? gate = path == "pause.bin" && m_gate?.Write == mode.HasValue ? m_gate : null;
|
|
return new ObservedStream(stream, gate, () => Interlocked.Decrement(ref m_activeStreams));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the real companion models and the modern server identity authenticator.
|
|
/// </summary>
|
|
internal sealed class ServiceServer : StandardServer
|
|
{
|
|
/// <summary>
|
|
/// Creates an isolated server whose credential exists only for this fixture lifetime.
|
|
/// </summary>
|
|
internal ServiceServer(ITelemetryContext telemetry, byte[] password)
|
|
: base(telemetry)
|
|
{
|
|
m_password = password;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the real shared Machinery node manager.
|
|
/// </summary>
|
|
internal MachineryNodeManager Manager { get; private set; } = null!;
|
|
|
|
/// <inheritdoc/>
|
|
protected override ValueTask<IMasterNodeManager> CreateMasterNodeManagerAsync(
|
|
IServerInternal server, ApplicationConfiguration configuration,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var options = new MachineryServerOptions
|
|
{
|
|
Parts = MachineryParts.BuildingBlocks | MachineryParts.Jobs
|
|
};
|
|
Manager = new MachineryNodeManager(server, configuration,
|
|
[new MachineryModelProvider(options.Parts), new MachineToolModelProvider()], options);
|
|
return new ValueTask<IMasterNodeManager>(
|
|
new MasterNodeManager(server, configuration, null, [Manager]));
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void OnServerStarted(IServerInternal server)
|
|
{
|
|
base.OnServerStarted(server);
|
|
server.IdentityRegistry.Register(new AnonymousAuthenticator());
|
|
server.IdentityRegistry.Register(new UserNamePasswordAuthenticator((token, ct) =>
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (token.UserName != "operator" || token.DecryptedPassword == null ||
|
|
!CryptographicOperations.FixedTimeEquals(token.DecryptedPassword, m_password))
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
return new ValueTask<IUserIdentity>(new UserIdentity(token));
|
|
}));
|
|
}
|
|
|
|
/// <summary>The ephemeral test credential, erased by the owning fixture.</summary>
|
|
private readonly byte[] m_password;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Signals actual provider I/O and stream disposal, with an optional asynchronous read barrier.
|
|
/// </summary>
|
|
internal sealed class IoGate
|
|
{
|
|
/// <summary>
|
|
/// Selects a write observation or asynchronous read pause.
|
|
/// </summary>
|
|
internal IoGate(bool write)
|
|
{
|
|
Write = write;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the selected operation.
|
|
/// </summary>
|
|
internal bool Write { get; }
|
|
|
|
/// <summary>
|
|
/// Gets a signal after the real file handle has reached provider I/O.
|
|
/// </summary>
|
|
internal TaskCompletionSource<bool> Entered { get; } =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
/// <summary>
|
|
/// Gets the test-controlled continuation, always released in cleanup.
|
|
/// </summary>
|
|
internal TaskCompletionSource<bool> Release { get; } =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
/// <summary>
|
|
/// Gets a signal after the server closes the actual provider stream.
|
|
/// </summary>
|
|
internal TaskCompletionSource<bool> Closed { get; } =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps a real backing stream to observe writes/disposal and pause asynchronous reads.
|
|
/// </summary>
|
|
private sealed class ObservedStream(Stream inner, IoGate? gate, Action onClose) : Stream
|
|
{
|
|
/// <inheritdoc/>
|
|
public override bool CanRead => inner.CanRead;
|
|
|
|
/// <inheritdoc/>
|
|
public override bool CanSeek => inner.CanSeek;
|
|
|
|
/// <inheritdoc/>
|
|
public override bool CanWrite => inner.CanWrite;
|
|
|
|
/// <inheritdoc/>
|
|
public override long Length => inner.Length;
|
|
|
|
/// <inheritdoc/>
|
|
public override long Position
|
|
{
|
|
get => inner.Position;
|
|
set => inner.Position = value;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Flush() => inner.Flush();
|
|
|
|
/// <inheritdoc/>
|
|
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
|
|
|
/// <inheritdoc/>
|
|
public override void SetLength(long value) => inner.SetLength(value);
|
|
|
|
/// <inheritdoc/>
|
|
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
|
|
|
/// <inheritdoc/>
|
|
public override void Write(byte[] buffer, int offset, int count)
|
|
{
|
|
inner.Write(buffer, offset, count);
|
|
if (gate is { Write: true })
|
|
{
|
|
gate.Entered.TrySetResult(true);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
|
|
{
|
|
if (gate is { Write: false })
|
|
{
|
|
gate.Entered.TrySetResult(true);
|
|
await gate.Release.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
}
|
|
return await inner.ReadAsync(buffer, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
|
|
{
|
|
if (gate is { Write: true })
|
|
{
|
|
gate.Entered.TrySetResult(true);
|
|
}
|
|
await inner.WriteAsync(buffer, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing && Interlocked.Exchange(ref m_disposed, 1) == 0)
|
|
{
|
|
inner.Dispose();
|
|
onClose();
|
|
gate?.Closed.TrySetResult(true);
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
/// <summary>The idempotent close gate for counter accounting.</summary>
|
|
private int m_disposed;
|
|
}
|
|
|
|
/// <summary>Short, repository-local, fixture-owned certificate and provider storage.</summary>
|
|
private readonly string m_root = Path.GetFullPath(Path.Combine(".ms-pki", Guid.NewGuid().ToString("N")[..8]));
|
|
|
|
/// <summary>Ephemeral credentials; never a real or hardcoded password.</summary>
|
|
private readonly byte[] m_password = System.Text.Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N"));
|
|
|
|
/// <summary>The real server fixture.</summary>
|
|
private readonly ServerFixture<ServiceServer> m_server;
|
|
|
|
/// <summary>Observes native decoded requests and forwards every verb to the actual source engine.</summary>
|
|
private readonly Mock<IIsa95JobOrderReceiverV2> m_receiver = new(MockBehavior.Strict);
|
|
|
|
/// <summary>The connected clients, disposed after their sessions.</summary>
|
|
private readonly List<ClientFixture> m_clients = [];
|
|
|
|
/// <summary>The real sessions owned exclusively by the fixture.</summary>
|
|
private readonly List<ISession> m_sessions = [];
|
|
|
|
/// <summary>The optional bounded storage pause for the next selected file open.</summary>
|
|
private IoGate? m_gate;
|
|
|
|
/// <summary>The number of server-owned provider streams still open.</summary>
|
|
private int m_activeStreams;
|
|
}
|
|
}
|
|
#endif
|