using System.Linq; using Dalamud.Bindings.ImGui; using Dalamud.Plugin.SelfTest; namespace HellionChat.SelfTests; // In-game behavioural check of the ChannelPopoutPool lifecycle (not a non-null-handle // check — feedback_hellion_chat_fontmanager_push_trap): pre-alloc count, unique slot // ids, a TryOpen->IsOpen->TryClose round-trip, idempotent close, and capacity refusal. // The pool is a live DI singleton, so the step works against the FREE slots (not full // capacity) and only closes ids it opened — it neither false-REDs on a non-empty pool // nor disturbs real pop-outs. Pure slot-map math is pinned by PopoutSlotMapTests. 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() { } }