/* ========================================================================
* 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.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.ISA95.Server.Providers;
using Opc.Ua.MachineTool.Server;
using V2 = Opc.Ua.ISA95.JobControl.V2;
using static Opc.Ua.MachineTool.Tests.MachineToolJobSourceTests;
namespace Opc.Ua.MachineTool.Tests
{
///
/// Verifies bounded admission and that every refusal preserves both accepted representations.
///
[TestFixture]
[Category("MachineTool")]
public sealed class MachineToolJobSourceValidationTests
{
///
/// Resource bounds are checked before a live engine or observation can be created.
///
[TestCase("Orders")]
[TestCase("Responses")]
[TestCase("Revisions")]
[TestCase("Subscribers")]
[TestCase("Changes")]
[TestCase("Payload")]
[TestCase("Text")]
[TestCase("Array")]
[TestCase("Descriptors")]
public void InvalidOptionsAreRejected(string invalid)
{
MachineToolJobSourceOptions options = invalid switch
{
"Orders" => Options() with { MaximumJobOrders = 0 },
"Responses" => Options() with { MaximumJobResponses = 0 },
"Revisions" => Options() with { MaximumPendingRevisions = 1 },
"Subscribers" => Options() with { MaximumSubscribers = 0 },
"Changes" => Options() with { MaximumPendingChanges = 0 },
"Payload" => Options() with { MaximumPayloadBytes = 127 },
"Text" => Options() with { MaximumTextLength = 0 },
"Array" => Options() with { MaximumArrayLength = 0 },
_ => Options() with { MaximumArrayLength = 1, Jobs = [Descriptor(), Descriptor("Job-2")] }
};
Assert.Throws(() => _ = new MachineToolJobSource(options));
}
///
/// Required program identities and quantity mappings are supplied by applications, never synthesized.
///
[TestCase("Job")]
[TestCase("Master")]
[TestCase("Program")]
[TestCase("Runs")]
[TestCase("PartsWithoutSet")]
[TestCase("SetWithoutParts")]
[TestCase("SetWithoutName")]
[TestCase("Overflow")]
[TestCase("Null")]
[TestCase("Duplicate")]
public void InvalidDescriptorsAreRejected(string invalid)
{
MachineToolJobSourceDescriptor descriptor = invalid switch
{
"Job" => Descriptor() with { JobOrderId = " " },
"Master" => Descriptor() with { WorkMasterId = string.Empty },
"Program" => Descriptor() with { ProgramName = string.Empty },
"Runs" => Descriptor() with { RunsPlanned = 0 },
"PartsWithoutSet" => Descriptor() with { PartsPlannedPerRun = 1 },
"SetWithoutParts" => Descriptor() with { PartSetKey = "Set", PartSetName = "Parts" },
"SetWithoutName" => Descriptor() with { PartSetKey = "Set", PartsPlannedPerRun = 1 },
"Overflow" => Descriptor() with
{
PartSetKey = "Set",
PartSetName = "Parts",
PartsPlannedPerRun = uint.MaxValue,
RunsPlanned = 2
},
"Null" => null!,
_ => Descriptor()
};
MachineToolJobSourceOptions options = invalid == "Duplicate"
? Options() with { Jobs = [descriptor, descriptor] }
: Options() with { Jobs = [descriptor] };
Assert.Throws(() => _ = new MachineToolJobSource(options));
}
///
/// Identity, count, nesting, Variant and total encoded-size bounds apply before invoking the engine.
///
[TestCase("MissingMaster")]
[TestCase("WrongMaster")]
[TestCase("UnknownJob")]
[TestCase("MissingId")]
[TestCase("Mask")]
[TestCase("String")]
[TestCase("Array")]
[TestCase("Bytes")]
[TestCase("Total")]
[TestCase("Nesting")]
[TestCase("Comments")]
public async Task InvalidOrOversizedOrderNeverCommitsAsync(string invalid)
{
await using var source = new MachineToolJobSource(Options() with
{
MaximumPayloadBytes = 256,
MaximumArrayLength = 8,
MaximumTextLength = 128
});
V2.ISA95JobOrderDataType order = Order();
ArrayOf comments = default;
switch (invalid)
{
case "MissingMaster":
order.WorkMasterID = [];
break;
case "WrongMaster":
order.WorkMasterID[0].ID = "Not supplied";
break;
case "UnknownJob":
order.JobOrderID = "Not supplied";
break;
case "MissingId":
order.JobOrderID = null;
break;
case "Mask":
order.EncodingMask = 0;
break;
case "String":
order.EncodingMask |= (uint)V2.ISA95JobOrderDataTypeFields.Description;
order.Description = [new LocalizedText(new string('x', 129))];
break;
case "Array":
order.EncodingMask |= (uint)V2.ISA95JobOrderDataTypeFields.Description;
order.Description = new LocalizedText[9].ToArrayOf();
break;
case "Bytes":
order.EncodingMask |= (uint)V2.ISA95JobOrderDataTypeFields.JobOrderParameters;
order.JobOrderParameters =
[
new V2.ISA95ParameterDataType { Value = Variant.From(new ByteString(new byte[257])) }
];
break;
case "Total":
order.EncodingMask |= (uint)V2.ISA95JobOrderDataTypeFields.Description;
order.Description =
[
new LocalizedText(new string('x', 120)), new LocalizedText(new string('y', 120))
];
break;
case "Nesting":
order.EncodingMask |= (uint)V2.ISA95JobOrderDataTypeFields.JobOrderParameters;
var parameter = new V2.ISA95ParameterDataType
{
ID = "Recursive",
EncodingMask = (uint)V2.ISA95ParameterDataTypeFields.Subparameters
};
parameter.Subparameters = [parameter];
order.JobOrderParameters = [parameter];
break;
case "Comments":
comments = [new LocalizedText(new string('x', 129))];
break;
}
Isa95JobOrderReceiptV2 receipt = await source.ReceiveJobOrderAsync(
Isa95JobOrderOperationV2.Store, order, comments).ConfigureAwait(false);
Assert.That(receipt.ReturnStatus, Is.EqualTo(Isa95JobReturnStatus.InvalidRequest));
Assert.That(ServiceResult.IsGood(receipt.Result), Is.False);
MachineToolJobSourceCapture capture = await source.CaptureAsync().ConfigureAwait(false);
Assert.That(capture.Snapshot.Revision, Is.Zero);
Assert.That(capture.Snapshot.Production.Jobs, Is.Empty);
Assert.That(capture.JobOrders, Is.Empty);
}
///
/// The existing protocol engine remains authoritative about valid order operations.
///
[TestCase(Isa95JobOrderOperationV2.Store, false)]
[TestCase(Isa95JobOrderOperationV2.Pause, false)]
[TestCase(Isa95JobOrderOperationV2.Resume, false)]
[TestCase(Isa95JobOrderOperationV2.Stop, false)]
[TestCase(Isa95JobOrderOperationV2.Clear, false)]
[TestCase(Isa95JobOrderOperationV2.RevokeStart, false)]
[TestCase(Isa95JobOrderOperationV2.Update, true)]
[TestCase(Isa95JobOrderOperationV2.Cancel, true)]
[TestCase(Isa95JobOrderOperationV2.Start, true)]
[TestCase((Isa95JobOrderOperationV2)999, false)]
public async Task EngineRefusalDoesNotPublishAStagedProjectionAsync(
Isa95JobOrderOperationV2 operation, bool execute)
{
await using var source = new MachineToolJobSource(Options());
if (execute)
{
await StartExecutingAsync(source).ConfigureAwait(false);
}
else
{
Success(await source.ReceiveJobOrderAsync(Isa95JobOrderOperationV2.Store, Order())
.ConfigureAwait(false));
}
MachineToolJobSourceCapture before = await source.CaptureAsync().ConfigureAwait(false);
Isa95JobOrderReceiptV2 receipt = await source.ReceiveJobOrderAsync(operation, Order())
.ConfigureAwait(false);
Assert.That(receipt.ReturnStatus, Is.Not.EqualTo(Isa95JobReturnStatus.Success));
MachineToolJobSourceCapture after = await source.CaptureAsync().ConfigureAwait(false);
Assert.That(after.Snapshot, Is.SameAs(before.Snapshot));
Assert.That(after.JobOrders[0].IsEqual(before.JobOrders[0]), Is.True);
}
///
/// Each canonical nonterminal state can be aborted without falsely completing any planned run.
///
[TestCase(0)]
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public async Task AbortPreservesZeroCompletedRunsFromEveryNonterminalStateAsync(int stage)
{
await using var source = new MachineToolJobSource(Options());
Success(await source.ReceiveJobOrderAsync(stage == 0
? Isa95JobOrderOperationV2.Store : Isa95JobOrderOperationV2.StoreAndStart, Order())
.ConfigureAwait(false));
if (stage >= 2)
{
Success(await source.TransitionAsync("Job-1", Isa95JobExecutionTransition.BeginExecution)
.ConfigureAwait(false));
}
if (stage == 3)
{
Success(await source.ReceiveJobOrderAsync(Isa95JobOrderOperationV2.Pause, Order())
.ConfigureAwait(false));
}
Success(await source.ReceiveJobOrderAsync(Isa95JobOrderOperationV2.Abort, Order()).ConfigureAwait(false));
MachineToolJobSourceCapture captured = await source.CaptureAsync().ConfigureAwait(false);
Assert.That(captured.JobOrders[0].State[0].StateNumber, Is.EqualTo(6));
Assert.That(captured.Snapshot.Production.Jobs[0].Phase, Is.EqualTo(MachineToolProductionPhase.Aborted));
Assert.That(captured.Snapshot.Production.Jobs[0].RunsCompleted, Is.Zero);
}
///
/// Intermediate run completions do not require fabricated part data when no part monitoring was requested.
///
[TestCase(false)]
[TestCase(true)]
public async Task ExplicitCompletionDistinguishesEarlyTerminationFromTheLastPlannedRunAsync(bool allRuns)
{
await using var source = new MachineToolJobSource(Options(Descriptor() with { RunsPlanned = 2 }));
await StartExecutingAsync(source).ConfigureAwait(false);
if (allRuns)
{
await source.CompleteRunAsync("Job-1").ConfigureAwait(false);
}
Success(await source.TransitionAsync("Job-1", Isa95JobExecutionTransition.Complete).ConfigureAwait(false));
MachineToolProductionJob job = (await source.ReadAsync().ConfigureAwait(false)).Production.Jobs[0];
Assert.That(job.Phase, Is.EqualTo(allRuns
? MachineToolProductionPhase.Ended : MachineToolProductionPhase.Aborted));
Assert.That(job.RunsCompleted, Is.EqualTo(allRuns ? 2 : 0));
Assert.That(job.PartSets, Is.Empty);
}
///
/// Capacity is the engine's real order/response capacity, and a second job cannot start on occupied equipment.
///
[Test]
public async Task EngineCapacityAndExplicitExecutionAdmissionPreserveStateAsync()
{
await using var source = new MachineToolJobSource(Options() with
{
Jobs = [Descriptor(), Descriptor("Job-2"), Descriptor("Job-3")],
MaximumJobOrders = 2,
MaximumJobResponses = 1
});
await StartExecutingAsync(source).ConfigureAwait(false);
Success(await source.ReceiveJobOrderAsync(Isa95JobOrderOperationV2.StoreAndStart, Order("Job-2"))
.ConfigureAwait(false));
Assert.That((await source.ReceiveJobOrderAsync(Isa95JobOrderOperationV2.Store, Order("Job-3"))
.ConfigureAwait(false)).ReturnStatus, Is.EqualTo(Isa95JobReturnStatus.UnableToAccept));
Assert.That((await source.TransitionAsync("Job-2", Isa95JobExecutionTransition.BeginExecution)
.ConfigureAwait(false)).ReturnStatus, Is.EqualTo(Isa95JobReturnStatus.UnableToAccept));
Assert.That((await source.TransitionAsync("Unknown", Isa95JobExecutionTransition.BeginExecution)
.ConfigureAwait(false)).ReturnStatus, Is.Not.EqualTo(Isa95JobReturnStatus.Success));
Assert.That((await source.TransitionAsync("Job-1", (Isa95JobExecutionTransition)999)
.ConfigureAwait(false)).ReturnStatus, Is.EqualTo(Isa95JobReturnStatus.InvalidRequest));
MachineToolJobSourceCapture captured = await source.CaptureAsync().ConfigureAwait(false);
var response = new V2.ISA95JobResponseDataType
{
JobResponseID = "R1",
JobOrderID = "Job-1",
JobState = captured.JobOrders[0].State
};
Assert.That((await source.ReceiveJobResponseAsync(response).ConfigureAwait(false)).ReturnStatus,
Is.EqualTo(Isa95JobReturnStatus.Success));
ulong revision = (await source.ReadAsync().ConfigureAwait(false)).Revision;
response.JobResponseID = "R2";
Assert.That((await source.ReceiveJobResponseAsync(response).ConfigureAwait(false)).ReturnStatus,
Is.EqualTo(Isa95JobReturnStatus.UnableToAccept));
Assert.That((await source.ReadAsync().ConfigureAwait(false)).Revision, Is.EqualTo(revision));
}
///
/// Invalid current-run counts cannot revise cumulative history or finish an unobserved run.
///
[Test]
public async Task InvalidPartObservationsAndUnaccountedCompletionPreserveRevisionAsync()
{
await using var source = new MachineToolJobSource(Options(Descriptor() with
{
PartSetKey = "Set",
PartSetName = "Parts",
PartsPlannedPerRun = 3
}));
await StartExecutingAsync(source).ConfigureAwait(false);
await source.RecordPartsAsync("Job-1", new MachineToolJobSourcePartCounts(2, 1, Acquired))
.ConfigureAwait(false);
MachineToolSnapshot before = await source.ReadAsync().ConfigureAwait(false);
foreach (MachineToolJobSourcePartCounts invalid in new[]
{
new MachineToolJobSourcePartCounts(1, 1, Acquired),
new MachineToolJobSourcePartCounts(4, 1, Acquired),
new MachineToolJobSourcePartCounts(2, 3, Acquired),
new MachineToolJobSourcePartCounts(2, 0, Acquired),
new MachineToolJobSourcePartCounts(2, 1, new MachineToolSampleInfo(default, StatusCodes.Good))
})
{
await Assert.ThrowsAsync(async () =>
await source.RecordPartsAsync("Job-1", invalid).ConfigureAwait(false));
}
Assert.That((await source.TransitionAsync("Job-1", Isa95JobExecutionTransition.Complete)
.ConfigureAwait(false)).ReturnStatus, Is.EqualTo(Isa95JobReturnStatus.UnableToAccept));
await Assert.ThrowsAsync(async () =>
await source.CompleteRunAsync("Job-1").ConfigureAwait(false));
await Assert.ThrowsAsync(async () =>
await source.RecordPartsAsync("Unknown", new MachineToolJobSourcePartCounts()).ConfigureAwait(false));
Assert.That(await source.ReadAsync().ConfigureAwait(false), Is.SameAs(before));
}
///
/// A monitoring acquisition retains supplied values and metadata instead of deriving measurements from jobs.
///
[Test]
public async Task RawMonitoringAndLegacyStatisticsArePreservedWithoutInventedDataAsync()
{
await using var source = new MachineToolJobSource(Options());
var monitoring = new MachineToolSnapshot
{
Operation = new MachineToolOperation { Sample = Acquired, PartsProducedInLifetime = 42 },
Channels = [new MachineToolChannel { Name = "NC", CombinedChannels = ["Raw"], Sample = Acquired }],
Software = [new MachineToolSoftware("Controller", "2", new LocalizedText("Vendor"))],
Spindles = [new MachineToolSpindle("S", false, Acquired)],
Lasers = [new MachineToolLaser("L", false, default, Acquired)],
EdmGenerators = [new MachineToolEdmGenerator("E", false, default, Acquired)],
Stacklights =
[
new MachineToolStacklight("Lamp", [new MachineToolLight(false, default, default, Acquired)])
],
Production = new MachineToolProductionObservation
{
Statistics = new MachineToolProductionStatistics(42, Acquired)
}
};
await source.SetMonitoringAsync(monitoring).ConfigureAwait(false);
MachineToolSnapshot current = await source.ReadAsync().ConfigureAwait(false);
Assert.That(current.Operation, Is.SameAs(monitoring.Operation));
Assert.That(current.Channels, Is.EqualTo(monitoring.Channels));
Assert.That(current.Spindles, Is.EqualTo(monitoring.Spindles));
Assert.That(current.Lasers, Is.EqualTo(monitoring.Lasers));
Assert.That(current.EdmGenerators, Is.EqualTo(monitoring.EdmGenerators));
Assert.That(current.Stacklights, Is.EqualTo(monitoring.Stacklights));
Assert.That(current.Production.Statistics, Is.SameAs(monitoring.Production.Statistics));
await Assert.ThrowsAsync(async () => await source.SetMonitoringAsync(monitoring with
{
Operation = monitoring.Operation with { PartsProducedInLifetime = 41 },
Production = monitoring.Production with
{
Statistics = new MachineToolProductionStatistics(41, Acquired)
}
}).ConfigureAwait(false));
Assert.That(await source.ReadAsync().ConfigureAwait(false), Is.SameAs(current));
}
}
}