fix(tell): couple CurrentTab to the active tab, retire LastTab

Plugin.CurrentTab now delegates to MainWindow.ActiveTab (fallback Tabs[0]) instead of the never-assigned LastTab index, so the game hooks, unread tracking, notification sounds, InputDisabled and Foray/Eureka paths all operate on the tab the user actually has selected. The dead LastTab/WantedTab fields and both WantedTab writes are removed.

A reference-based MainWindow.ResetActiveTabIfRemoved repairs the active-tab reference on eviction/logout (immune to the SaveConfig temp-tab strip window). The worker-thread eviction path marshals it onto the framework thread so the strip mutation serializes with Draw; logout is already framework-thread. The Draw-seed gains a lazy re-seed for a wholesale config swap. Adds CurrentTabCouplingStep (headless) and the interactive CurrentTabGuidedStep self-test (step count 30 -> 32).
This commit is contained in:
2026-06-13 16:09:59 +02:00
parent ad635c77c1
commit 0ca8513065
5 changed files with 314 additions and 26 deletions
@@ -0,0 +1,141 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
namespace HellionChat.SelfTests;
// F2 (guided): interactive, fires NO synthetic probes. Shows the full measured
// state every frame so a result is observable, not a guess, and walks the user
// through the real switch-away-and-back flow. It verifies the PRIVACY-relevant
// effect, keyed on the tab type:
// - a NORMAL tab carrying a game-side tell must lose its RUNTIME target
// (CurrentChannel.TellTarget) on switch-away-and-back (the F1 strip), so a
// typed line can't /tell the old partner;
// - a BOUND auto-tell tab keeps its partner by design (leg1) — its binding is
// Tab.TellTarget and is deliberately untouched by the strip.
// The channel label is intentionally NOT asserted: a tell tab re-derives back to
// Tell after the strip (spec TR-7); only the target matters for privacy.
internal sealed class CurrentTabGuidedStep : ISelfTestStep
{
private readonly Plugin _plugin;
// 0 = waiting for a tell; 1 = tell seen, waiting to switch AWAY; 2 = switched
// away, waiting to come BACK to the tracked tab.
private int _phase;
private Tab? _tellTab;
private bool _wasBound;
private string _seenPartner = "";
public CurrentTabGuidedStep(Plugin plugin)
{
_plugin = plugin;
}
public string Name => "Hellion Chat - Tell target cleared on tab switch (guided)";
public SelfTestStepResult RunStep()
{
var active = _plugin.CurrentTab;
var cc = active.CurrentChannel;
var bound = active.TellTarget?.IsSet() == true;
var runtime = cc.TellTarget?.IsSet() == true;
// Live diagnostics every frame — a result is never a guess.
ImGui.Text($"Active tab : {active.Name}");
ImGui.Text($"Channel : {cc.Channel}");
ImGui.Text($"Runtime target : {DescribeTarget(cc.TellTarget)}");
ImGui.Text($"Tab-bound (leg1): {(bound ? $"yes -> {active.TellTarget!.Name}" : "no")}");
if (_tellTab is not null)
ImGui.Text(
$"Tracking '{_tellTab.Name}' (bound: {_wasBound}, partner: {_seenPartner})"
);
ImGui.Separator();
if (ImGui.Button("Skip##guided-tellflow"))
{
ImGui.Text("Skipped by user — not verified.");
return SelfTestStepResult.Pass;
}
// Restart cleanly if the tracked tab is evicted mid-flow.
if (_tellTab is not null && !Plugin.Config.Tabs.Contains(_tellTab))
{
ImGui.Text(">> Tracked tab was removed; restarting.");
Reset();
}
if (_phase == 0)
{
ImGui.Text(">> Step 1: get a tab into Tell — /tell from a normal tab (stay on it),");
ImGui.Text(" or open an auto-tell tab. Watch the lines above update.");
if (cc.Channel == InputChannel.Tell && (runtime || bound))
{
_tellTab = active;
_wasBound = bound;
_seenPartner = bound ? active.TellTarget!.Name : cc.TellTarget!.Name;
_phase = 1;
}
return SelfTestStepResult.Waiting;
}
if (_phase == 1)
{
ImGui.Text(">> Step 2: now click AWAY to a different tab.");
if (!ReferenceEquals(active, _tellTab))
_phase = 2;
return SelfTestStepResult.Waiting;
}
// _phase == 2: switched away; wait to come BACK, then check the target.
ImGui.Text($">> Step 3: now click BACK onto '{_tellTab!.Name}'.");
if (!ReferenceEquals(active, _tellTab))
return SelfTestStepResult.Waiting;
if (_wasBound)
{
// leg1: the binding lives on Tab.TellTarget and must survive the strip.
if (_tellTab.TellTarget?.IsSet() == true)
{
ImGui.Text(
"PASS: bound auto-tell tab kept its partner (leg1 — the conversation stays)."
);
return SelfTestStepResult.Pass;
}
ImGui.Text(
$"FAIL: bound tab LOST partner '{_seenPartner}' — leg1 was wrongly stripped."
);
return SelfTestStepResult.Fail;
}
// non-bound: the stale RUNTIME target must be gone (the privacy strip).
if (_tellTab.CurrentChannel.TellTarget?.IsSet() != true)
{
ImGui.Text(
$"PASS: stale partner '{_seenPartner}' cleared — a typed line won't /tell them."
);
return SelfTestStepResult.Pass;
}
ImGui.Text(
"FAIL: stale runtime partner still bound after switch-away-and-back — privacy leak."
);
return SelfTestStepResult.Fail;
}
private static string DescribeTarget(TellTarget? t) =>
t?.IsSet() == true ? $"{t.Name} (World {t.World})" : "none";
private void Reset()
{
_phase = 0;
_tellTab = null;
_wasBound = false;
_seenPartner = "";
}
public void CleanUp() => Reset();
}