From 49f5119b177d84c88e91b0b4d63c2754a70045ed Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 01:29:59 +0200 Subject: [PATCH] feat(popout): arm the auto-tell pop-out settings and add the pool self-test --- HellionChat/Plugin.cs | 1 + .../SelfTests/ChannelPopoutBindStep.cs | 119 ++++++++++++++++++ .../Components/Settings/Tabs/ChannelsTab.cs | 18 ++- 3 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 HellionChat/SelfTests/ChannelPopoutBindStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 7f4e746..cc2a810 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -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), diff --git a/HellionChat/SelfTests/ChannelPopoutBindStep.cs b/HellionChat/SelfTests/ChannelPopoutBindStep.cs new file mode 100644 index 0000000..8d1b987 --- /dev/null +++ b/HellionChat/SelfTests/ChannelPopoutBindStep.cs @@ -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() { } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index 5929c6f..7c3bd2b 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -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(); 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();