review(track0): async ContextMenu OnClick + keyboard-⋯ anchoring + focus-return; discovery-driven DriverTypeNames guard; CSV round-trip comment

Wave-0 reviewer findings (no Critical/High):
- T0-1 M: ContextMenuItem.OnClick Action→Func<Task> (kills async-void at menu call sites Wave A/B/C need); keyboard-activated ⋯ now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too.
- T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught.
- T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:17:37 -04:00
parent 9a896efecd
commit c3277b52c9
6 changed files with 169 additions and 81 deletions
@@ -16,10 +16,11 @@ public sealed class CsvRoundTripTests
// Each case is a jagged table of fields.
yield return new object[] { new[] { new[] { "a", "b", "c" } } };
yield return new object[] { new[] { new[] { "a", "b" }, new[] { "c", "d" } } };
// NOTE: a single row of a single empty field ([[""]]) is deliberately excluded — it serialises to
// the empty string, which is indistinguishable from an empty document and parses back to zero rows.
// That one degenerate case is the sole non-round-trippable shape; every multi-field / multi-row
// empty case below (",," and blank interior lines) does round-trip exactly.
// NOTE: a table whose FINAL row is a single empty field ([[""]], or e.g. [["a"],[""]]) is deliberately
// excluded — that trailing lone-empty-field row serialises to nothing after the preceding row's
// terminator, so the parser's no-phantom-trailing-row rule drops it on the way back. This is the only
// non-round-trippable shape; a single-empty-field row anywhere but last (see the interior [""] case
// below), ",,", and blank interior lines all round-trip exactly.
yield return new object[] { new[] { new[] { "", "", "" } } };
yield return new object[] { new[] { new[] { "a" }, new[] { "" }, new[] { "b" } } };
yield return new object[] { new[] { new[] { "a,b", "c" } } }; // embedded delimiter
@@ -8,34 +8,72 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards <see cref="DriverTypeNames"/> against drift from the driver factories that are the
/// authority for these strings. The test builds the <b>real</b> registered-factory set the way
/// production does — it drives each driver assembly's <c>*DriverFactoryExtensions.Register</c>
/// entry point into a live <see cref="DriverFactoryRegistry"/>, exactly as the Host's
/// <c>DriverFactoryBootstrap.Register</c> does — then asserts bidirectional parity with the
/// constants. If a factory renames its <c>DriverTypeName</c> (or a constant is added/removed
/// without a matching factory), this fails, forcing the constant and the factory back in sync.
/// authority for these strings. Factory discovery is <b>reflection-driven, not a hand-copied list</b>:
/// the test scans the driver assemblies deployed to its output directory for the
/// <c>*DriverFactoryExtensions.Register(DriverFactoryRegistry, …)</c> convention and invokes each into
/// a live <see cref="DriverFactoryRegistry"/> — the same registration path production takes — then
/// asserts bidirectional parity with the constants. Because discovery is by convention, a brand-new
/// driver assembly referenced by this project (and thus copied to bin) is picked up automatically: if
/// it registers a <c>DriverType</c> string with no matching <see cref="DriverTypeNames"/> constant (or
/// a constant is added/removed without a matching factory), this fails, forcing the two back in sync.
/// </summary>
public sealed class DriverTypeNamesGuardTests
{
// Driver-adjacent assemblies that ship no runtime factory (contracts/addressing/CLI helpers).
private static readonly string[] NonFactoryDriverAssemblySuffixes =
{ ".Contracts", ".Addressing", ".Cli" };
/// <summary>
/// Build the authoritative registered-factory set by invoking every driver assembly's
/// registration extension against a fresh registry — the same set of calls, in the same
/// spirit, as <c>DriverFactoryBootstrap.Register</c> on a driver-role node.
/// Build the authoritative registered-factory set by discovering every
/// <c>*DriverFactoryExtensions.Register</c> in the deployed driver assemblies (by convention) and
/// invoking it against a fresh registry. The factory func is never invoked — we only need the
/// type-name key each <c>Register</c> call installs — so <see langword="null"/> is passed for the
/// optional <c>loggerFactory</c>/other parameters.
/// </summary>
private static IReadOnlyCollection<string> RegisteredDriverTypeNames()
{
var registry = new DriverFactoryRegistry();
var invoked = 0;
// loggerFactory is optional on every extension; the factory func is never invoked here,
// so null is fine — we only need the type-name key each Register call installs.
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory: null);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory: null);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory: null);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory: null);
Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
var binDir = Path.GetDirectoryName(typeof(DriverTypeNamesGuardTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll"))
{
var name = Path.GetFileNameWithoutExtension(dll);
if (NonFactoryDriverAssemblySuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal)))
continue;
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
foreach (var type in types)
{
if (type is null || !type.IsClass || type.Name is not { } n
|| !n.EndsWith("DriverFactoryExtensions", StringComparison.Ordinal))
continue;
var register = type.GetMethod("Register", BindingFlags.Public | BindingFlags.Static);
var ps = register?.GetParameters();
if (register is null || ps is null || ps.Length == 0
|| ps[0].ParameterType != typeof(DriverFactoryRegistry))
continue;
var args = new object?[ps.Length];
args[0] = registry; // remaining params (optional loggerFactory, etc.) default to null
register.Invoke(null, args);
invoked++;
}
}
// Discovery must find *something*; a zero here means the bin scan/convention broke, not that the
// fleet has no drivers — fail loudly rather than pass a vacuously-empty parity check.
invoked.ShouldBeGreaterThan(0,
"no *DriverFactoryExtensions.Register was discovered in the output directory — the driver " +
"assemblies did not deploy to bin, or the naming convention changed");
return registry.RegisteredTypes;
}