using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
///
/// Campaign CH slice CH6c: — the live
/// per-window opacity mechanism ported from ChatInterface::SetOpacity/
/// SetDefaultOpacity/SetActiveOpacity (0x004F3120/0x004F3BC0/
/// 0x004F3C40).
///
///
/// #379 fix (2026-08-11, gate 4): the CH6c-era "extended to every
/// RetailWindowManager-registered window" scope (formerly register row AP-190)
/// was a bug, not a deliberate divergence — retail's ONLY two
/// ChatInterface::SetDefaultOpacity/SetActiveOpacity callers are
/// the main chat window and the four floaty chat windows (see the class's own
/// doc comment). These tests now register windows under their REAL
/// so the scope check is exercised by name, exactly
/// like the production wiring ( registers every
/// window under its constant, never an arbitrary
/// string) — the pre-fix tests used placeholder names ("Vitals", "A", "B",
/// ...) that happened to fall inside the (bugged) unscoped set and would now
/// silently fall outside the real scope for the wrong reason.
///
///
public sealed class RetailWindowOpacityControllerTests
{
private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f };
/// 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.
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, WindowNames.Chat);
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 the
// chat-scoped opacity to every future Mount* call too.
(RetailWindowHandle handle, _) = RegisterWindow(root, WindowNames.ChatWindow1);
Assert.Equal(0.3f, handle.Opacity);
}
[Fact]
public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack()
{
UiRoot root = NewRoot();
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, WindowNames.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);
}
///
/// #379 regression: pins the EXACT applied-window set to the five real
/// chat windows. Before the fix, this same setup
/// left vitals/toolbar at 0.4 (the DefaultOpacity) instead of
/// their own authored 1.0 — the user's literal complaint ("only the
/// chatwindows shall change not the other panels").
///
[Fact]
public void OpacityFade_AppliesOnlyToChatWindows_NeverOtherPanels()
{
UiRoot root = NewRoot();
(RetailWindowHandle vitals, _) = RegisterWindow(root, WindowNames.Vitals);
(RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, WindowNames.Toolbar);
(RetailWindowHandle chat, _) = RegisterWindow(root, WindowNames.Chat);
(RetailWindowHandle floaty1, UiElement floaty1Child) = RegisterWindow(root, WindowNames.ChatWindow1);
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f);
// Non-chat windows never had Apply called on them at all — they sit at
// UiElement.Opacity's own 1.0 ctor default, untouched by the slider.
Assert.Equal(1.0f, vitals.Opacity);
Assert.Equal(1.0f, toolbar.Opacity);
// The two chat windows DID get the DefaultOpacity applied.
Assert.Equal(0.4f, chat.Opacity);
Assert.Equal(0.4f, floaty1.Opacity);
root.SetKeyboardFocus(toolbarChild);
// Focusing a NON-chat window's descendant still does not touch its own
// opacity (that window was never subscribed to focus changes either)
// NOR does it bleed the ACTIVE opacity onto the chat windows — they
// stay at DefaultOpacity since neither of THEM has focus.
Assert.Equal(1.0f, vitals.Opacity);
Assert.Equal(1.0f, toolbar.Opacity);
Assert.Equal(0.4f, chat.Opacity);
Assert.Equal(0.4f, floaty1.Opacity);
root.SetKeyboardFocus(floaty1Child);
controller.SetActiveOpacity(0.6f);
// A live re-apply (SetActiveOpacity -> ReapplyAll) is ALSO scoped — the
// two non-chat windows remain untouched throughout, proving ReapplyAll
// itself (not just the initial Attach) respects the scope. The FOCUSED
// chat window picks up the new active value; the unfocused one stays
// at the (unchanged) default.
Assert.Equal(1.0f, vitals.Opacity);
Assert.Equal(1.0f, toolbar.Opacity);
Assert.Equal(0.4f, chat.Opacity);
Assert.Equal(0.6f, floaty1.Opacity);
}
[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, WindowNames.Chat);
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, WindowNames.ChatWindow1);
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, WindowNames.Chat);
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, WindowNames.ChatWindow1);
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, WindowNames.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, WindowNames.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);
}
[Fact]
public void WindowUnregistered_DetachesSubscription_AndForgetsFocusedState()
{
// CH6c review NIT: before this fix, the only detach point for a
// handle's DescendantFocusChanged subscription (and its membership in
// _focused) was the CONTROLLER's own Dispose — a window unregistered
// while it held focus stayed subscribed and pinned in _focused for
// the rest of the session. Prove the RetailWindowManager.WindowUnregistered
// wiring actually detaches: a stray post-unregister notification (the
// kind a lingering external reference to the handle could still fire)
// must not reach the controller anymore.
UiRoot root = NewRoot();
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, WindowNames.Chat);
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
root.SetKeyboardFocus(child);
Assert.Equal(1.0f, handle.Opacity);
root.WindowManager.Unregister(WindowNames.Chat);
// Unregister hides the outer frame, which drops keyboard focus off
// the now-invisible child — the manager's (still-live at that point)
// focus-change plumbing reapplies DefaultOpacity naturally. Expected
// either way; not itself the thing this test pins.
Assert.Equal(0.5f, handle.Opacity);
controller.SetActiveOpacity(0.7f);
// A stray post-unregister focus-gain notification (the kind a
// lingering external reference to the handle could still fire). If
// the controller were STILL subscribed, this would re-add the stale
// handle to _focused and apply the NEW active opacity (0.7).
handle.NotifyDescendantFocusChanged(child);
Assert.Equal(0.5f, handle.Opacity);
}
[Fact]
public void SetMutators_AfterDispose_AreNoOps()
{
UiRoot root = NewRoot();
(RetailWindowHandle handle, _) = RegisterWindow(root, WindowNames.Chat);
var controller = new RetailWindowOpacityController(
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
controller.Dispose();
controller.SetDefaultOpacity(0.9f);
controller.SetActiveOpacity(0.9f);
controller.SetOpacity(0.2f, 0.3f);
// None of the three post-Dispose calls changed anything — no
// ObjectDisposedException either, matching Dispose's own idempotent
// shape.
Assert.Equal(0.5f, controller.DefaultOpacity);
Assert.Equal(1.0f, controller.ActiveOpacity);
Assert.Equal(0.5f, handle.Opacity);
}
}