Skip to content

Commit 66f2f14

Browse files
committed
merge feat/issue-176 (TrakHound#185) into integration
2 parents f07b726 + 8213389 commit 66f2f14

2 files changed

Lines changed: 243 additions & 7 deletions

File tree

libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,55 @@ private void Init()
179179
/// </summary>
180180
public bool UseStreaming { get; set; }
181181

182+
/// <summary>
183+
/// A snapshot of the device model received from the most recent Probe response,
184+
/// keyed by device UUID. The dictionary is empty until the first probe succeeds.
185+
/// Each call returns a fresh snapshot reflecting the most recently completed probe;
186+
/// previously returned snapshots are unaffected by subsequent probes. Each
187+
/// <see cref="IDevice"/>'s DataItems carry the fully wired
188+
/// <see cref="IDataItem.Container"/> and <see cref="IDataItem.Device"/>
189+
/// back-pointers set during parsing, so consumers can walk the component ancestry
190+
/// of any DataItem without re-parsing the document.
191+
/// </summary>
192+
/// <remarks>
193+
/// <para>
194+
/// The dictionary returned by each call is a fresh allocation independent of the
195+
/// cache; the <see cref="IDevice"/> values within are shared references to cached
196+
/// instances that the client replaces wholesale on each probe. The accessor
197+
/// acquires the client's internal lock, so callers may enumerate the snapshot
198+
/// without synchronizing against the worker thread that processes subsequent probes.
199+
/// </para>
200+
/// <para>
201+
/// Each access allocates a fresh dictionary. Cache the returned reference if you
202+
/// read repeatedly between probes.
203+
/// </para>
204+
/// </remarks>
205+
public IReadOnlyDictionary<string, IDevice> Devices
206+
{
207+
get
208+
{
209+
lock (_lock)
210+
{
211+
return new Dictionary<string, IDevice>(_devices);
212+
}
213+
}
214+
}
215+
182216

183217
#region "Events"
184218

185219
/// <summary>
186-
/// Raised when a Device is received
220+
/// Raised once per parsed device for every Probe response the client receives,
221+
/// carrying the fully wired <see cref="IDevice"/> instance — the same instance
222+
/// the <see cref="Devices"/> snapshot accessor would return for that UUID — with
223+
/// its DataItems' <see cref="IDataItem.Container"/> and <see cref="IDataItem.Device"/>
224+
/// back-pointers set, and the agent's <c>InstanceId</c> stamped on each DataItem.
187225
/// </summary>
226+
/// <remarks>
227+
/// Handlers fire in document order while the cache is still being populated.
228+
/// Subscribe to <see cref="ProbeReceived"/> to receive notification after the full
229+
/// probe response has been processed.
230+
/// </remarks>
188231
public event EventHandler<IDevice> DeviceReceived;
189232

190233
/// <summary>
@@ -835,7 +878,6 @@ private void ProcessProbeDocument(IDevicesResponseDocument document)
835878
_cachedDataItems.Clear();
836879
}
837880

838-
var outputDevices = new List<IDevice>();
839881
foreach (var device in document.Devices)
840882
{
841883
var outputDevice = ProcessDevice(document.Header, device);
@@ -846,10 +888,8 @@ private void ProcessProbeDocument(IDevicesResponseDocument document)
846888
_devices.Remove(outputDevice.Uuid);
847889
_devices.Add(outputDevice.Uuid, outputDevice);
848890
}
849-
}
850891

851-
foreach (var outputDevice in outputDevices)
852-
{
892+
// Fire per-device inside the populate loop so the cache and event stay in lockstep.
853893
DeviceReceived?.Invoke(this, outputDevice);
854894
}
855895

