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); } }