Merge restoration block 0 (verification truth) into v1.8.x track

This commit is contained in:
2026-05-30 08:56:00 +02:00
7 changed files with 177 additions and 66 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.5.6</Version>
<Version>1.8.1</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions -->
+20
View File
@@ -118,6 +118,14 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
internal Integrations.CustomAudioPlayer CustomAudioPlayer { get; private set; } = null!;
// Ctor-smoke anchors (B0-2). Exposed so the Payload/Chunk ctor-smoke steps
// can drive the real per-frame Lender path (Borrow()) and the eager
// singletons through the container, never via new(). Mirror of the
// FontManager property pattern — every SelfTest reaches services this way.
internal PayloadHandler PayloadHandler { get; private set; } = null!;
internal Util.Lender<PayloadHandler> PayloadHandlerLender { get; private set; } = null!;
internal Ui.Components.ChunkRenderer ChunkRenderer { get; private set; } = null!;
// Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so
// any service allocated in LoadAsync can read Plugin.PlatformUtil.
internal static IPlatformUtil PlatformUtil { get; private set; } = null!;
@@ -304,6 +312,16 @@ public sealed class Plugin : IAsyncDalamudPlugin
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
// Ctor-smoke anchors (B0-2). Resolved last, against the fully built
// container: every MakePayloadHandler dep (MainWindow, InputBar,
// ChunkRenderer, ...) is resolvable here, and the ChunkRenderer resolve
// below just reuses the same cached singleton. These are plain
// post-build container resolves (no new factory-lambda edge) — they add
// no DI cycle. See feedback_di_factory_callsite_cycles.
PayloadHandler = _host.Services.GetRequiredService<PayloadHandler>();
PayloadHandlerLender = _host.Services.GetRequiredService<Util.Lender<PayloadHandler>>();
ChunkRenderer = _host.Services.GetRequiredService<Ui.Components.ChunkRenderer>();
}
public async Task LoadAsync(CancellationToken cancellationToken)
@@ -340,6 +358,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
new SelfTests.ThemeSwitchSelfTestStep(this),
new SelfTests.ThemeCrossfadeSelfTestStep(this),
new SelfTests.FontManagerCtorSmokeStep(this),
new SelfTests.PayloadHandlerCtorSmokeStep(this),
new SelfTests.ChunkRendererCtorSmokeStep(this),
new SelfTests.FontPushSmokeStep(this),
new SelfTests.WizardStateSmokeStep(this),
new SelfTests.FoxBannerTextureSmokeStep(this),
@@ -0,0 +1,37 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// ChunkRenderer is a plain singleton (PluginHostFactory.cs:247) consumed by the
// real render path (MainWindow/MessageList/InputPreview DrawChunks). One
// resolution path is enough — unlike PayloadHandler there is no Lender. The
// type exposes no post-ctor observables (no LoadException-style state), so the
// honest assertion is "the DI ctor resolved a non-null instance". If a
// dependency registration breaks, Plugin's eager resolve throws before this
// step; the step pins that the singleton is reachable through the real
// container property, not via new().
internal sealed class ChunkRendererCtorSmokeStep : ISelfTestStep
{
private readonly Plugin plugin;
public ChunkRendererCtorSmokeStep(Plugin plugin)
{
this.plugin = plugin;
}
public string Name => "Hellion Chat - ChunkRenderer ctor smoke";
public SelfTestStepResult RunStep()
{
if (this.plugin.ChunkRenderer is null)
{
ImGui.Text("Plugin.ChunkRenderer is null");
return SelfTestStepResult.Fail;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
@@ -1,63 +0,0 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// Pins the post-migration shape of the v21 config. The plugin schema
// gate stamps Config.Version = 21 right after load, so by the time
// /xlperf reaches this step the migration must already be complete
// and the five v21 fields must carry their declared defaults on a
// fresh install (or the saved values on an existing one). The probe
// only verifies the version stamp and the field types — it does not
// rewrite the user's config.
internal sealed class ConfigMigrationV21Step : ISelfTestStep
{
public ConfigMigrationV21Step(Plugin plugin)
{
_ = plugin;
}
public string Name => "Hellion Chat - Config v21 migration";
public SelfTestStepResult RunStep()
{
if (Plugin.Config.Version != 21)
{
ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 21");
return SelfTestStepResult.Fail;
}
if (Plugin.Config.MaxParallelPopouts <= 0)
{
ImGui.Text(
$"Config.MaxParallelPopouts is {Plugin.Config.MaxParallelPopouts}, must be > 0"
);
return SelfTestStepResult.Fail;
}
if (Plugin.Config.SidebarAutoSwitchThresholdPx <= 0)
{
ImGui.Text(
$"Config.SidebarAutoSwitchThresholdPx is {Plugin.Config.SidebarAutoSwitchThresholdPx}, must be > 0"
);
return SelfTestStepResult.Fail;
}
if (!Enum.IsDefined(Plugin.Config.TellAutoOpenMode))
{
ImGui.Text($"Config.TellAutoOpenMode {Plugin.Config.TellAutoOpenMode} is out of range");
return SelfTestStepResult.Fail;
}
// MainWindowOpen and SettingsWindowOpen are bool — declaration alone
// proves the migration emitted them with defaults; reading them
// here is just a touch-test that the property is reachable.
_ = Plugin.Config.MainWindowOpen;
_ = Plugin.Config.SettingsWindowOpen;
_ = Plugin.Config.ScreenshotMode;
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
@@ -0,0 +1,69 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// Drives the per-frame Lender<PayloadHandler> path the same way MainWindow.Draw
// and InputPreview do (Borrow() + ResetCounter()), NOT the eager singleton.
// PayloadHandler is registered twice (PluginHostFactory.cs:253/254): an eager
// singleton for the init HostedServices, and a Lender<T> factory-lambda for
// per-frame isolation. MS.DI resolves factory lambdas lazily and does not
// detect cycles through them, so a Borrow() that throws is the only automated
// signal of a broken lazy ctor before the first real frame renders. A
// singleton-only smoke would resolve the eager instance and mask exactly that
// failure. Resolve through the container/Lender, never new().
internal sealed class PayloadHandlerCtorSmokeStep : ISelfTestStep
{
private readonly Plugin plugin;
public PayloadHandlerCtorSmokeStep(Plugin plugin)
{
this.plugin = plugin;
}
public string Name => "Hellion Chat - PayloadHandler ctor smoke";
public SelfTestStepResult RunStep()
{
var lender = this.plugin.PayloadHandlerLender;
if (lender is null)
{
ImGui.Text("Plugin.PayloadHandlerLender is null");
return SelfTestStepResult.Fail;
}
// Borrow() runs MakePayloadHandler's factory lambda on first use; a
// throw or null here means a broken lazy ctor. This is the real
// per-frame construction path, not the eager singleton.
var borrowed = lender.Borrow();
// Keep the probe idempotent and avoid perturbing the frame path:
// MainWindow.Draw resets this same shared Lender every frame, so
// resetting here leaves a closed-MainWindow /xlperf run clean too.
lender.ResetCounter();
if (borrowed is null)
{
ImGui.Text("Lender<PayloadHandler>.Borrow() returned null");
return SelfTestStepResult.Fail;
}
// Second construction path: the eager singleton the init HostedServices
// consume (PluginHostFactory.cs:253, :356). Assert it resolved too.
if (this.plugin.PayloadHandler is null)
{
ImGui.Text("Plugin.PayloadHandler (singleton) is null");
return SelfTestStepResult.Fail;
}
// NOTE: we deliberately do NOT assert HandleTooltips == false /
// HoveredItem == 0u. MainWindow and InputPreview share this Lender, so a
// warm pool can hand back a reused instance whose hover state was set by
// a prior frame. The honest ctor-smoke assertion is "constructs through
// the real lazy path and is reachable" — a non-default warm value does
// not contradict that.
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
+48
View File
@@ -0,0 +1,48 @@
# HellionChat SelfTest Standard
These steps run in-game via `/xlperf`. They are HellionChat's real test layer:
Dalamud-coupled classes cannot be instantiated in an xUnit AppDomain, so the
honest verification path is the running plugin, not a headless harness.
## The render-path rule (binding for every step)
A SelfTest exists to catch a broken **runtime** path. To do that it MUST:
1. **ENTRY = the real runtime entry the game calls** per frame or on the real
action — `HonorificHeader.Draw`, `ChunkRenderer.DrawChunks`,
`InputBar.TrySend`, `Sidebar.Draw`, `MessageList.Draw`,
`Lender<PayloadHandler>.Borrow()`. NEVER a helper only the test calls.
2. **ASSERT observable state produced _through_ that entry** — a rendered or
suppressed slot, a set flag, a held vs. sent message. Do NOT re-implement the
helper's logic inside the test and assert against your own copy.
3. **Wire first.** Where the real path does not yet call the correct helper,
wiring it is part of the restoration work; the SelfTest verifies only after.
## Reviewer trick (run before trusting any step)
For every helper a step calls:
```bash
grep -rn '<Helper>' HellionChat/ | grep -v SelfTests | grep -v Tests
```
Zero non-test callers = false-green suspect. The step is passing on dead code.
## The hard gate
Green steps + clean build + clean csharpier are NOT sufficient. In-game smoke
(Linux/Wine, via `/xlperf`) is the true gate. Where headless cannot honestly
verify (scroll state, real send, atlas rebuild, warm object pools), mark the
step explicitly as smoke-only instead of faking a headless pass.
## Anti-pattern of record
`HonorificService.ShouldRenderSlot` had zero production callers and was green
only because the test called it directly — a test passing on a path the game
never runs. That is the failure this standard prevents.
## Step classification
The current real-path / helper-only / mixed classification of every registered
step (with false-green suspects flagged) lives in the Obsidian vault:
`Projekte/FFXIV/Hellion Chat/Audits/HellionChat SelfTest-Klassifikation 2026-05-29.md`.
+2 -2
View File
@@ -3,7 +3,7 @@
"Author": "Jon Kazama (Hellion Forge)",
"Name": "Hellion Chat",
"InternalName": "HellionChat",
"AssemblyVersion": "1.5.6.0",
"AssemblyVersion": "1.8.1.0",
"Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR",
"ApplicableVersion": "any",
"RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat",
@@ -25,7 +25,7 @@
"DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
"DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
"DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
"TestingAssemblyVersion": "1.5.6.0",
"TestingAssemblyVersion": "1.8.1.0",
"IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png",
"ImageUrls": [
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",