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), extended to every -registered /// window rather than retail's ChatInterface-only scope (register row AP-190). /// 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, "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); } [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, "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("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, "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); } }