fix(raw): manual tag entry feeds each row's picker the merged device config (#468 follow-up)

This commit is contained in:
Joseph Doherty
2026-08-05 03:25:13 -04:00
parent abb9f8a1cb
commit a2093b4400
2 changed files with 157 additions and 3 deletions
@@ -0,0 +1,132 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// Pins that the bulk manual-entry grid hands each row's typed tag editor the <b>merged</b>
/// Driver+Device config, not a stub.
/// </summary>
/// <remarks>
/// <para>
/// The AbCip / TwinCAT / FOCAS editors' address pickers dial the <c>GetDriverConfigJson</c> Func as
/// their browse connect config. <see cref="RawManualTagEntryModal"/> passed a literal <c>"{}"</c>,
/// so "Browse controller" always connected against an empty config and could never reach the
/// device — the picker was authorable but unusable, and no offline test reached it (AdminUI has no
/// bUnit).
/// </para>
/// <para>
/// Unlike the single-tag modal, the device here is a direct <c>[Parameter]</c> — no tag load
/// precedes it — so the resolve keys off <c>DeviceId</c> on the open path. The accessor must stay
/// <b>live</b> (reading the field, not a captured snapshot), because the resolve is awaited after
/// the rows are seeded. Exercised as a plain class: AdminUI has no bUnit, so the injected service
/// and the lifecycle/parameter-building members are reached by reflection.
/// </para>
/// </remarks>
public sealed class RawManualTagEntryModalMergedConfigTests
{
[Fact]
public async Task Each_rows_editor_receives_the_merged_device_config_not_a_stub()
{
const string merged = """{"endpoint":"10.0.0.1:44818","slot":0}""";
var svc = new FakeRawTreeService
{
OnLoadMergedProbeConfig = deviceId =>
deviceId == "dev-1" ? ("AbCip", merged) : null,
};
var modal = new RawManualTagEntryModal();
SetParameters(modal, visible: true, deviceId: "dev-1", driverType: "AbCip");
InjectService(modal, svc);
await OpenAsync(modal);
GetDriverConfigJson(modal).ShouldBe(merged);
}
[Fact]
public async Task A_failed_resolve_degrades_to_the_empty_stub_and_does_not_throw()
{
var svc = new FakeRawTreeService
{
OnLoadMergedProbeConfig = _ => throw new InvalidOperationException("device unreachable"),
};
var modal = new RawManualTagEntryModal();
SetParameters(modal, visible: true, deviceId: "dev-1", driverType: "AbCip");
InjectService(modal, svc);
await Should.NotThrowAsync(() => OpenAsync(modal));
GetDriverConfigJson(modal).ShouldBe("{}");
}
/// <summary>
/// Sets the modal's <c>[Parameter]</c>s. By reflection because BL0005 forbids assigning component
/// parameters from outside the component, and this project treats warnings as errors.
/// </summary>
private static void SetParameters(
RawManualTagEntryModal modal, bool visible, string deviceId, string driverType)
{
Set("Visible", visible);
Set("DeviceId", deviceId);
Set("DriverType", driverType);
void Set(string name, object value)
{
var property = typeof(RawManualTagEntryModal).GetProperty(name);
property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed.");
property!.SetValue(modal, value);
}
}
/// <summary>Sets the <c>@inject</c>-generated private <c>Svc</c> property.</summary>
private static void InjectService(RawManualTagEntryModal modal, FakeRawTreeService svc)
{
var property = typeof(RawManualTagEntryModal).GetProperty(
"Svc",
BindingFlags.NonPublic | BindingFlags.Instance);
property.ShouldNotBeNull("the injected IRawTreeService property is no longer named Svc.");
property!.SetValue(modal, svc);
}
/// <summary>Drives the open path — the protected lifecycle method the resolve hangs off.</summary>
private static async Task OpenAsync(RawManualTagEntryModal modal)
{
var method = typeof(RawManualTagEntryModal).GetMethod(
"OnParametersSetAsync",
BindingFlags.NonPublic | BindingFlags.Instance);
method.ShouldNotBeNull("the open path moved off OnParametersSetAsync — retarget this driver.");
await (Task)method!.Invoke(modal, null)!;
}
/// <summary>Invokes the <c>GetDriverConfigJson</c> Func the first seeded row's editor is handed.</summary>
private static string GetDriverConfigJson(RawManualTagEntryModal modal)
{
var rowsField = typeof(RawManualTagEntryModal).GetField(
"_rows",
BindingFlags.NonPublic | BindingFlags.Instance);
rowsField.ShouldNotBeNull("the manual-entry grid's row list is no longer named _rows.");
var rows = ((System.Collections.IEnumerable)rowsField!.GetValue(modal)!).Cast<object>().ToList();
rows.ShouldNotBeEmpty("the open path no longer seeds a row, so no editor parameters exist to check.");
var build = typeof(RawManualTagEntryModal).GetMethod(
"BuildEditorParameters",
BindingFlags.NonPublic | BindingFlags.Instance);
build.ShouldNotBeNull("BuildEditorParameters was renamed — this test reads it by name.");
var parameters = (IDictionary<string, object>)build!.Invoke(modal, [rows[0]])!;
return ((Func<string>)parameters["GetDriverConfigJson"])();
}
}