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

This commit is contained in:
Joseph Doherty
2026-09-08 09:45:25 -04:00
3 changed files with 106 additions and 16 deletions
@@ -61,14 +61,12 @@
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label form-label-sm">Instance</label> <label class="form-label form-label-sm">Instance</label>
<select class="form-select form-select-sm" data-test="flow-instance-select" @* A site can hold hundreds of instances, so this is the shared area-tree
@bind="_selectedInstanceId" @bind:after="OnInstanceChangedAsync" disabled="@(_running || _selectedSiteId == 0)"> combobox, not a flat list (design 2026-09-08). _areas/_instances are
<option value="0">— choose —</option> loaded once per site — the picker treats a new list as a site change. *@
@foreach (var i in _instances) <InstancePicker Areas="_areas" Instances="_instances"
{ SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
<option value="@i.Id">@i.UniqueName</option> Disabled="@(_running || _selectedSiteId == 0)" DataTestPrefix="flow-instance" />
}
</select>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label form-label-sm">Script</label> <label class="form-label form-label-sm">Script</label>
@@ -111,7 +111,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private List<ApiMethod> _methods = []; private List<ApiMethod> _methods = [];
private List<Site> _sites = []; private List<Site> _sites = [];
/// <summary>
/// The selected site's instances, feeding the <c>InstancePicker</c> alongside
/// <see cref="_areas"/>. The picker compares both by REFERENCE — a new list object is read
/// as "the site changed" and closes its panel — so they are assigned exactly once per site
/// load (<see cref="LoadInstancesAsync"/>) and never rebuilt on a render path.
/// </summary>
private List<Instance> _instances = []; private List<Instance> _instances = [];
/// <summary>The selected site's areas; the picker nests them into its tree. See <see cref="_instances"/>.</summary>
private List<Area> _areas = [];
private List<FlowScriptOption> _scripts = []; private List<FlowScriptOption> _scripts = [];
private int _selectedMethodId; private int _selectedMethodId;
@@ -411,9 +421,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private async Task LoadInstancesAsync() private async Task LoadInstancesAsync()
{ {
_instances = _selectedSiteId > 0 if (_selectedSiteId <= 0)
? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList() {
: []; _instances = [];
_areas = [];
return;
}
_instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList();
// The picker nests these itself (InstanceTreeBuilder), so order does not matter here.
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
}
/// <summary>
/// The picker raises the id rather than binding a &lt;select&gt;, so the page applies it
/// before running the same change handler the old dropdown used.
/// </summary>
private async Task OnInstancePickedAsync(int id)
{
_selectedInstanceId = id;
await OnInstanceChangedAsync();
} }
/// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary> /// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
@@ -603,10 +603,27 @@ public class FlowDebuggerPageTests : BunitContext
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null); private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);
/// <summary>
/// Picks an instance through the <c>InstancePicker</c> combobox that replaced the flat
/// instance <c>&lt;select&gt;</c> (design 2026-09-08): open the panel, click the row. The
/// fixture instance has no area, so its row sits at the root and needs no expansion.
/// </summary>
private static void PickInstance(IRenderedComponent<FlowDebuggerPage> cut, int id)
{
cut.Find("[data-test='flow-instance-toggle']").Click();
cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click();
}
/// <summary>Clicks the picker row (an area label toggles it, an instance label selects it).</summary>
private static void ClickPickerRow(IRenderedComponent<FlowDebuggerPage> cut, string label) =>
cut.FindAll("[data-test='flow-instance-row']").First(r => r.TextContent.Trim() == label).Click();
private void WireInstance() private void WireInstance()
{ {
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>()) _templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); .Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_templates.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>([]));
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }); _templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick])); _flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction()); _flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
@@ -620,7 +637,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>(); var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7"); PickInstance(cut, 7);
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
@@ -634,7 +651,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>(); var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7"); PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("OnFlag"); cut.Find("select[data-test='flow-script-select']").Change("OnFlag");
Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent); Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
@@ -653,7 +670,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>(); var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7"); PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual"); cut.Find("select[data-test='flow-script-select']").Change("Manual");
Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']")); Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
@@ -672,7 +689,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>(); var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7"); PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Tick"); cut.Find("select[data-test='flow-script-select']").Change("Tick");
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
@@ -714,7 +731,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>(); var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7"); PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual"); cut.Find("select[data-test='flow-script-select']").Change("Manual");
cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
@@ -751,4 +768,51 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
} }
[Fact]
public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded()
{
RegisterServices();
WireInstance();
// Same instance, now filed under Line1 / Cell A so the picker has a tree to drill into.
_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 }]));
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(
[new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }]));
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("[data-test='flow-instance-toggle']").Click();
// The panel opens collapsed: only the root area, and the instance is not rendered at all.
Assert.Equal(["Line1"], cut.FindAll("[data-test='flow-instance-row']").Select(r => r.TextContent.Trim()).ToList());
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
ClickPickerRow(cut, "Line1");
Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']"));
ClickPickerRow(cut, "Cell A");
cut.Find("[data-test='flow-instance-row'][data-instance-id='7']").Click();
// Picking the row ran OnInstanceChangedAsync, so the script list is loaded.
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
}
[Fact]
public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker()
{
RegisterServices();
WireInstance();
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")));
}
} }