feat(popout): arm the auto-tell pop-out settings and add the pool self-test

This commit is contained in:
2026-06-16 01:29:59 +02:00
parent 6578c10b13
commit 49f5119b17
3 changed files with 127 additions and 11 deletions
+1
View File
@@ -388,6 +388,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
new SelfTests.TypingIpcStateStep(this),
new SelfTests.ConfigMigrationV23Step(this),
new SelfTests.ChannelPopoutBindStep(this),
new SelfTests.HoverSheenAllocStep(this),
new SelfTests.HonorificHeaderRenderStep(this),
new SelfTests.AboutIntegrationsStatusStep(this),
@@ -0,0 +1,119 @@
using System.Linq;
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// Exercises the ChannelPopoutPool lifecycle in-game (a behavioural step, not a
// non-null-handle check — feedback_hellion_chat_fontmanager_push_trap). Verifies
// pre-alloc == MaxParallelPopouts, unique slot ids, a TryOpen->IsOpen->TryClose
// round-trip, idempotent TryClose, and capacity-exceeded refusal (warn, no throw).
// The pool is a LIVE DI singleton, so a tester may already have real pop-outs open
// when /xlperf runs; the step tests against the FREE slots (not full capacity) and
// only ever closes ids it opened, so it neither false-REDs on a non-empty pool nor
// disturbs real pop-outs. The pure slot-map math is pinned by PopoutSlotMapTests
// (Build-Suite); this step proves the live wiring on top of it. Every slot reserved
// is released before RunStep returns, so no pop-out is left bound.
internal sealed class ChannelPopoutBindStep : ISelfTestStep
{
private readonly Plugin _plugin;
public ChannelPopoutBindStep(Plugin plugin)
{
_plugin = plugin;
}
public string Name => "Hellion Chat - Channel popout pool lifecycle";
public SelfTestStepResult RunStep()
{
var pool = _plugin.ChannelPopoutPool;
var capacity = Plugin.Config.MaxParallelPopouts;
if (pool.Instances.Count != capacity)
{
ImGui.Text(
$"Expected {capacity} pre-allocated pop-out windows, found {pool.Instances.Count}."
);
return SelfTestStepResult.Fail;
}
if (pool.Instances.Select(w => w.SlotIndex).Distinct().Count() != pool.Instances.Count)
{
ImGui.Text("Pop-out windows do not have unique slot indices.");
return SelfTestStepResult.Fail;
}
// Free slots right now = capacity minus whatever real pop-outs are already
// bound. Testing against this (not capacity) keeps the step state-independent.
var free = capacity - pool.Instances.Count(w => w.Bound is not null);
// Round-trip on a throwaway tab, only when there's a slot to take. A bare Tab
// has CurrentChannel.Channel == Invalid + an empty SelectedChannels, so the
// pool's OnTabActivated strip is a no-op (no NRE), and the live active tab is
// passed only as `previous`, so it is never mutated. We close before
// returning, so the bound window never reaches a Draw frame.
if (free > 0)
{
var probe = new Tab { Name = "##selftest-popout-probe" };
if (pool.IsOpen(probe.Identifier))
{
ImGui.Text("Probe tab already open before TryOpen.");
return SelfTestStepResult.Fail;
}
if (!pool.TryOpen(probe))
{
ImGui.Text("TryOpen returned false with a free slot.");
return SelfTestStepResult.Fail;
}
if (!pool.IsOpen(probe.Identifier))
{
ImGui.Text("IsOpen is false right after a successful TryOpen.");
pool.TryClose(probe.Identifier);
return SelfTestStepResult.Fail;
}
pool.TryClose(probe.Identifier);
if (pool.IsOpen(probe.Identifier))
{
ImGui.Text("IsOpen is still true after TryClose.");
return SelfTestStepResult.Fail;
}
// Idempotent: closing an already-closed id is a silent no-op.
pool.TryClose(probe.Identifier);
}
// Capacity guard: fill the remaining free slots, then one more open must be
// refused (warn, no throw). Release everything we opened before reporting.
var fillers = Enumerable
.Range(0, free)
.Select(_ => new Tab { Name = "##selftest-fill" })
.ToList();
var opened = fillers.Count(pool.TryOpen);
var overflow = new Tab { Name = "##selftest-overflow" };
var overflowRejected = !pool.TryOpen(overflow);
foreach (var filler in fillers)
pool.TryClose(filler.Identifier);
pool.TryClose(overflow.Identifier);
if (opened != free)
{
ImGui.Text($"Filled only {opened}/{free} free slots before TryOpen refused.");
return SelfTestStepResult.Fail;
}
if (!overflowRejected)
{
ImGui.Text("Pool accepted an open beyond capacity instead of refusing.");
return SelfTestStepResult.Fail;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
@@ -1,5 +1,4 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Ui.Components.Settings.Tabs;
@@ -45,12 +44,11 @@ internal sealed class ChannelsTab
() => Plugin.Config.AutoTellTabsShowGreetedToggle,
v => Plugin.Config.AutoTellTabsShowGreetedToggle = v
);
// Popout is a v1.8.0 teaser — render disabled, do NOT persist.
using (ImRaii.Disabled(true))
{
var openAsPopout = Plugin.Config.AutoTellTabsOpenAsPopout;
ImGui.Checkbox("Open as popout (lands in v1.8.0)", ref openAsPopout);
}
DrawToggle(
"Open as popout",
() => Plugin.Config.AutoTellTabsOpenAsPopout,
v => Plugin.Config.AutoTellTabsOpenAsPopout = v
);
}
if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen))
@@ -75,7 +73,7 @@ internal sealed class ChannelsTab
private void DrawTellAutoOpenModeCombo()
{
var labels = new[] { "Off", "Sidebar", "Top tab", "Popout (lands in v1.8.0)" };
var labels = new[] { "Off", "Sidebar", "Top tab", "Popout" };
var values = Enum.GetValues<TellAutoOpenMode>();
var current = Plugin.Config.TellAutoOpenMode;
var selected = 0;
@@ -91,9 +89,7 @@ internal sealed class ChannelsTab
ImGui.SetNextItemWidth(220);
if (ImGui.Combo("Tell auto-open mode", ref selected, labels, labels.Length))
{
// Popout (index 3) is a v1.8.0 teaser — revert to previous value
// and skip SaveConfig.
if (selected >= 0 && selected < values.Length && selected != 3)
if (selected >= 0 && selected < values.Length)
{
Plugin.Config.TellAutoOpenMode = values[selected];
_plugin.SaveConfig();