docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded
Task 14 — the P1 milestone gate. Ran the live /run verification on a docker-dev
rig against the real Mosquitto TLS+auth fixture, and recorded the result.
The gate did its job: it found MQTT was unauthorable in production.
RawDriverTypeDialog's driver-type list is a hand-maintained array that nobody
added MQTT to, so the /raw "New driver" picker never offered it — with every
other layer (contracts, driver, browser, factory, host registration, typed tag
editor) complete and 266 green unit tests. Nothing tied that array to
DriverTypeNames and AdminUI has no bUnit, so no offline test could see it.
Fixed, plus RawDriverTypeDialogParityTests: a reflection parity guard that
fails for ANY DriverTypeNames entry missing from the picker. Verified
falsifiable — RED with "…cannot be authored from the /raw New-driver picker,
so they are unreachable in production: Mqtt" before the one-line fix.
A second gap is left OPEN and documented, not fixed (no task ever covered it):
MqttDriverForm / MqttDeviceForm do not exist, and DriverConfigModal/DeviceModal
have no raw-JSON fallback — so an operator can create an MQTT driver but cannot
author its broker endpoint or credentials. P1 is not operator-complete until
those two razor forms land. The gate authored config via SQL to get past it.
Live gate result (isolated 2-node MAIN stack, image built from this branch,
fixture at 10.100.0.35:8883, AllowUntrustedServerCertificate rather than
pinning the CA into the rig):
- typed MQTT tag editor renders and round-trips; Json shows the JSON-path
field, Raw/Scalar hide it; wildcard topic a/+/c blocked at save; data-type
list offers Float64 and no Double; qos/retainSeed absent on "(driver
default)" and qos:2 written as a number; isHistorized/historianTagname
survive a topic-only edit; SparkplugB renders the P2 placeholder and
switching back keeps Plain values; jsonPath is guidance not a gate in all
three shapes ($ seeded only for a brand-new blank tag, blank stays absent,
clearing saves fine)
- deployed, and both editor-written blobs resolved and served changing live
values through OPC UA (22.8 / 1629, StatusCode Good) — no BadNodeIdUnknown
- the bespoke #-observation browser drove a real broker session and rendered
the fixture's actual topic tree (line1/{counter,state,temperature},
noise/chatter, retained/seed); a Modbus device's picker still correctly
reports "Browsing unavailable"
Also found live and documented (not a code change): both nodes of a redundant
pair run the driver, so a FIXED clientId makes them evict each other forever —
the broker logs "already connected, closing old connection" and both nodes
reconnect every ~2s while still reporting Healthy. Unset (the default) is
correct; confirmed 0 reconnects/60s on both nodes after removing it.
Suites: offline 1134 passed / 0 failed (266 Driver.Mqtt + 730 AdminUI incl. the
3 new guards + 138 Core.Abstractions); live 7/7 against the fixture.
Docs: new docs/drivers/Mqtt.md + Mqtt-Test-Fixture.md (the per-driver + fixture
convention every other driver follows), MQTT rows in docs/drivers/README.md,
TestConnectProbes.md, root README.md, and infra/README.md §3.
CLAUDE.md drift corrected while adding the MQTT endpoints:
- the "every fixture carries project: lmxopcua" claim was false — only the
MQTT fixture does; the filter returns one stack, not the fleet
- lmxopcua-fix.ps1 is Windows-VM-only; on macOS drive the host over ssh/rsync
(and the docker-dev rig itself runs locally under OrbStack)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the <c>/raw</c> "New driver" picker against driver-type drift.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="RawDriverTypeDialog"/> is the <b>only</b> surface an operator can author a
|
||||
/// <c>/raw</c> driver from, and its option list is a hand-maintained array — nothing tied it to
|
||||
/// <see cref="DriverTypeNames"/>. The MQTT P1 live gate found the consequence: the driver shipped
|
||||
/// complete (contracts, driver, browser, factory + host registration, typed tag editor, 266 green
|
||||
/// unit tests) and was still <b>unauthorable in production</b>, because nobody added the row to
|
||||
/// this array. Every offline test passed the whole way — the array is not reachable from any of
|
||||
/// them, and AdminUI has no bUnit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So this asserts parity by reflection rather than re-listing the drivers: a new
|
||||
/// <see cref="DriverTypeNames"/> constant that nobody surfaces in the picker fails here instead of
|
||||
/// at someone's live gate. It reads the private static <c>Types</c> field because that array is
|
||||
/// the component's own source of truth for the rendered <c><option></c> set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RawDriverTypeDialogParityTests
|
||||
{
|
||||
private static IReadOnlyList<(string Label, string Value)> DialogTypes()
|
||||
{
|
||||
var field = typeof(RawDriverTypeDialog).GetField(
|
||||
"Types",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
field.ShouldNotBeNull(
|
||||
"RawDriverTypeDialog.Types was renamed or removed — this guard reads it by name and " +
|
||||
"silently guards nothing once it cannot find it.");
|
||||
|
||||
var raw = (Array)field!.GetValue(null)!;
|
||||
|
||||
return raw.Cast<object>()
|
||||
.Select(t =>
|
||||
{
|
||||
var type = t.GetType();
|
||||
return ((string)type.GetField("Item1")!.GetValue(t)!,
|
||||
(string)type.GetField("Item2")!.GetValue(t)!);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_is_offered_by_the_new_driver_picker()
|
||||
{
|
||||
var offered = DialogTypes().Select(t => t.Value).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = DriverTypeNames.All.Where(n => !offered.Contains(n)).ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"These driver types are declared in DriverTypeNames but cannot be authored from the " +
|
||||
$"/raw New-driver picker, so they are unreachable in production: {string.Join(", ", missing)}. " +
|
||||
"Add a row to RawDriverTypeDialog.Types.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_picker_offers_no_driver_type_that_is_not_declared()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var unknown = DialogTypes().Select(t => t.Value).Where(v => !declared.Contains(v)).ToList();
|
||||
|
||||
unknown.ShouldBeEmpty(
|
||||
$"The /raw New-driver picker offers driver types with no DriverTypeNames constant, so " +
|
||||
$"authoring one produces a driver no factory can build: {string.Join(", ", unknown)}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_is_authorable_from_the_new_driver_picker()
|
||||
{
|
||||
// The specific regression the P1 live gate caught, pinned by name so a future refactor of the
|
||||
// parity facts above cannot quietly drop MQTT again.
|
||||
DialogTypes().Select(t => t.Value).ShouldContain(DriverTypeNames.Mqtt);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user