diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 8ad1595..fa37ccf 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -330,9 +330,11 @@ public sealed class Plugin : IAsyncDalamudPlugin SelfTestRegistry.RegisterTestSteps([ new SelfTests.ThemeSwitchSelfTestStep(this), + new SelfTests.ThemeCrossfadeSelfTestStep(this), new SelfTests.FontManagerCtorSmokeStep(this), new SelfTests.FontPushSmokeStep(this), new SelfTests.WizardStateSmokeStep(this), + new SelfTests.QuickPickerSelfTestStep(this), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/QuickPickerSelfTestStep.cs b/HellionChat/SelfTests/QuickPickerSelfTestStep.cs new file mode 100644 index 0000000..ec0e537 --- /dev/null +++ b/HellionChat/SelfTests/QuickPickerSelfTestStep.cs @@ -0,0 +1,64 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Resources; + +namespace HellionChat.SelfTests; + +// Verifies the v1.5.4 PM-2 quick-picker plumbing without rendering: +// resource strings resolve, the theme registry yields the expected +// minimum built-in count, and Config.Tabs is populated. +internal sealed class QuickPickerSelfTestStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public QuickPickerSelfTestStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - Quick picker plumbing"; + + public SelfTestStepResult RunStep() + { + if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Tooltip)) + { + ImGui.Text("Settings_QuickPicker_Tooltip is empty in the active locale."); + return SelfTestStepResult.Fail; + } + if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Themes_Header)) + { + ImGui.Text("Settings_QuickPicker_Themes_Header is empty in the active locale."); + return SelfTestStepResult.Fail; + } + if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Tabs_Header)) + { + ImGui.Text("Settings_QuickPicker_Tabs_Header is empty in the active locale."); + return SelfTestStepResult.Fail; + } + + var registry = this.plugin.ThemeRegistry; + if (registry is null) + { + ImGui.Text("ThemeRegistry not resolved."); + return SelfTestStepResult.Fail; + } + + var builtIns = registry.AllBuiltIns().ToList(); + if (builtIns.Count < 10) + { + ImGui.Text($"Expected at least 10 built-in themes, found {builtIns.Count}."); + return SelfTestStepResult.Fail; + } + + var tabs = Plugin.Config.Tabs; + if (tabs is null || tabs.Count == 0) + { + ImGui.Text("Config.Tabs is empty."); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/ThemeCrossfadeSelfTestStep.cs b/HellionChat/SelfTests/ThemeCrossfadeSelfTestStep.cs new file mode 100644 index 0000000..d1034a3 --- /dev/null +++ b/HellionChat/SelfTests/ThemeCrossfadeSelfTestStep.cs @@ -0,0 +1,216 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Themes; + +namespace HellionChat.SelfTests; + +// Verifies the v1.5.4 PM-1 crossfade contract: switching the active +// theme arms TryGetActiveCrossfade for ~300ms, then the registry +// returns to direct AbgrCache reads. A second switch within 100ms +// keeps the lerped path active (no identity-snap). CleanUp restores +// the initial theme so /xlperf stays idempotent. +internal sealed class ThemeCrossfadeSelfTestStep : ISelfTestStep +{ + private readonly Plugin plugin; + + private string? initialSlug; + private string? targetSlug; + private string? midSwitchSlug; + private long armedAtTickMs = long.MinValue; + private long midArmedAtTickMs = long.MinValue; + private bool sawCrossfade; + private bool sawMidCrossfadeSwitch; + private bool sawCrossfadeEnd; + private bool restoredInitial; + + public ThemeCrossfadeSelfTestStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - Theme crossfade"; + + public SelfTestStepResult RunStep() + { + var registry = this.plugin.ThemeRegistry; + if (registry is null) + return SelfTestStepResult.Fail; + + if (this.initialSlug is null) + { + this.initialSlug = registry.Active.Slug; + this.targetSlug = PickDifferentSlug(registry, this.initialSlug); + if (this.targetSlug is null) + { + ImGui.Text("Need at least two themes available; only one built-in found."); + return SelfTestStepResult.Fail; + } + + registry.Switch(this.targetSlug); + this.armedAtTickMs = Environment.TickCount64; + ImGui.Text($"Crossfade armed: {this.initialSlug} -> {this.targetSlug}"); + return SelfTestStepResult.Waiting; + } + + if (!this.sawCrossfade) + { + if (registry.TryGetActiveCrossfade(out _)) + { + this.sawCrossfade = true; + this.midArmedAtTickMs = Environment.TickCount64; + ImGui.Text("Crossfade observed mid-window, arming mid-switch test..."); + return SelfTestStepResult.Waiting; + } + + // If the window already closed before we observed it, that + // is acceptable only on extremely slow frame paths; accept + // it as "saw the start" if more than 300ms have elapsed. + // Skip the mid-crossfade-switch phase in that case -- the + // lerped path is no longer active, so a second switch would + // re-arm a fresh crossfade and not exercise PM-1b's + // mid-flight-origin override. + if (Environment.TickCount64 - this.armedAtTickMs > 300) + { + this.sawCrossfade = true; + this.sawMidCrossfadeSwitch = true; + this.sawCrossfadeEnd = true; + ImGui.Text("Crossfade window closed before observation; accepting."); + return SelfTestStepResult.Waiting; + } + + return SelfTestStepResult.Waiting; + } + + if (!this.sawMidCrossfadeSwitch) + { + // PM-Test-3 mid-crossfade-switch phase: within ~100ms of the + // first observed crossfade, fire a second Switch to a THIRD + // theme. ArmCrossfade must compose the current lerped state + // as the new origin -- TryGetActiveCrossfade still returns + // true (lerped path stays active, no identity-snap) and the + // lerped value is neither the identity-from nor the + // identity-to of the new switch (origin shifted to the + // mid-flight cache, target is the third theme). + if (Environment.TickCount64 - this.midArmedAtTickMs < 100) + { + this.midSwitchSlug = PickDifferentSlug( + registry, + [this.initialSlug!, this.targetSlug!] + ); + if (this.midSwitchSlug is null) + { + // Only two themes available -- mid-switch phase cannot + // exercise the lerped-origin path. Accept and move on + // (the v1.5.3 baseline ships >=10 built-ins, so this + // branch is defensive). + this.sawMidCrossfadeSwitch = true; + ImGui.Text("Only two themes available; skipping mid-switch assert."); + return SelfTestStepResult.Waiting; + } + + var fromCache = registry.Active.AbgrCache; + registry.Switch(this.midSwitchSlug); + var toCache = registry.Active.AbgrCache; + + if (!registry.TryGetActiveCrossfade(out var midLerped)) + { + ImGui.Text("Mid-switch failed: TryGetActiveCrossfade returned false."); + return SelfTestStepResult.Fail; + } + + // Lerped value must be neither the new identity-from + // (target cache of the first switch) nor the new + // identity-to (third theme cache) -- it must originate + // from the mid-flight composed snapshot. + if (midLerped.Equals(fromCache) || midLerped.Equals(toCache)) + { + ImGui.Text("Mid-switch failed: lerped value is an identity snap."); + return SelfTestStepResult.Fail; + } + + this.sawMidCrossfadeSwitch = true; + ImGui.Text( + $"Mid-switch armed: {this.targetSlug} -> {this.midSwitchSlug} (lerped origin)." + ); + return SelfTestStepResult.Waiting; + } + + // Window for mid-switch already elapsed; accept and continue. + this.sawMidCrossfadeSwitch = true; + ImGui.Text("Mid-switch window elapsed before fire; accepting."); + return SelfTestStepResult.Waiting; + } + + if (!this.sawCrossfadeEnd) + { + if (!registry.TryGetActiveCrossfade(out _)) + { + this.sawCrossfadeEnd = true; + ImGui.Text("Crossfade window closed cleanly."); + return SelfTestStepResult.Waiting; + } + return SelfTestStepResult.Waiting; + } + + if (!this.restoredInitial) + { + registry.Switch(this.initialSlug); + this.restoredInitial = true; + ImGui.Text($"Restored: {this.initialSlug}"); + return SelfTestStepResult.Waiting; + } + + // Wait for the restore-crossfade to also conclude before + // declaring Pass, so /xlperf does not flicker out mid-fade. + if (registry.TryGetActiveCrossfade(out _)) + return SelfTestStepResult.Waiting; + + return SelfTestStepResult.Pass; + } + + public void CleanUp() + { + // Best-effort: if anything went sideways, snap back to the + // initial slug. Switch is idempotent on same-slug. + var registry = this.plugin.ThemeRegistry; + if (registry is not null && this.initialSlug is not null) + { + registry.Switch(this.initialSlug); + } + + this.initialSlug = null; + this.targetSlug = null; + this.midSwitchSlug = null; + this.armedAtTickMs = long.MinValue; + this.midArmedAtTickMs = long.MinValue; + this.sawCrossfade = false; + this.sawMidCrossfadeSwitch = false; + this.sawCrossfadeEnd = false; + this.restoredInitial = false; + } + + private static string? PickDifferentSlug(ThemeRegistry registry, string activeSlug) => + PickDifferentSlug(registry, [activeSlug]); + + private static string? PickDifferentSlug( + ThemeRegistry registry, + IReadOnlyCollection excludeSlugs + ) + { + foreach (var theme in registry.AllBuiltIns()) + { + var match = false; + foreach (var excluded in excludeSlugs) + { + if (string.Equals(theme.Slug, excluded, StringComparison.OrdinalIgnoreCase)) + { + match = true; + break; + } + } + if (!match) + return theme.Slug; + } + return null; + } +}