From 1c820b7f53fa6f0e9adf84ae075c36f9d7c7ca5f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 18 May 2026 22:03:50 +0200 Subject: [PATCH] test(selftest): register WizardStateSmokeStep for v1.5.2 wizard flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variant 1 walks the FirstRunWizard state machine through Step 1 → 4 and commits with no pending values to verify the no-op write-back path. Variant 2 picks Roleplay on Step 2, skips Step 3, commits, and asserts LoadPreviousSession / FilterIncludePreviousSessions stayed on their pre-test value — pinning the null-semantics from Spec Z.176. ApplyRoleplay would overwrite six privacy / retention fields, so the step snapshots them before Variant 2 and CleanUp() restores them, keeping the self-test idempotent across /xlperf runs. Catches state-machine throws and CommitPending NREs that would otherwise surface as a hard plugin crash during Finish ✓ clicks. Runs alongside the existing three FontManager / ThemeSwitch self-test steps. --- HellionChat/Plugin.cs | 1 + HellionChat/SelfTests/WizardStateSmokeStep.cs | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 HellionChat/SelfTests/WizardStateSmokeStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 74464d6..b486c8f 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -321,6 +321,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.ThemeSwitchSelfTestStep(this), new SelfTests.FontManagerCtorSmokeStep(this), new SelfTests.FontPushSmokeStep(this), + new SelfTests.WizardStateSmokeStep(this), ]); if (!Config.FirstRunCompleted) diff --git a/HellionChat/SelfTests/WizardStateSmokeStep.cs b/HellionChat/SelfTests/WizardStateSmokeStep.cs new file mode 100644 index 0000000..3fc3d20 --- /dev/null +++ b/HellionChat/SelfTests/WizardStateSmokeStep.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.Ui; + +namespace HellionChat.SelfTests; + +// Drives the FirstRunWizard state machine through every step and +// commits a no-op pending state (Variant 1), then re-runs picking +// Roleplay on Step 2 and skipping Step 3 (Variant 2). Verifies +// that the staged-commit path does not throw under any combination +// of Pending* values and that CommitPending leaves Config in a +// readable shape. Variant 2's Roleplay commit would normally +// mutate the six PrivacyFilter / Retention fields ApplyRoleplay +// touches, so the step snapshots them before Variant 2 runs and +// CleanUp() restores them — the self-test stays idempotent across +// repeated /xlperf runs and does not overwrite an active privacy +// profile. +internal sealed class WizardStateSmokeStep : ISelfTestStep +{ + private readonly Plugin plugin; + + // Snapshot slots for the six Configuration fields ApplyRoleplay + // writes in Variant 2. Populated right before Variant 2 mutates + // Config, consumed by CleanUp(). Reference-typed snapshots + // (HashSet, Dictionary) capture the existing slot by reference, + // which is safe because ApplyRoleplay reassigns the slot with + // a fresh instance instead of mutating in place. + private bool? snapshotPrivacyFilterEnabled; + private HashSet? snapshotPrivacyPersistChannels; + private bool? snapshotPrivacyPersistUnknownChannels; + private bool? snapshotRetentionEnabled; + private int? snapshotRetentionDefaultDays; + private Dictionary? snapshotRetentionPerChannelDays; + + public WizardStateSmokeStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - FirstRunWizard state smoke"; + + public SelfTestStepResult RunStep() + { + var wizard = this.plugin.FirstRunWizard; + if (wizard is null) + { + ImGui.Text("Plugin.FirstRunWizard is null"); + return SelfTestStepResult.Fail; + } + + try + { + // Variant 1: no-op CommitPending. Walks the state machine and + // verifies the empty-pending write-back path does not throw. + wizard.TestOnly_AdvanceTo(1); + wizard.TestOnly_AdvanceTo(2); + wizard.TestOnly_AdvanceTo(3); + wizard.TestOnly_AdvanceTo(4); + wizard.CommitPending(); + + // Variant 2: skip Step 3 explicitly. Picks Roleplay on Step 2, + // jumps straight to Step 4 (no Step-3 entry → no seed for + // LoadPreviousSession / FilterIncludePreviousSessions), commits, + // and asserts the two coupled history toggles remained on their + // pre-test value. Pins the null-semantics from Spec Z.176 so a + // regression in CommitPending that started writing seeded + // recommendations unconditionally would surface here. + // CommitPending → ApplyRoleplay overwrites six privacy / + // retention fields, so snapshot them first and let CleanUp + // restore them after the assert. Keeps /xlperf idempotent. + this.snapshotPrivacyFilterEnabled = Plugin.Config.PrivacyFilterEnabled; + this.snapshotPrivacyPersistChannels = Plugin.Config.PrivacyPersistChannels; + this.snapshotPrivacyPersistUnknownChannels = Plugin.Config.PrivacyPersistUnknownChannels; + this.snapshotRetentionEnabled = Plugin.Config.RetentionEnabled; + this.snapshotRetentionDefaultDays = Plugin.Config.RetentionDefaultDays; + this.snapshotRetentionPerChannelDays = Plugin.Config.RetentionPerChannelDays; + + var loadPrevBefore = Plugin.Config.LoadPreviousSession; + var filterPrevBefore = Plugin.Config.FilterIncludePreviousSessions; + wizard.TestOnly_AdvanceTo(2); + wizard.TestOnly_SetPendingProfile(FirstRunWizard.PrivacyProfile.Roleplay); + wizard.TestOnly_AdvanceTo(4); + wizard.CommitPending(); + if (Plugin.Config.LoadPreviousSession != loadPrevBefore) + { + ImGui.Text("Skip-Step-3 path overwrote LoadPreviousSession"); + return SelfTestStepResult.Fail; + } + if (Plugin.Config.FilterIncludePreviousSessions != filterPrevBefore) + { + ImGui.Text("Skip-Step-3 path overwrote FilterIncludePreviousSessions"); + return SelfTestStepResult.Fail; + } + } + catch (Exception ex) + { + ImGui.Text($"Wizard state smoke threw: {ex.GetType().Name}: {ex.Message}"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() + { + // Restore the six Variant-2 snapshots so back-to-back /xlperf + // runs don't drift the active privacy profile. If Variant 2 + // never ran (Variant 1 threw early), the slots stay null and + // restore is a no-op. After restore the slots are nulled so a + // future RunStep starts fresh. + if (this.snapshotPrivacyFilterEnabled is { } privacyFilter) + Plugin.Config.PrivacyFilterEnabled = privacyFilter; + if (this.snapshotPrivacyPersistChannels is { } persistChannels) + Plugin.Config.PrivacyPersistChannels = persistChannels; + if (this.snapshotPrivacyPersistUnknownChannels is { } persistUnknown) + Plugin.Config.PrivacyPersistUnknownChannels = persistUnknown; + if (this.snapshotRetentionEnabled is { } retentionEnabled) + Plugin.Config.RetentionEnabled = retentionEnabled; + if (this.snapshotRetentionDefaultDays is { } retentionDays) + Plugin.Config.RetentionDefaultDays = retentionDays; + if (this.snapshotRetentionPerChannelDays is { } retentionPolicy) + Plugin.Config.RetentionPerChannelDays = retentionPolicy; + + this.snapshotPrivacyFilterEnabled = null; + this.snapshotPrivacyPersistChannels = null; + this.snapshotPrivacyPersistUnknownChannels = null; + this.snapshotRetentionEnabled = null; + this.snapshotRetentionDefaultDays = null; + this.snapshotRetentionPerChannelDays = null; + } +}