@@ -1092,7 +1132,8 @@ private IComponentStream ProcessComponentStream(IMTConnectStreamsHeader header,
10921132
outputComponentStream.NativeName = inputComponentStream.NativeName;
10931133
outputComponentStream.Uuid = inputComponentStream.Uuid;
10941134

1095-
if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == Devices.Device.TypeId)
1135+
// Fully-qualified to disambiguate from the Devices property below; the namespace using above is shadowed inside this class.
1136+
if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == MTConnect.Devices.Device.TypeId)
10961137
{
10971138
outputComponentStream.Component = GetCachedDevice(deviceUuid);
10981139
}
@@ -1148,7 +1189,8 @@ private async void CheckAssetChanged(IEnumerable<IObservation> observations, Can
11481189
{
11491190
if (observations != null && observations.Count() > 0)
11501191
{
1151-
var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == Devices.DataItems.AssetChangedDataItem.TypeId);
1192+
// Fully-qualified to disambiguate from the Devices property below; the namespace using above is shadowed inside this class.
1193+
var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == MTConnect.Devices.DataItems.AssetChangedDataItem.TypeId);
11521194
if (assetsChanged != null)
11531195
{
11541196
foreach (var assetChanged in assetsChanged)
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
2+
// TrakHound Inc. licenses this file to you under the MIT license.
3+
4+
using MTConnect.Clients;
5+
using MTConnect.Devices;
6+
using NUnit.Framework;
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Linq;
10+
using System.Threading;
11+
12+
namespace MTConnect.Tests.Http.Clients
13+
{
14+
/// <summary>
15+
/// Drives the full MTConnectHttpClient (the streaming client, not the
16+
/// single-shot probe client) against the real embedded MTConnectHttpServer
17+
/// started by AgentRunner, pinning the two surfaces issue #176 added:
18+
/// the public Devices snapshot accessor (which exposes the post-probe
19+
/// device cache the client already maintains internally) and the
20+
/// DeviceReceived event (which historically built an empty list and
21+
/// iterated it, so it never fired in the field). Both invariants are
22+
/// exercised end to end through Start()/Stop(), so the probe round trip
23+
/// and the ProcessProbeDocument hand-off both run.
24+
/// </summary>
25+
[TestFixture]
26+
public class HttpClientDeviceModel : HttpClientFixture
27+
{
28+
// Generous CI-safe bounds. The streaming client raises DeviceReceived
29+
// and populates the snapshot from the first probe, which AgentRunner
30+
// guarantees is answerable before the fixture returns from Start().
31+
private const int ProbeWaitTimeoutMs = 15000;
32+
private const int ProbeWaitPollMs = 50;
33+
34+
/// <summary>Pins the behaviour expressed by the test name: devices accessor is empty before probe.</summary>
35+
[Test]
36+
public void DevicesAccessorIsEmptyBeforeProbe()
37+
{
38+
var client = new MTConnectHttpClient(Hostname, Port);
39+
40+
Assert.That(client.Devices, Is.Not.Null, "Devices accessor returned null");
41+
Assert.That(client.Devices.Count, Is.EqualTo(0), "Devices accessor was non-empty before any probe ran");
42+
}
43+
44+
/// <summary>Pins the behaviour expressed by the test name: devices accessor is populated after probe.</summary>
45+
[Test]
46+
public void DevicesAccessorIsPopulatedAfterProbe()
47+
{
48+
var client = new MTConnectHttpClient(Hostname, Port);
49+
50+
using var probeSignal = new ManualResetEventSlim(false);
51+
client.ProbeReceived += (_, _) => probeSignal.Set();
52+
53+
try
54+
{
55+
client.Start();
56+
57+
Assert.That(probeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "ProbeReceived did not fire within the timeout");
58+
59+
// The accessor returns a snapshot, so capture once and assert
60+
// against the captured dictionary.
61+
var snapshot = client.Devices;
62+
63+
Assert.That(snapshot, Is.Not.Null, "Devices accessor returned null after probe");
64+
Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), "Devices accessor did not surface every probed device");
65+
Assert.That(snapshot.Values.Any(d => d.Name == DeviceName), Is.True, $"Devices accessor did not surface the {DeviceName} device");
66+
}
67+
finally
68+
{
69+
client.Stop();
70+
}
71+
}
72+
73+
/// <summary>Pins the behaviour expressed by the test name: devices accessor returns snapshot not live view.</summary>
74+
[Test]
75+
public void DevicesAccessorReturnsSnapshotNotLiveView()
76+
{
77+
var client = new MTConnectHttpClient(Hostname, Port);
78+
79+
using var probeSignal = new ManualResetEventSlim(false);
80+
client.ProbeReceived += (_, _) => probeSignal.Set();
81+
82+
try
83+
{
84+
client.Start();
85+
86+
Assert.That(probeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "ProbeReceived did not fire within the timeout");
87+
88+
var snapshot = client.Devices;
89+
90+
// The accessor must hand back an independent snapshot, not a
91+
// reference to the live cache — mutating the returned
92+
// dictionary must not be possible, and successive reads must
93+
// not alias one another.
94+
Assert.That(snapshot, Is.InstanceOf<IReadOnlyDictionary<string, IDevice>>(), "Devices accessor did not return an IReadOnlyDictionary");
95+
Assert.That(ReferenceEquals(snapshot, client.Devices), Is.False, "Devices accessor handed back an aliased instance instead of a snapshot");
96+
}
97+
finally
98+
{
99+
client.Stop();
100+
}
101+
}
102+
103+
/// <summary>Pins the behaviour expressed by the test name: device received fires once per device on first probe.</summary>
104+
[Test]
105+
public void DeviceReceivedFiresOncePerDeviceOnFirstProbe()
106+
{
107+
var client = new MTConnectHttpClient(Hostname, Port);
108+
109+
// Capture both the count and the carried IDevice instances so we
110+
// can assert the wired model is delivered, not a placeholder.
111+
var receivedDevices = new List<IDevice>();
112+
var receivedLock = new object();
113+
client.DeviceReceived += (_, device) =>
114+
{
115+
lock (receivedLock) { receivedDevices.Add(device); }
116+
};
117+
118+
using var probeSignal = new ManualResetEventSlim(false);
119+
client.ProbeReceived += (_, _) => probeSignal.Set();
120+
121+
try
122+
{
123+
client.Start();
124+
125+
Assert.That(probeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "ProbeReceived did not fire within the timeout");
126+
127+
// The event is raised from inside ProcessProbeDocument, which
128+
// runs on the worker thread; ProbeReceived fires at the end of
129+
// that same method, so once it has been observed, every
130+
// DeviceReceived invocation for this probe has already
131+
// happened.
132+
List<IDevice> snapshot;
133+
lock (receivedLock) { snapshot = new List<IDevice>(receivedDevices); }
134+
135+
Assert.That(snapshot.Count, Is.EqualTo(ExpectedDocumentEntryCount), "DeviceReceived did not fire once per probed device");
136+
Assert.That(snapshot.All(d => d != null), Is.True, "DeviceReceived carried a null device");
137+
Assert.That(snapshot.Any(d => d.Name == DeviceName), Is.True, $"DeviceReceived did not deliver the {DeviceName} device");
138+
}
139+
finally
140+
{
141+
client.Stop();
142+
}
143+
}
144+
145+
/// <summary>Pins the behaviour expressed by the test name: device received carries wired data item back pointers.</summary>
146+
[Test]
147+
public void DeviceReceivedCarriesWiredDataItemBackPointers()
148+
{
149+
var client = new MTConnectHttpClient(Hostname, Port);
150+
151+
IDevice? targetDevice = null;
152+
var targetLock = new object();
153+
client.DeviceReceived += (_, device) =>
154+
{
155+
if (device?.Name == DeviceName)
156+
{
157+
lock (targetLock) { targetDevice = device; }
158+
}
159+
};
160+
161+
using var probeSignal = new ManualResetEventSlim(false);
162+
client.ProbeReceived += (_, _) => probeSignal.Set();
163+
164+
try
165+
{
166+
client.Start();
167+
168+
Assert.That(probeSignal.Wait(ProbeWaitTimeoutMs), Is.True, "ProbeReceived did not fire within the timeout");
169+
170+
IDevice? captured;
171+
lock (targetLock) { captured = targetDevice; }
172+
173+
Assert.That(captured, Is.Not.Null, $"DeviceReceived did not deliver the {DeviceName} device");
174+
175+
// Walk the DataItem tree and assert the back-pointers the
176+
// issue calls out are wired through to the caller — the whole
177+
// point of surfacing the cached IDevice is access to this
178+
// ancestry without re-parsing.
179+
var dataItems = captured!.GetDataItems().ToList();
180+
Assert.That(dataItems, Is.Not.Empty, $"Device {DeviceName} carried no DataItems");
181+
182+
foreach (var dataItem in dataItems)
183+
{
184+
Assert.That(dataItem.Device, Is.Not.Null, $"DataItem {dataItem.Id} lost its Device back-pointer");
185+
Assert.That(dataItem.Container, Is.Not.Null, $"DataItem {dataItem.Id} lost its Container back-pointer");
186+
}
187+
}
188+
finally
189+
{
190+
client.Stop();
191+
}
192+
}
193+
}
194+
}

0 commit comments

Comments
 (0)