Files
HellionChat/HellionChat/SelfTests/SidebarSectionHeaderStep.cs
T
JonKazama-Hellion 99dca8cb31 fix(selftests): take TabsListLock around Config.Tabs mutations
The three sidebar/coupling steps add, insert and remove tabs straight from
the framework thread while the message worker mutates the same list under
TabsListLock. CurrentTabCouplingStep's Insert(0, ...) is the worst of them:
it shifts every index, so DropOldestTempTab can remove the wrong tab between
its index lookup and RemoveAt.

Locks sit around the individual mutations, never around a Draw call, so no
step holds the lock across rendering.
2026-08-17 06:49:42 +02:00

127 lines
4.7 KiB
C#

using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
namespace HellionChat.SelfTests;
// B3-4: section headers render once per non-empty temp-tab pool, and compact
// mode suppresses the header text (separators stay). Drives the REAL
// Sidebar.Draw inside the /xlperf window frame (same render precedent as
// SidebarGreetedGlyphStep) and reads the render observability counter.
// Injects a mixed tab set (persistent + unpinned temp + pinned temp) and
// restores config in finally.
internal sealed class SidebarSectionHeaderStep : ISelfTestStep
{
private readonly Plugin plugin;
public SidebarSectionHeaderStep(Plugin plugin) => this.plugin = plugin;
public string Name => "Hellion Chat - Sidebar section header";
public SelfTestStepResult RunStep()
{
var sidebar = plugin.MainWindow.GetSidebarForSelfTest();
if (sidebar is null)
{
ImGui.Text("Sidebar null");
return SelfTestStepResult.Fail;
}
var savedCompact = Plugin.Config.AutoTellTabsCompactDisplay;
var savedSidebarWidth = Plugin.Config.SidebarWidth;
// Both headers need a populated pool behind them. Persistent tabs
// normally already exist — inject a probe only when the live config
// has none, so the section order has a real first section.
var injected = new List<Tab>();
if (Plugin.Config.Tabs.All(t => t.IsTempTab))
{
injected.Add(
new Tab
{
Name = "Persistent Probe@SelfTest",
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
}
);
}
injected.Add(BuildTempProbe("Tell Probe@SelfTest", pinned: false));
injected.Add(BuildTempProbe("Pinned Probe@SelfTest", pinned: true));
// Config.Tabs is mutated by the message worker under TabsListLock; a
// framework-thread writer must take the same lock.
lock (plugin.TabsListLock)
{
foreach (var tab in injected)
Plugin.Config.Tabs.Add(tab);
}
Tab? active = null;
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
try
{
// Headers are not width-gated, but the pinned width keeps the step
// uniform with SidebarGreetedGlyphStep (expanded rows, no min-drag
// row drops while the probes render).
Plugin.Config.SidebarWidth = 220;
Plugin.Config.AutoTellTabsCompactDisplay = false;
sidebar.Draw(width, Plugin.Config.Tabs, ref active);
if (sidebar.LastDrawnSectionHeaderCount != 2)
{
ImGui.Text(
$"Expected 2 section headers with compact OFF, got {sidebar.LastDrawnSectionHeaderCount}"
);
return SelfTestStepResult.Fail;
}
Plugin.Config.AutoTellTabsCompactDisplay = true;
sidebar.Draw(width, Plugin.Config.Tabs, ref active);
if (sidebar.LastDrawnSectionHeaderCount != 0)
{
ImGui.Text(
$"Compact ON must suppress header text, got {sidebar.LastDrawnSectionHeaderCount}"
);
return SelfTestStepResult.Fail;
}
}
finally
{
lock (plugin.TabsListLock)
{
foreach (var tab in injected)
Plugin.Config.Tabs.Remove(tab);
}
Plugin.Config.AutoTellTabsCompactDisplay = savedCompact;
Plugin.Config.SidebarWidth = savedSidebarWidth;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
// Mirror of AutoTellTabsService.BuildTempTab (the real builder is
// private); only the sheet-based tab name is replaced with a literal.
private static Tab BuildTempProbe(string name, bool pinned) =>
new()
{
Name = name,
IsTempTab = true,
IsPinned = pinned,
AllSenderMessages = true,
TellTarget = new TellTarget(name, 0, 0, TellReason.Direct),
Channel = InputChannel.Tell,
DisplayTimestamp = true,
UnreadMode = UnreadMode.Unseen,
HideWhenInactive = false,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
}