Skip to content

Commit 490a3eb

Browse files
romanettclaude
andauthored
Fix the state machine tests on preview.4 and cover the materialized alias nodes (#854)
Three test changes, all in the tiers rather than in the samples. The two state machine fixtures fail on master. 2.0.0-preview.4 materializes the states and transitions of a FluentFiniteStateMachineState as real nodes, and mints their NodeIds from the machine's own identifier and the element's browse name - the numeric id the sample declares stays on the node as its state number. CurrentState/Id therefore names the materialized state node, which is what Part 16 asks for and what the assertions say they check, but the fixtures computed the expected id from the declared number and compared against that. They now browse the machine for the state node instead, the way a client comparing CurrentState/Id against a state would have to. The alias name fixture gains the two things materialization promises that nothing asserted yet. That a node exists under the category is only half of Part 17 6.2: the other half is the AliasFor reference of 8.2 reaching the node the structural browse path leads to, so a client which discovers an alias by browsing lands where one which called FindAlias would. And the optional Methods the standard category was given are called rather than only browsed for - FindAliasVerbose answers there, not only on the sample's own categories. The workshop subscription tier gets the dialog watchdog it was missing. A sample which throws reports it in a message box, and a modal dialog on the thread the test drives blocks the message loop, so every wait afterwards runs out and reports that a control never changed - which says nothing about why. The watchdog closes the dialog and keeps what it said, and a complaint the sample made now wins over the symptom it caused. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent eaa33e4 commit 490a3eb

4 files changed

Lines changed: 111 additions & 7 deletions

File tree

‎Tests/SampleClients.Tests/WorkshopClientSubscriptionTests.cs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ private static async Task DriveClientAsync(
196196
// their notification callbacks back to the UI thread and need one to do so
197197
CreateHandles(form);
198198

199+
// A sample which throws reports it in a message box, and a modal dialog on the
200+
// thread this test drives blocks the message loop - every wait below then runs out
201+
// and reports that a control never changed, which says nothing about why. The
202+
// watchdog closes the dialog and keeps what it said, so the complaint itself
203+
// becomes the failure.
204+
using var watchdog = new DialogWatchdog();
205+
watchdog.Start();
206+
199207
ConnectServerCtrl connect = WinFormsHarness.GetConnectControl(form);
200208

201209
ISession session = await connect
@@ -217,11 +225,29 @@ private static async Task DriveClientAsync(
217225

218226
if (client.Arrange != null)
219227
{
220-
await client.Arrange(form, ct).ConfigureAwait(true);
228+
try
229+
{
230+
await client.Arrange(form, ct).ConfigureAwait(true);
231+
}
232+
catch (Exception) when (watchdog.Captured.Count > 0)
233+
{
234+
// the sample complained while it was being set up, and whatever the
235+
// arrange step then observed is a consequence of that. Report the
236+
// complaint instead, which is the failure a reader can act on.
237+
Assert.Fail(
238+
$"The {client.Name} client reported an error while it was driven: " +
239+
string.Join(" | ", watchdog.Captured));
240+
}
221241
}
222242

223243
bool arrived = await WaitAsync(() => client.HasNotification(form), ct).ConfigureAwait(true);
224244

245+
// a complaint the sample made is the better failure, so it is reported first
246+
Assert.That(
247+
watchdog.Captured,
248+
Is.Empty,
249+
$"The {client.Name} client reported an error while it was driven.");
250+
225251
Assert.That(
226252
arrived,
227253
Is.True,

‎Tests/SampleNodeManagers.Tests/AliasNamesNodeManagerTests.cs‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,73 @@ await ReportAsync("Browsing the standard TagVariables object", children)
122122
});
123123
}
124124

125+
/// <summary>
126+
/// A materialized alias node actually reaches the node it stands for.
127+
/// </summary>
128+
/// <remarks>
129+
/// That the node exists under the category is only half of Part 17 §6.2. What makes it
130+
/// an alias rather than an empty placeholder is the <c>AliasFor</c> reference of §8.2,
131+
/// and the target it names has to be the node the structural browse path leads to -
132+
/// otherwise a §6.2 client which discovers the alias by browsing ends up somewhere
133+
/// else than one which called <c>FindAlias</c>.
134+
/// </remarks>
135+
[Test]
136+
[CancelAfter(kTimeout)]
137+
public async Task AMaterializedAliasNodeReachesTheNodeItStandsFor(CancellationToken ct)
138+
{
139+
NodeId aliasNode = await ChildAsync(ObjectIds.TagVariables, "TIC101_PV", ct)
140+
.ConfigureAwait(false);
141+
142+
IReadOnlyList<ReferenceDescription> targets = await SessionOps
143+
.BrowseAsync(Session, aliasNode, ct, referenceTypeId: ReferenceTypeIds.AliasFor)
144+
.ConfigureAwait(false);
145+
146+
Assert.That(targets, Is.Not.Empty, "The alias node has an AliasFor reference to its target.");
147+
148+
NodeId target = ExpandedNodeId.ToNodeId(targets[0].NodeId, Session.NamespaceUris);
149+
150+
NodeId browsed = await ResolveAsync(ct, Plant, Reactor, TemperatureMeasurement)
151+
.ConfigureAwait(false);
152+
153+
await TestContext.Out
154+
.WriteLineAsync($"TIC101_PV --AliasFor--> {target}, browse path -> {browsed}")
155+
.ConfigureAwait(false);
156+
157+
Assert.That(
158+
target,
159+
Is.EqualTo(browsed),
160+
"AliasFor points at the node the structural browse path leads to.");
161+
}
162+
163+
/// <summary>
164+
/// The optional Methods the standard category gained are not just nodes: they answer.
165+
/// </summary>
166+
/// <remarks>
167+
/// Materialization creates the Method nodes at the NodeIds the OPC Foundation reserves
168+
/// for them, but a node a client cannot call is worth nothing. This calls the one which
169+
/// the well known categories ship without, on the standard category rather than on the
170+
/// sample's own, which is the case a client with no prior knowledge of this server hits.
171+
/// </remarks>
172+
[Test]
173+
[CancelAfter(kTimeout)]
174+
public async Task TheStandardCategoryAnswersTheOptionalVerboseMethod(CancellationToken ct)
175+
{
176+
AliasNameClient standard = AliasNameClient.OpenStandardTagVariables(Session);
177+
178+
IReadOnlyList<AliasNameVerboseDataType> found = await standard
179+
.FindAliasVerboseAsync("TIC101_PV", null, ct)
180+
.ConfigureAwait(false);
181+
182+
Assert.That(
183+
NamesOf(found),
184+
Is.EquivalentTo(new[] { "TIC101_PV" }),
185+
"FindAliasVerbose answers on the standard category, not only on the sample's own.");
186+
}
187+
125188
/// <summary>
126189
/// A wildcard narrows the search the way Part 17 §6.3.2 defines it.
127-
/// </summary> /// <remarks>
190+
/// </summary>
191+
/// <remarks>
128192
/// This is the entire point of the pattern argument: a client which wants the
129193
/// measured values of the plant asks for them by name shape, rather than reading the
130194
/// whole inventory and filtering it itself.

‎Tests/SampleNodeManagers.Tests/StateMachinesNodeManagerTests.cs‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ public async Task OperationMachineStartsInItsInitialState(CancellationToken ct)
146146
{
147147
string state = await ReadOperationStateNameAsync(ct).ConfigureAwait(false);
148148
NodeId stateId = await ReadOperationStateIdAsync(ct).ConfigureAwait(false);
149+
NodeId offNode = await OperationStateNodeAsync("Off", ct).ConfigureAwait(false);
149150

150151
Assert.Multiple(() => {
151152
Assert.That(
@@ -155,7 +156,7 @@ public async Task OperationMachineStartsInItsInitialState(CancellationToken ct)
155156

156157
Assert.That(
157158
stateId,
158-
Is.EqualTo(OperationState(StateMachinesNodeManager.OffState)),
159+
Is.EqualTo(offNode),
159160
"CurrentState/Id has to name the state node of the machine's own namespace.");
160161
});
161162
}
@@ -190,6 +191,7 @@ public async Task CausesDriveTheDeclaredTransitions(CancellationToken ct)
190191

191192
string running = await ReadOperationStateNameAsync(ct).ConfigureAwait(false);
192193
NodeId runningId = await ReadOperationStateIdAsync(ct).ConfigureAwait(false);
194+
NodeId runningNode = await OperationStateNodeAsync("Running", ct).ConfigureAwait(false);
193195

194196
Assert.Multiple(() => {
195197
Assert.That(
@@ -198,7 +200,7 @@ public async Task CausesDriveTheDeclaredTransitions(CancellationToken ct)
198200
"Start has to move the machine from Idle to Running.");
199201
Assert.That(
200202
runningId,
201-
Is.EqualTo(OperationState(StateMachinesNodeManager.RunningState)),
203+
Is.EqualTo(runningNode),
202204
"CurrentState/Id has to follow the state.");
203205
});
204206

@@ -471,9 +473,21 @@ public async Task ProgramReturnsToReadyOnItsOwn(CancellationToken ct)
471473
}
472474

473475
#region Helpers
474-
private NodeId OperationState(uint stateId)
476+
/// <summary>
477+
/// The node of one of the Operation machine's states, found by browsing for it.
478+
/// </summary>
479+
/// <remarks>
480+
/// The state nodes are materialized by the stack, which mints their NodeIds from the
481+
/// machine's own identifier and the state's browse name rather than from the numeric
482+
/// id the sample declared - that number stays on the node as its state number. So the
483+
/// node is browsed for by name instead of being computed, which is also what a client
484+
/// comparing CurrentState/Id against a state would have to do.
485+
/// </remarks>
486+
private async Task<NodeId> OperationStateNodeAsync(string stateName, CancellationToken ct)
475487
{
476-
return new NodeId(stateId, NamespaceIndex(StateMachinesNamespace));
488+
NodeId machine = await OperationNodeAsync(ct).ConfigureAwait(false);
489+
490+
return await ChildAsync(machine, stateName, ct).ConfigureAwait(false);
477491
}
478492

479493
private Task<NodeId> OperationNodeAsync(CancellationToken ct)

‎docs/TESTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ What each fixture pins down, in one line:
317317
| Methods | Argument metadata, the two argument-validation refusals, the ramp, and replacing a running process |
318318
| NodeManagement | The four Part 4 §5.8 services over a real session: a client creates an object and a variable with attributes, gets a node id from the node manager or asks for one, is refused a duplicate browse name, a taken node id, a non-hierarchical reference and a parent outside the folder the sample opens, deletes what it added and is refused the model, references a node into a second folder and drops the reference again without deleting the node, sees the derived counter follow and a GeneralModelChangeEvent report the folder, and is refused everything on a node manager which never opted in |
319319
| RoleManagement | What a Part 18 Role is worth: an anonymous session browses the machine and is refused every value, an Observer reads but neither writes nor calls, an Operator does both, an Engineer sees a node an Observer cannot browse, UserRolePermissions reports what the session earns, the role configuration is refused to everyone but a SecurityAdmin on an encrypted channel, and a Role granted at runtime reaches an already open session |
320-
| AliasNames | What a Part 17 index is worth: the standard TagVariables object answers FindAlias for the whole plant, a wildcard narrows it, a tag name resolves to the node the browse path leads to and back again, the application-defined category tree is browsable below the standard Aliases object and its nested categories serve only their own unit, FindAliasVerbose names the category an entry came from, and the tag list is editable at runtime by a SecurityAdmin on an encrypted channel and by nobody else |
320+
| AliasNames | What a Part 17 index is worth: the standard TagVariables object answers FindAlias for the whole plant, a wildcard narrows it, a tag name resolves to the node the browse path leads to and back again, the materialized alias nodes carry an AliasFor reference which reaches that same node, the standard category answers the optional FindAliasVerbose it was given, the application-defined category tree is browsable below the standard Aliases object and its nested categories serve only their own unit, FindAliasVerbose names the category an entry came from, and the tag list is editable at runtime by a SecurityAdmin on an encrypted channel and by nobody else |
321321
| UserAuthentication | UserAccessLevel computed per session, the write refused for anonymous, an unknown user refused a session |
322322
| PerfTest | The register/offset arithmetic in the node id, nodes synthesized on demand, bounds refused |
323323
| DataAccess | The segment tree, blocks browsable down to their tags, one block reachable through two paths |

0 commit comments

Comments
 (0)