BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface value (0.5) as ONE shared global default applied to every RetailWindowManager-registered window, not just the four floating chat windows retail itself fades. That faded the whole out-of-box registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including several windows that can never take keyboard focus and so were stuck at 0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override (0x004CD0F0) instead — retail-identical opaque presentation for the 11 non-chat windows and the main chat window; only the four floating chat windows now diverge from retail's 0.5-while-idle default, and the Settings -> Chat transparency slider remains fully user-settable. AP-190 reworded and gains two new decomp-verified clauses: (3) retail eases opacity toward its target by 5% of the delta per tick (ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus element-messages at @0x004F5275) where acdream snaps -- deferred, needs a UI frame-tick hook the opacity controller doesn't have; (4) retail's focus predicate is the chat ENTRY FIELD specifically (ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses any-focusable-descendant. Both findings + the pre-existing UiMenu.cs PushAlphaAbsolute(1f) popup bypass are folded into the window-shell research doc's opacity section. NITs: fixed the stale "text bypasses the alpha" comment in UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/ DrawString through the same ApplyAlpha chokepoint as sprites/rects); added RetailWindowManager.WindowUnregistered + wired RetailWindowOpacityController to detach and forget a window unregistered while it held focus (previously only Dispose detached, leaking any window unregistered mid-focus for the rest of the session); added post-Dispose no-op guards to the three Set* opacity mutators; added a DrawString (BitmapFont path) alpha regression test and a DrawStringDat outline/background-pass alpha test (the existing tests only ever exercised the foreground/fill pass). Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation (caught by the full-suite run this fix requires) to match the new 1.0 default. Campaign ledger CH6c row updated to APPROVE-WITH-FIXES. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562 lines
23 KiB
C#
562 lines
23 KiB
C#
using System.Linq;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
using AcDream.UI.Abstractions.Tests.Input;
|
|
using Silk.NET.Input;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
|
|
|
/// <summary>
|
|
/// K.3: <see cref="SettingsPanel"/> renders the rebind UI on top of
|
|
/// <see cref="SettingsVM"/>. These tests use <see cref="FakePanelRenderer"/>
|
|
/// to assert the panel emits the expected widget calls — top action
|
|
/// buttons, section headers, conflict prompt when one is pending, and
|
|
/// the "Rebind" button forwarding to the VM.
|
|
/// </summary>
|
|
public sealed class SettingsPanelTests
|
|
{
|
|
private sealed class NullBus : ICommandBus
|
|
{
|
|
public void Publish<T>(T command) where T : notnull { }
|
|
}
|
|
|
|
private static (SettingsPanel panel, SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher)
|
|
Build()
|
|
{
|
|
var kb = new FakeKeyboardSource();
|
|
var mouse = new FakeMouseSource();
|
|
var persisted = new KeyBindings();
|
|
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
|
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
|
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
|
|
dispatcher.Attach();
|
|
var vm = new SettingsVM(
|
|
persisted, dispatcher, _ => { },
|
|
DisplaySettings.Default, _ => { },
|
|
AudioSettings.Default, _ => { },
|
|
GameplaySettings.Default, _ => { },
|
|
ChatSettings.Default, _ => { },
|
|
CharacterSettings.Default, _ => { });
|
|
var panel = new SettingsPanel(vm);
|
|
return (panel, vm, kb, dispatcher);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_emits_Save_Cancel_ResetAll_buttons_at_top()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer();
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains(buttonLabels, l => l == "Save changes");
|
|
Assert.Contains(buttonLabels, l => l == "Cancel changes");
|
|
Assert.Contains(buttonLabels, l => l == "Reset all to retail defaults");
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_emits_section_headers_for_each_category()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("Movement", headers);
|
|
Assert.Contains("Postures", headers);
|
|
Assert.Contains("Camera", headers);
|
|
Assert.Contains("Combat", headers);
|
|
Assert.Contains("UI panels", headers);
|
|
Assert.Contains("Chat", headers);
|
|
Assert.Contains("Hotbar", headers);
|
|
Assert.Contains("Emotes", headers);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_shows_unbound_for_actions_with_no_draft_bindings()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
// The minimal Build() table doesn't bind MovementBackup → expect "(unbound)"
|
|
// text somewhere in the call stream.
|
|
var texts = r.Calls.Where(c => c.Method == "Text")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains(texts, t => t.Contains("(unbound)"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Clicking_Rebind_button_calls_BeginRebind_on_VM()
|
|
{
|
|
var (panel, vm, _, dispatcher) = Build();
|
|
// First render — capture the rebind-button labels generated for
|
|
// bound actions. The panel uses "Rebind##{action}" so each action
|
|
// has a unique imgui ID.
|
|
var r1 = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
|
|
var rebindLabels = r1.Calls.Where(c => c.Method == "Button"
|
|
&& ((string)c.Args[0]!).StartsWith("Rebind##"))
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.NotEmpty(rebindLabels);
|
|
|
|
// Second render — simulate clicking the first Rebind button by
|
|
// making the renderer return true for every Button call. Since
|
|
// we click the first Rebind button it will invoke BeginRebind on
|
|
// some bound action.
|
|
var r2 = new FakePanelRenderer { CollapsingHeaderNextReturn = true, ButtonNextReturn = true };
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
|
|
|
|
// Either RebindInProgress is set (some action) OR HasUnsavedChanges
|
|
// changed (Save/Cancel/Reset clicked instead). Since ButtonNextReturn
|
|
// returns true for ALL buttons, multiple actions fire on this single
|
|
// render — the more relevant assertion is that the dispatcher entered
|
|
// capture mode at SOME point during the render. (ButtonNextReturn is
|
|
// a single shared return value across all buttons so multiple may
|
|
// have "clicked"; the panel's logic must still route through the VM.)
|
|
Assert.True(dispatcher.IsCapturing || vm.PendingConflict is not null
|
|
|| vm.RebindInProgress is not null
|
|
|| true /* Save/Cancel/Reset may have intervened first; this test
|
|
only proves the renderer-button path doesn't NRE */);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_with_PendingConflict_displays_conflict_prompt_buttons()
|
|
{
|
|
var (panel, vm, kb, _) = Build();
|
|
// Force a conflict by binding MovementForward → A (already
|
|
// MovementTurnLeft).
|
|
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
|
vm.BeginRebind(InputAction.MovementForward, original);
|
|
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
|
Assert.NotNull(vm.PendingConflict);
|
|
|
|
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains(buttonLabels, l => l == "Yes — Reassign");
|
|
Assert.Contains(buttonLabels, l => l == "No — Keep existing");
|
|
}
|
|
|
|
[Fact]
|
|
public void Hidden_panel_short_circuits_when_Begin_returns_false()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { BeginReturns = false };
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
// Begin + End balanced even when Begin returned false.
|
|
Assert.Contains(r.Calls, c => c.Method == "Begin");
|
|
Assert.Contains(r.Calls, c => c.Method == "End");
|
|
// Section headers should NOT have been emitted.
|
|
Assert.DoesNotContain(r.Calls, c => c.Method == "CollapsingHeader");
|
|
}
|
|
|
|
[Fact]
|
|
public void IsVisible_defaults_false()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
Assert.False(panel.IsVisible);
|
|
}
|
|
|
|
[Fact]
|
|
public void Id_is_acdream_settings()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
Assert.Equal("acdream.settings", panel.Id);
|
|
}
|
|
|
|
// -- Tabbed shell -----------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Render_opens_tab_bar_with_six_tab_items()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer();
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
// BeginTabBar exactly once, EndTabBar exactly once.
|
|
Assert.Single(r.Calls, c => c.Method == "BeginTabBar");
|
|
Assert.Single(r.Calls, c => c.Method == "EndTabBar");
|
|
|
|
// The six tab labels approved in the design brainstorm.
|
|
var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Equal(
|
|
new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" },
|
|
tabLabels);
|
|
}
|
|
|
|
[Fact]
|
|
public void Keybinds_tab_renders_section_headers_when_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
// Default ActiveTabLabel = null → FakePanelRenderer treats the
|
|
// first tab item ("Keybinds") as active.
|
|
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("Movement", headers);
|
|
Assert.Contains("Hotbar", headers);
|
|
Assert.Contains("Emotes", headers);
|
|
}
|
|
|
|
[Fact]
|
|
public void Inactive_tabs_do_not_render_keybind_section_headers()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
// Force "Display" to be the active tab — the Keybinds content
|
|
// must NOT render.
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain("Movement", headers);
|
|
Assert.DoesNotContain("Hotbar", headers);
|
|
}
|
|
|
|
// -- Character tab content -------------------------------------------
|
|
|
|
[Fact]
|
|
public void Character_tab_when_active_renders_channel_combo_plus_checkboxes()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var combos = r.Calls.Where(c => c.Method == "Combo")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("Default chat channel", combos);
|
|
|
|
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains(checks, l => l.StartsWith("Auto-attack"));
|
|
Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging"));
|
|
Assert.Contains(checks, l => l.StartsWith("Show pickup messages"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Character_tab_does_not_render_when_a_different_tab_is_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var combos = r.Calls.Where(c => c.Method == "Combo")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain("Default chat channel", combos);
|
|
}
|
|
|
|
[Fact]
|
|
public void Character_tab_channel_combo_uses_AvailableChannels_list()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel");
|
|
var items = (string[])ch.Args[2]!;
|
|
Assert.Contains("Local", items);
|
|
Assert.Contains("Allegiance", items);
|
|
Assert.Contains("Fellowship", items);
|
|
}
|
|
|
|
[Fact]
|
|
public void All_six_tabs_are_now_implemented_no_placeholder_text_remains()
|
|
{
|
|
// After the L.0 build order finishes, no tab should render the
|
|
// "Coming soon" placeholder line. If a future commit re-adds a
|
|
// placeholder tab without updating this test, it will fail.
|
|
var (panel, _, _, _) = Build();
|
|
|
|
foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" })
|
|
{
|
|
var r = new FakePanelRenderer { ActiveTabLabel = tabLabel };
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain(wrapped, t => t.Contains("coming soon"));
|
|
}
|
|
}
|
|
|
|
// -- Display tab content ---------------------------------------------
|
|
|
|
[Fact]
|
|
public void Display_tab_when_active_renders_resolution_combo_plus_sliders()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
|
|
var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList();
|
|
var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList();
|
|
|
|
Assert.Contains("Resolution", combos);
|
|
Assert.Contains("Fullscreen", checks);
|
|
Assert.Contains("V-Sync", checks);
|
|
Assert.Contains("Show FPS", checks);
|
|
Assert.Contains("Field of View", sliders);
|
|
Assert.Contains("Gamma", sliders);
|
|
}
|
|
|
|
[Fact]
|
|
public void Display_tab_does_not_render_when_a_different_tab_is_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain("Resolution", combos);
|
|
}
|
|
|
|
[Fact]
|
|
public void Display_tab_resolution_combo_uses_AvailableResolutions_list()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution");
|
|
var items = (string[])resCall.Args[2]!;
|
|
Assert.Contains("1920x1080", items);
|
|
Assert.Contains("3840x2160", items);
|
|
}
|
|
|
|
// -- Audio tab content -----------------------------------------------
|
|
|
|
[Fact]
|
|
public void Audio_tab_when_active_renders_implemented_volume_sliders()
|
|
{
|
|
// The rule is "no slider that does nothing", and Campaign A changed
|
|
// what qualifies. Ambient is now surfaced because slice A5 gave it a
|
|
// region ambient system to drive. Music is gone entirely — retail has
|
|
// no music system, so slice A6 deleted the field rather than leaving a
|
|
// knob that could never do anything.
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("Master", sliders);
|
|
Assert.Contains("SFX", sliders);
|
|
Assert.Contains("Ambient", sliders);
|
|
Assert.DoesNotContain("Music", sliders);
|
|
}
|
|
|
|
[Fact]
|
|
public void Audio_tab_does_not_render_when_a_different_tab_is_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain("Master", sliders);
|
|
Assert.DoesNotContain("Music", sliders);
|
|
}
|
|
|
|
// -- Gameplay tab content --------------------------------------------
|
|
|
|
[Fact]
|
|
public void Gameplay_tab_when_active_renders_expected_checkboxes()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
// Spot check the major retail-named toggles. Don't assert exact
|
|
// count — adding new toggles shouldn't break this test.
|
|
Assert.Contains("Auto-target on attack", checks);
|
|
Assert.Contains("Auto-repeat attacks", checks);
|
|
Assert.Contains("Run mode is toggle (vs hold)", checks);
|
|
Assert.Contains("Show item tooltips", checks);
|
|
Assert.Contains("Show helm on character", checks);
|
|
Assert.Contains("Show cloak on character", checks);
|
|
Assert.Contains("Lock UI (disable panel drag/resize)", checks);
|
|
Assert.Contains("Use mouse turning", checks);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gameplay_tab_does_not_render_when_a_different_tab_is_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain("Auto-target on attack", checks);
|
|
Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks);
|
|
}
|
|
|
|
// -- Chat tab content ------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("General", checks);
|
|
Assert.Contains("Trade", checks);
|
|
Assert.Contains("LFG (looking for group)", checks);
|
|
Assert.Contains("Roleplay", checks);
|
|
Assert.Contains("Society (CD / EW / RB)", checks);
|
|
Assert.Contains("Show timestamps", checks);
|
|
Assert.Contains("Filter profanity", checks);
|
|
Assert.Contains("Appear offline (hide from /who)", checks);
|
|
|
|
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("Font size (pt)", sliders);
|
|
}
|
|
|
|
[Fact]
|
|
public void Chat_tab_does_not_render_when_a_different_tab_is_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var checks = r.Calls.Where(c => c.Method == "Checkbox")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
// The tab labels "General", "Trade" etc only appear inside the
|
|
// Chat tab. Confirm none of them rendered.
|
|
Assert.DoesNotContain("General", checks);
|
|
Assert.DoesNotContain("Trade", checks);
|
|
}
|
|
|
|
[Fact]
|
|
public void Audio_sliders_are_clamped_to_zero_one_range()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master");
|
|
Assert.Equal(0f, (float)masterCall.Args[2]!);
|
|
Assert.Equal(1f, (float)masterCall.Args[3]!);
|
|
}
|
|
|
|
// -- Campaign CH slice CH6c: chat tab opacity sliders -----------------
|
|
|
|
[Fact]
|
|
public void Chat_tab_when_active_renders_two_linked_opacity_sliders()
|
|
{
|
|
var (panel, vm, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.Contains("Background opacity (unfocused)", sliders);
|
|
Assert.Contains("Active opacity (typing / focused)", sliders);
|
|
|
|
var bgCall = r.Calls.First(
|
|
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Background opacity (unfocused)");
|
|
Assert.Equal(vm.ChatDraft.DefaultOpacity, (float)bgCall.Args[1]!);
|
|
Assert.Equal(0f, (float)bgCall.Args[2]!);
|
|
Assert.Equal(1f, (float)bgCall.Args[3]!);
|
|
|
|
var activeCall = r.Calls.First(
|
|
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Active opacity (typing / focused)");
|
|
Assert.Equal(vm.ChatDraft.ActiveOpacity, (float)activeCall.Args[1]!);
|
|
}
|
|
|
|
[Fact]
|
|
public void Chat_tab_opacity_sliders_do_not_render_when_a_different_tab_is_active()
|
|
{
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
|
.Select(c => (string)c.Args[0]!).ToList();
|
|
Assert.DoesNotContain("Background opacity (unfocused)", sliders);
|
|
Assert.DoesNotContain("Active opacity (typing / focused)", sliders);
|
|
}
|
|
|
|
[Fact]
|
|
public void Chat_tab_dragging_active_opacity_below_background_drags_background_down_in_draft()
|
|
{
|
|
// FakePanelRenderer applies ONE injected value to every SliderFloat call
|
|
// in the same Render pass and each branch's `_vm.SetChat` starts from the
|
|
// ORIGINAL pre-render draft — so with SliderFloatNextReturn set, the
|
|
// LAST-rendered opacity slider ("Active", rendered after "Background")
|
|
// determines the final draft. That exercises ChatOpacityLink.SetActive's
|
|
// drag-background-down path end-to-end through the real panel code,
|
|
// starting from ChatSettings.Default (DefaultOpacity=1.0, ActiveOpacity=1.0
|
|
// as of the CH6c review fix).
|
|
var (panel, vm, _, _) = Build();
|
|
var r = new FakePanelRenderer
|
|
{
|
|
ActiveTabLabel = "Chat",
|
|
SliderFloatNextReturn = true,
|
|
SliderFloatNextValue = 0.2f,
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
Assert.Equal(0.2f, vm.ChatDraft.DefaultOpacity);
|
|
Assert.Equal(0.2f, vm.ChatDraft.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Save_Cancel_buttons_render_outside_the_tab_bar()
|
|
{
|
|
// The global Save / Cancel / Reset-all row must come BEFORE
|
|
// BeginTabBar so it stays visible on every tab. Any change that
|
|
// accidentally moves the buttons inside a tab item should fail
|
|
// here.
|
|
var (panel, _, _, _) = Build();
|
|
var r = new FakePanelRenderer();
|
|
|
|
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
|
|
|
int saveIdx = r.Calls.FindIndex(c => c.Method == "Button"
|
|
&& (string)c.Args[0]! == "Save changes");
|
|
int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar");
|
|
Assert.True(saveIdx >= 0);
|
|
Assert.True(tabBarIdx >= 0);
|
|
Assert.True(saveIdx < tabBarIdx,
|
|
$"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx}).");
|
|
}
|
|
}
|