Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/AlarmControllerTests.cs
romanett 93559d06d3 Fix sample A&C refresh and alarm timing found by slow CTT Alarms and Conditions runs (#4482)
## Why the CTT *Alarms and Conditions* group was so slow

I measured every A&C CU with a fresh `ConsoleReferenceServer --ctt` (CTT
1.05.06, scripts 1.05.513), read the per-test `ResultNode` timestamps,
and added `addLog` timing/counter instrumentation to a copy of the CTT
project.

| Cause | Effect |
| --- | --- |
| **CertificateExpiration** opens a modal dialog in `initialize.js`
("adjust the clock on the server"), and it also appears in `--hidden`
runs | The CTT never exits. This is why the whole group "did not finish
in 40 minutes". |
| **Limit/Level CUs:** `initialize.js` idles the CU session for one
Alarm Cycle Time (60 s) during the initial capture.
`RequestedSessionTimeout` is also 60 s. | The server correctly closes
the session. `LimitHelper` then gets `BadSessionIdInvalid` for its 5
filter subscriptions, and Test_003–006 each run 180 s, about 14 min per
CU. |
| Alarm `Test_002` never gives the AcknowledgeableCondition type a
result | Always runs 3 × cycle (180 s). |
| Enable `Test_003` needs every alarm type to go active within cycle/10
| Timed out in 6 of 7 runs, because boolean and analog alarms changed
state on different ticks. |
| Enable `Err_004` answers each event with Disable/Disable/Enable |
Feedback burst, after which the CTT alarm thread sometimes returns no
events. Details below. |
| Comment `Test_001`–`004` compare against
`CallHelper.ServerTimeOfCall`, which the CTT never sets (`0001-01-01`) |
Every comment event counts as unrelated, and the tests skip after 3
retries. The server's events carry the expected comment. |

Measured CU by CU, the group took **about 87 min plus the hang**. With
the fixes below, running the whole group in one CTT process without
CertificateExpiration takes **18:45** at Alarm Cycle Time 30. The per-CU
table is in `plans/ctt-testing.md` §6.

## Server changes

- **Refresh one subscription per request, not one per Call**
(`AlarmNodeManager.CallAsync`). The sample answered every
ConditionRefresh/ConditionRefresh2 after the first one in a Call with
`Bad_RefreshInProgress`, even for other subscriptions, and never
forwarded them. So CTT Refresh `Err_004` (10 subscriptions per Call)
only ever refreshed one of them. The check is now keyed by
SubscriptionId and MonitoredItemId.
- Regression tests:
`AlarmsAndConditionsRefreshTests.ConditionRefresh[2]OfDifferentSubscriptionsInOneCallSucceedsAsync`.
Both fail without the fix.
- **Boolean alarm source now uses the analog source's steps and limits**
(`AlarmController`, `ConditionTypeHolder.GetSeverity`). OffNormal,
Discrete and SystemOffNormal alarms now change state on the same tick as
the limit alarms (5 per second, 70/90/30/10), and the shortest state
still lasts 4 s.
- With this, Enable `Test_003` passed in 4 s instead of a timeout.
Confirm `Test_001` for the boolean types also stopped failing in both
group runs.
- Test:
`AlarmControllerTests.BooleanAndAnalogSourcesChangeStateOnTheSameStep`
(fails without the change).
- **Alarm controllers step on shared interval boundaries**
(`AlarmController.SetNextInterval`). Scheduling relative to the last
step let the two controllers drift up to one 100 ms simulation tick
apart, so the boolean events sometimes landed in a later publish and
Enable `Test_003` still timed out in 3 of 5 runs. With shared boundaries
it passed in 6 of 6 runs.
- Test:
`AlarmControllerTests.ControllersScheduleTheirStepsOnSharedIntervalBoundaries`.

I did not make the simulation faster. Acknowledge, Confirm and Comment
tests call methods with the EventId of the event they just received, and
a shorter state would make them race the next transition.

## Not reproduced / not a server defect

- **Refresh `Err_004` `BadSubscriptionIdInvalid` cascade.** In the
original run the alarm-thread session's subscriptions (15.5 s lifetime)
were gone after the script's 30 s wait. Nine further CTT runs passed all
28 Refresh/Refresh2 cases. An in-process replay with a sequential
publisher kept every subscription; that replay is what exposed the
refresh defect above.
- **Enable event stall (C43) is inside the CTT.** A server build that
logs every returned notification message showed that during a stall the
server sent 435 and 420 events to the two CTT event items in 173
messages, and the CTT acknowledged all of them, while its alarm-thread
buffers returned nothing. It only happens after `Err_004`'s
Disable/Enable feedback burst, in 8 of 28 runs.

## Docs

- `plans/ctt-testing.md`: new §6 *Alarms and Conditions*, covering the
recommended run, where the time goes, how to instrument a run and the
expected result. Also a note that a hidden CTT run can block on dialogs,
so always use a timeout.
- `plans/ctt-issues.md`: the A&C "open server findings" are replaced
with these results, and there are new CTT script defects C39–C44 plus
configuration notes (Shelving chattering alarms, Alarm Cycle Time vs
session timeout).

## Tests

- `Opc.Ua.History.Tests` A&C + AlarmClient: 155 passed.
- `Opc.Ua.Server.Tests` `AlarmControllerTests` /
`QuickstartsServerUtilitiesTests`: 4 passed.
- CTT whole-group runs (27 CUs, 128 cases): 18:45 at cycle 30 and 29:23
at cycle 60 on this branch; 23:43 and 24:21 with only the refresh fix.
The spread comes from the Enable stall (C43).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
2026-09-15 14:44:36 +00:00

132 lines
5.7 KiB
C#

/* ========================================================================
* 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 Alarms;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Tests for the alarm simulation that drives the Quickstarts alarm node manager
/// in CTT mode.
/// </summary>
[TestFixture]
[Category("Alarms")]
[Parallelizable]
public class AlarmControllerTests
{
private static readonly string[] s_limitStates = ["HighHigh", "High", "Normal", "Low", "LowLow"];
[Test]
public void BooleanAndAnalogSourcesChangeStateOnTheSameStep()
{
// CTT A and C Enable Test_003 needs an active event from every alarm type
// within a tenth of the Alarm Cycle Time. That only holds deterministically
// when the boolean and analog sources cross their limits on the same step.
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var analog = new SteppableAlarmController(new BaseDataVariableState(null), isBoolean: false, telemetry);
var boolean = new SteppableAlarmController(new BaseDataVariableState(null), isBoolean: true, telemetry);
analog.Start(uint.MaxValue);
boolean.Start(uint.MaxValue);
var bands = new HashSet<string>();
for (int step = 0; step < 80; step++)
{
(int analogValue, bool analogActive) = analog.Step();
(int booleanValue, bool booleanActive) = boolean.Step();
Assert.That(booleanValue, Is.EqualTo(analogValue), $"step {step}");
bool limitActive = analogValue >= AlarmDefines.HIGH_ALARM ||
analogValue <= AlarmDefines.LOW_ALARM;
Assert.That(booleanActive, Is.EqualTo(limitActive), $"step {step} value {booleanValue}");
Assert.That(analogActive, Is.EqualTo(limitActive), $"step {step} value {analogValue}");
bands.Add(analogValue switch
{
>= AlarmDefines.HIGHHIGH_ALARM => "HighHigh",
>= AlarmDefines.HIGH_ALARM => "High",
<= AlarmDefines.LOWLOW_ALARM => "LowLow",
<= AlarmDefines.LOW_ALARM => "Low",
_ => "Normal"
});
}
Assert.That(bands, Is.EquivalentTo(s_limitStates),
"One simulation period must visit every limit state.");
}
[Test]
public void ControllersScheduleTheirStepsOnSharedIntervalBoundaries()
{
// Two controllers that stepped a few milliseconds apart must still be due in the
// same simulation pass; relative scheduling let them drift a timer tick apart.
var analogStep = new DateTime(2026, 9, 14, 9, 17, 4, 980, DateTimeKind.Local);
DateTime booleanStep = analogStep.AddMilliseconds(15);
DateTime analogNext = SteppableAlarmController.NextBoundary(analogStep, 1000);
DateTime booleanNext = SteppableAlarmController.NextBoundary(booleanStep, 1000);
Assert.That(analogNext, Is.EqualTo(new DateTime(2026, 9, 14, 9, 17, 5, DateTimeKind.Local)));
Assert.That(booleanNext, Is.EqualTo(analogNext));
Assert.That(analogNext.Kind, Is.EqualTo(DateTimeKind.Local));
Assert.That(
SteppableAlarmController.NextBoundary(analogNext.AddMilliseconds(2), 1000),
Is.EqualTo(analogNext.AddSeconds(1)),
"A step taken just after a boundary is due at the next one.");
}
private sealed class SteppableAlarmController : AlarmController
{
public SteppableAlarmController(
BaseDataVariableState variable,
bool isBoolean,
ITelemetryContext telemetry)
: base(variable, 1000, isBoolean, telemetry)
{
}
public (int Value, bool Active) Step()
{
int value = 0;
bool active = false;
GetValue(ref value, ref active);
return (value, active);
}
public static DateTime NextBoundary(DateTime now, int interval)
{
return GetNextIntervalBoundary(now, interval);
}
}
}
}