Merge branch 'worktree-agent-a327a30cdb2e454ff' into feat/instance-picker

This commit is contained in:
Joseph Doherty
2026-09-08 10:01:47 -04:00
5 changed files with 212 additions and 35 deletions
@@ -29,23 +29,24 @@ public class DebugViewInstancePickerTests : BunitContext
{
private const int SiteId = 1;
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private void RegisterServices()
{
// DebugView touches localStorage on render; let bUnit answer loosely.
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For<ITemplateEngineRepository>();
repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List<Instance>
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List<Instance>
{
new("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled },
new("Off01") { Id = 8, SiteId = SiteId, State = InstanceState.Disabled },
});
repo.GetAreasBySiteIdAsync(SiteId).Returns(new List<Area>
_repo.GetAreasBySiteIdAsync(SiteId).Returns(new List<Area>
{
new("Line1") { Id = 1, SiteId = SiteId },
new("Cell A") { Id = 2, SiteId = SiteId, ParentAreaId = 1 },
});
Services.AddSingleton(repo);
Services.AddSingleton(_repo);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>
@@ -156,6 +157,60 @@ public class DebugViewInstancePickerTests : BunitContext
Assert.False(connect.HasAttribute("disabled"));
}
/// <summary>
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
/// Blazor renders as soon as <c>LoadInstancesForSite</c> hits its first await, and at that point
/// the page is not <c>_connected</c> — so the field was already enabled while the site's lists
/// were still loading. Typing into it then (an operator, or Playwright's <c>FillAsync</c>, which
/// waits only for "enabled") lost the text: the picker reads the freshly-assigned lists as
/// "the site changed" and clears the field and closes the panel.
/// </summary>
[Fact]
public void PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded()
{
RegisterServices();
// Hold the instance load open so the render that follows the first await is observable.
var gate = new TaskCompletionSource<IReadOnlyList<Instance>>();
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(gate.Task);
var cut = Render<DebugViewPage>();
cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString());
Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled"));
cut.InvokeAsync(() => gate.SetResult(
[new Instance("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }]));
cut.WaitForAssertion(() =>
Assert.False(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")));
}
/// <summary>
/// The localStorage auto-reconnect must apply the same membership guard the
/// <c>?instanceId=</c> deep link does. Off01 is Disabled, so the page filters it out of the
/// list the picker is handed — restoring it anyway left the field blank while the page held
/// a non-zero id and auto-connected to it. The stored id is dropped instead, and the site
/// stays selected and loaded so the operator can pick again.
/// </summary>
[Fact]
public void AutoReconnect_FromStorage_SkipsAnInstanceThatIsNoLongerAvailable()
{
RegisterServices();
JSInterop.Setup<string>("localStorage.getItem", "debugView.siteId").SetResult(SiteId.ToString());
JSInterop.Setup<string>("localStorage.getItem", "debugView.instanceId").SetResult("8");
var cut = Render<DebugViewPage>();
cut.WaitForAssertion(() =>
Assert.Equal(string.Empty, cut.Find("[data-test='debug-instance-input']").GetAttribute("value")));
// Nothing is selected, so Connect stays disabled — and the stale id is forgotten.
var connect = cut.FindAll("button.btn.btn-primary.btn-sm").First(b => b.TextContent.Contains("Connect"));
Assert.True(connect.HasAttribute("disabled"));
JSInterop.VerifyInvoke("localStorage.removeItem");
}
[Fact]
public void WhileConnected_ThePickerIsDisabled()
{
@@ -816,6 +816,67 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
}
/// <summary>
/// The same deep link, but the instance sits two areas deep. The picker resolves the field
/// text from <c>SelectedInstanceId</c> against the flat instance list, not by walking the
/// tree — so a nested instance must show its name with the panel never opened (and therefore
/// with Line1 / Cell A never expanded).
/// </summary>
[Fact]
public void ScriptEntry_DeepLink_InstanceIdInsideAnArea_ShowsItsNameWithoutOpeningThePanel()
{
RegisterServices();
WireInstance();
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(
[new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }]));
var nested = new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 };
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([nested]));
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(nested);
var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7");
var cut = Render<FlowDebuggerPage>();
cut.WaitForAssertion(() =>
Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")));
// The panel was never opened, so nothing of the tree is rendered.
Assert.Empty(cut.FindAll("[data-test='flow-instance-panel']"));
Assert.Empty(cut.FindAll("[data-test='flow-instance-row']"));
}
/// <summary>
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
/// Blazor renders as soon as <c>OnSiteChangedAsync</c> hits its first await, and at that point
/// <c>_selectedSiteId</c> is already non-zero while <c>_instances</c>/<c>_areas</c> are still the
/// previous site's lists — so an operator (or Playwright's <c>FillAsync</c>, which waits only for
/// "enabled") could type into the field and have the load wipe the text and close the panel a
/// moment later, because the picker reads a new list object as "the site changed".
/// </summary>
[Fact]
public void ScriptEntry_PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded()
{
RegisterServices();
WireInstance();
// Hold the instance load open so the render that follows the first await is observable.
var gate = new TaskCompletionSource<IReadOnlyList<Instance>>();
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>()).Returns(gate.Task);
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
Assert.True(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled"));
Assert.True(cut.Find("[data-test='flow-instance-toggle']").HasAttribute("disabled"));
cut.InvokeAsync(() => gate.SetResult([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
cut.WaitForAssertion(() =>
Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")));
}
[Fact]
public void ScriptEntry_SiteChange_ClearsThePickedInstance()
{