acdream/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
Erik a819687cf0 feat(chat): Campaign CH slice CH6c — window opacity + transparency setting
Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.

RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.

Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.

Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.

Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.

Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:00:55 +02:00

188 lines
7.9 KiB
C#

using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign CH slice CH6c: <see cref="RetailWindowOpacityController"/> — the live
/// per-window opacity mechanism ported from <c>ChatInterface::SetOpacity/
/// SetDefaultOpacity/SetActiveOpacity</c> (<c>0x004F3120</c>/<c>0x004F3BC0</c>/
/// <c>0x004F3C40</c>), extended to every <see cref="RetailWindowManager"/>-registered
/// window rather than retail's ChatInterface-only scope (register row AP-190).
/// </summary>
public sealed class RetailWindowOpacityControllerTests
{
private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f };
/// <summary>Registers a bare window ("outer frame" + one focusable child) and
/// returns both — mirrors how a real window has a content descendant the
/// chat entry / any input field could take focus on.</summary>
private static (RetailWindowHandle handle, UiElement child) RegisterWindow(UiRoot root, string name)
{
var frame = new UiPanel { Width = 100f, Height = 100f };
var child = new UiPanel { Width = 10f, Height = 10f, AcceptsFocus = true };
frame.AddChild(child);
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(name, frame);
return (handle, child);
}
[Fact]
public void Construction_AttachesToAlreadyRegisteredWindows_AtDefaultOpacity()
{
UiRoot root = NewRoot();
(RetailWindowHandle handle, _) = RegisterWindow(root, "Vitals");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
// No descendant has focus yet — every window starts at the DEFAULT
// (unfocused) opacity, matching retail's unfocused ChatInterface state.
Assert.Equal(0.5f, handle.Opacity);
Assert.Equal(0.5f, controller.DefaultOpacity);
Assert.Equal(1.0f, controller.ActiveOpacity);
}
[Fact]
public void WindowRegisteredAfterConstruction_PicksUpLiveOpacityImmediately()
{
UiRoot root = NewRoot();
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.9f);
// The window is mounted AFTER the controller exists — proves the
// RetailWindowManager.WindowRegistered subscription (not just the ctor's
// catch-up loop over already-registered windows) is what applies retail's
// GLOBAL opacity scope to every future Mount* call too.
(RetailWindowHandle handle, _) = RegisterWindow(root, "Toolbar");
Assert.Equal(0.3f, handle.Opacity);
}
[Fact]
public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack()
{
UiRoot root = NewRoot();
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
Assert.Equal(0.5f, handle.Opacity);
root.SetKeyboardFocus(child);
Assert.Equal(1.0f, handle.Opacity);
root.SetKeyboardFocus(null);
Assert.Equal(0.5f, handle.Opacity);
GC.KeepAlive(controller);
}
[Fact]
public void OpacityFade_AppliesToEveryRegisteredWindow_NotJustChat()
{
// The CH6c scope extension: retail's ChatInterface::SetOpacity only ever
// runs on chat-derived windows. acdream applies the SAME mechanism to
// every RetailWindowManager window — vitals, toolbar, whatever else is
// mounted — matching the task's GLOBAL-option framing.
UiRoot root = NewRoot();
(RetailWindowHandle vitals, _) = RegisterWindow(root, "Vitals");
(RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, "Toolbar");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f);
Assert.Equal(0.4f, vitals.Opacity);
Assert.Equal(0.4f, toolbar.Opacity);
root.SetKeyboardFocus(toolbarChild);
Assert.Equal(0.4f, vitals.Opacity); // unrelated window: still unfocused
Assert.Equal(1.0f, toolbar.Opacity); // the focused one: active
}
[Fact]
public void SetDefaultOpacity_AboveCurrentActive_DragsActiveUp_AndReappliesEverywhere()
{
// Decomp-verified linking (ChatInterface::SetDefaultOpacity @0x004F3BC0):
// raising DEFAULT above the current ACTIVE value drags active UP to
// match — it never clamps the default down instead.
UiRoot root = NewRoot();
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.5f);
root.SetKeyboardFocus(focusedChild);
Assert.Equal(0.3f, unfocused.Opacity);
Assert.Equal(0.5f, focused.Opacity);
controller.SetDefaultOpacity(0.9f);
Assert.Equal(0.9f, controller.DefaultOpacity);
Assert.Equal(0.9f, controller.ActiveOpacity);
Assert.Equal(0.9f, unfocused.Opacity);
Assert.Equal(0.9f, focused.Opacity);
}
[Fact]
public void SetActiveOpacity_BelowCurrentDefault_DragsDefaultDown_AndReappliesEverywhere()
{
// Symmetric case (ChatInterface::SetActiveOpacity @0x004F3C40).
UiRoot root = NewRoot();
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 0.7f);
root.SetKeyboardFocus(focusedChild);
controller.SetActiveOpacity(0.1f);
Assert.Equal(0.1f, controller.DefaultOpacity);
Assert.Equal(0.1f, controller.ActiveOpacity);
Assert.Equal(0.1f, unfocused.Opacity);
Assert.Equal(0.1f, focused.Opacity);
}
[Fact]
public void SetOpacity_AppliesBothInRetailsUpdateFromPlayerModuleOrder()
{
// UpdateFromPlayerModule (0x004CE3F0) reads/applies Default first, then
// Active — the shape used to push a freshly loaded ChatSettings pair.
UiRoot root = NewRoot();
(RetailWindowHandle handle, _) = RegisterWindow(root, "Chat");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
controller.SetOpacity(defaultOpacity: 0.2f, activeOpacity: 0.6f);
Assert.Equal(0.2f, controller.DefaultOpacity);
Assert.Equal(0.6f, controller.ActiveOpacity);
Assert.Equal(0.2f, handle.Opacity);
}
[Fact]
public void ConstructorSeed_EnforcesTheActiveGreaterThanOrEqualDefaultInvariant()
{
// A corrupt/hand-edited settings.json could carry active < default.
// The seed collapses through the SAME link the live setters use.
UiRoot root = NewRoot();
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.8f, activeOpacity: 0.2f);
Assert.True(controller.ActiveOpacity >= controller.DefaultOpacity);
Assert.Equal(0.2f, controller.DefaultOpacity);
Assert.Equal(0.2f, controller.ActiveOpacity);
}
[Fact]
public void Dispose_UnsubscribesFromFocusChanges()
{
UiRoot root = NewRoot();
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
controller.Dispose();
root.SetKeyboardFocus(child);
// Still at the last value the controller applied before disposal — a
// focus change after Dispose is not observed anymore.
Assert.Equal(0.5f, handle.Opacity);
}
}