test(selftest): pin v1.5.4 crossfade and quick-picker contracts
ThemeCrossfadeSelfTestStep walks Switch -> crossfade-observed -> mid-crossfade-switch -> crossfade-end -> restore using TryGetActiveCrossfade, returns Waiting frame-by-frame and Pass after the restore concludes. The mid-switch phase fires a second Switch within ~100ms of the first observed crossfade and asserts the lerped value is neither identity-from nor identity-to, exercising the ArmCrossfade mid-flight-origin override. QuickPickerSelfTestStep verifies the three new resource strings, the built-in theme floor (>=10), and Config.Tabs non-empty.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user