/* ========================================================================
* Copyright (c) 2005-2025 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.Linq;
using Generators;
using GeneratorModel = Opc.Ua.Generators;
using NUnit.Framework;
namespace Opc.Ua.OpenUsd.Tests.Generator
{
///
/// Holds the simulated operating state machine to what the model declares.
///
///
/// The sample keeps two descriptions of the same state machine: a C# enum plus
/// , which the physics uses,
/// and , which translates it onto the numbers
/// GeneratorStateMachineType declares. Two descriptions of one thing
/// drift, and when they do, a server reports a machine as running while its
/// physics say it is stopped and nothing at run time notices. These tests are
/// what stops that.
///
[TestFixture]
[Category("Generators")]
public sealed class GeneratorStateMachineTests
{
///
/// Every simulated state has a number in the model.
///
[Test]
public void EveryStateHasAModelStateNumber()
{
foreach (GeneratorRunState state in Enum.GetValues(typeof(GeneratorRunState))
.Cast())
{
Assert.That(
GeneratorStateMap.StateNumbers.ContainsKey(state),
Is.True,
$"{state} has no state number.");
}
}
///
/// No two states share a number, or a client cannot tell them apart.
///
[Test]
public void StateNumbersAreDistinct()
{
Assert.That(
GeneratorStateMap.StateNumbers.Values.Distinct().Count(),
Is.EqualTo(GeneratorStateMap.StateNumbers.Count));
}
///
/// The two descriptions of the state machine agree in both directions.
///
///
/// A transition the physics permits but the map lacks would move a machine
/// without telling a client; one the map holds but the physics refuses would
/// be dead weight that looks supported.
///
[Test]
public void LegalTransitionsAndModelTransitionsAgree()
{
var states = Enum.GetValues(typeof(GeneratorRunState))
.Cast()
.ToArray();
var missingFromMap = new List();
var missingFromSimulation = new List();
foreach (GeneratorRunState from in states)
{
foreach (GeneratorRunState to in states)
{
bool legal = GeneratorSimulation.IsLegalTransition(from, to);
bool mapped = GeneratorStateMap.TransitionNumbers.ContainsKey((from, to));
if (legal && !mapped)
{
missingFromMap.Add($"{from}->{to}");
}
if (mapped && !legal)
{
missingFromSimulation.Add($"{from}->{to}");
}
}
}
Assert.Multiple(() =>
{
Assert.That(
missingFromMap,
Is.Empty,
"Transitions the simulation permits but the model map lacks.");
Assert.That(
missingFromSimulation,
Is.Empty,
"Transitions the model map holds but the simulation refuses.");
});
}
///
/// No two transitions share a number.
///
[Test]
public void TransitionNumbersAreDistinct()
{
Assert.That(
GeneratorStateMap.TransitionNumbers.Values.Distinct().Count(),
Is.EqualTo(GeneratorStateMap.TransitionNumbers.Count));
}
///
/// Every state can be reached from Off by legal moves.
///
///
/// A state no path reaches is a state the model declares and the sample
/// never shows, which is worse than not declaring it.
///
[Test]
public void EveryStateIsReachableFromOff()
{
var reached = new HashSet { GeneratorRunState.Off };
var queue = new Queue();
queue.Enqueue(GeneratorRunState.Off);
GeneratorRunState[] states = Enum.GetValues(typeof(GeneratorRunState))
.Cast()
.ToArray();
while (queue.Count > 0)
{
GeneratorRunState from = queue.Dequeue();
foreach (GeneratorRunState to in states)
{
if (GeneratorSimulation.IsLegalTransition(from, to) && reached.Add(to))
{
queue.Enqueue(to);
}
}
}
Assert.That(reached, Is.EquivalentTo(states));
}
///
/// Every state except Off can get back to Off, so no set can be stranded.
///
[Test]
public void EveryStateCanReturnToOff()
{
GeneratorRunState[] states = Enum.GetValues(typeof(GeneratorRunState))
.Cast()
.ToArray();
var canReturn = new HashSet { GeneratorRunState.Off };
bool grew;
do
{
grew = false;
foreach (GeneratorRunState from in states)
{
if (canReturn.Contains(from))
{
continue;
}
foreach (GeneratorRunState to in states)
{
if (GeneratorSimulation.IsLegalTransition(from, to) &&
canReturn.Contains(to))
{
canReturn.Add(from);
grew = true;
break;
}
}
}
}
while (grew);
Assert.That(canReturn, Is.EquivalentTo(states));
}
///
/// Every state the model declares is reached by the running simulation.
///
///
/// Distinct from , which walks the
/// declared transition table. This one drives the actual simulation, and it
/// is the test that would have caught Synchronizing and Paralleled being
/// declared, drawn in the README and never entered: a client browsing the
/// state machine saw two states it could never observe.
///
[Test]
public void EveryStateIsEnteredByTheRunningSimulation()
{
var seen = new HashSet();
// Profile 0 energises a dead bus; a later profile synchronises onto a
// live one, so both paths have to run to cover the model.
foreach (int profile in new[] { 0, 1 })
{
GeneratorSimulation simulation = SimulationHarness.Create(profile);
simulation.StateChanged = (from, to) => seen.Add(to);
seen.Add(simulation.State);
for (int tick = 0; tick < 4000; tick++)
{
simulation.Advance(tick);
}
}
// A protection trip and the emergency stop are commanded, not part of
// the free-running cycle.
GeneratorSimulation tripped = SimulationHarness.Create();
SimulationHarness.AdvanceUntil(tripped, GeneratorRunState.Loaded);
tripped.StateChanged = (from, to) => seen.Add(to);
tripped.Trip();
GeneratorSimulation stopped = SimulationHarness.Create();
SimulationHarness.AdvanceUntil(stopped, GeneratorRunState.Loaded);
stopped.StateChanged = (from, to) => seen.Add(to);
stopped.RequestState(GeneratorRunState.EmergencyStopped);
var missing = new List();
foreach (GeneratorRunState state in Enum.GetValues(typeof(GeneratorRunState))
.Cast())
{
if (!seen.Contains(state))
{
missing.Add(state);
}
}
Assert.That(missing, Is.Empty, "States the model declares but nothing enters.");
}
///
/// A start commanded through a method is counted, not just an automatic one.
///
///
/// The counter used to be incremented only in the automatic cycle, so the
/// more a client used Start or StartTest the further NumberOfStarts drifted
/// from the truth - the opposite of what a start counter is for.
///
[Test]
public void ACommandedStartIsCounted()
{
GeneratorSimulation simulation = SimulationHarness.Create();
Assert.That(GeneratorCommands.Start(simulation), Is.True);
simulation.Advance(0);
Assert.That(
SimulationHarness.LastStartCount(simulation),
Is.EqualTo(1u),
"A commanded start was not counted.");
}
///
/// The automatic cycle counts its starts exactly once each.
///
[Test]
public void AnAutomaticStartIsCountedOnce()
{
GeneratorSimulation simulation = SimulationHarness.Create();
Assert.That(
SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded),
Is.True);
Assert.That(SimulationHarness.LastStartCount(simulation), Is.EqualTo(1u));
}
///
/// A set starts shut down rather than in whatever the enum's default is.
///
[Test]
public void ASimulationStartsOff()
{
GeneratorSimulation simulation = SimulationHarness.Create();
Assert.That(simulation.State, Is.EqualTo(GeneratorRunState.Off));
}
///
/// A legal request is accepted and moves the set.
///
[Test]
public void ALegalRequestIsAccepted()
{
GeneratorSimulation simulation = SimulationHarness.Create();
Assert.Multiple(() =>
{
Assert.That(simulation.RequestState(GeneratorRunState.Ready), Is.True);
Assert.That(simulation.State, Is.EqualTo(GeneratorRunState.Ready));
});
}
///
/// An illegal request is refused and leaves the set where it was.
///
[Test]
public void AnIllegalRequestIsRefusedAndChangesNothing()
{
GeneratorRunState[] unreachableFromOff =
[
GeneratorRunState.Loaded,
GeneratorRunState.Running,
GeneratorRunState.Paralleled,
GeneratorRunState.EmergencyStopped,
];
Assert.Multiple(() =>
{
foreach (GeneratorRunState requested in unreachableFromOff)
{
GeneratorSimulation simulation = SimulationHarness.Create();
Assert.That(
simulation.RequestState(requested),
Is.False,
$"Off->{requested} was accepted.");
Assert.That(
simulation.State,
Is.EqualTo(GeneratorRunState.Off),
$"Off->{requested} moved the set.");
}
});
}
///
/// Every state change is announced exactly once, in order.
///
///
/// The address-space state machine follows this callback, so a change that
/// is not announced is a change a client never sees.
///
[Test]
public void EveryStateChangeIsAnnouncedOnce()
{
GeneratorSimulation simulation = SimulationHarness.Create();
List seen = SimulationHarness.RecordStates(simulation);
simulation.RequestState(GeneratorRunState.Ready);
simulation.RequestState(GeneratorRunState.Starting);
simulation.RequestState(GeneratorRunState.Warmup);
simulation.RequestState(GeneratorRunState.Running);
Assert.That(seen, Is.EqualTo(new[]
{
GeneratorRunState.Ready,
GeneratorRunState.Starting,
GeneratorRunState.Warmup,
GeneratorRunState.Running,
}));
}
///
/// A refused request announces nothing.
///
[Test]
public void ARefusedRequestAnnouncesNothing()
{
GeneratorSimulation simulation = SimulationHarness.Create();
List seen = SimulationHarness.RecordStates(simulation);
simulation.RequestState(GeneratorRunState.Loaded);
Assert.That(seen, Is.Empty);
}
///
/// Left alone, a set runs itself up to load rather than sitting at Off.
///
///
/// The sample's whole point is a plant that looks alive without a client
/// driving it, so the automatic cycle is part of the contract.
///
[Test]
public void ASetRunsItselfUpToLoad()
{
GeneratorSimulation simulation = SimulationHarness.Create();
Assert.That(
SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded),
Is.True,
$"Stalled in {simulation.State}.");
}
///
/// Two sets advance independently of one another.
///
[Test]
public void SetsAdvanceIndependently()
{
GeneratorSimulation first = SimulationHarness.Create(0);
GeneratorSimulation second = SimulationHarness.Create(1);
first.RequestState(GeneratorRunState.Ready);
Assert.Multiple(() =>
{
Assert.That(first.State, Is.EqualTo(GeneratorRunState.Ready));
Assert.That(second.State, Is.EqualTo(GeneratorRunState.Off));
});
}
}
}