Retail EoR has no music system: the linked winmm MIDI player has zero callers, 'music' appears zero times in the 65 MB decomp, SoundType has no music member, InitPrefs registers no music key, and the install ships no music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings Music knob are deleted rather than left as an API modelling dead code — the string-keyed signature was the tell, since every other entry point is DID-keyed. Old settings.json files carrying a 'music' key still load; the reader ignores unknown keys and the next save drops it. The Ambient slider is now surfaced, because slice A5 gave it something to drive, and its default returns to retail's 1.0 from an invented 0.8 — InitPrefs defaults every sound preference to unity. The panel rule is unchanged: no slider that does nothing. r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong sections (falloff, pan, voice pool, selection, music, ambient) so a future reader reaches the lane notes instead of the Ghidra-era reads that this campaign spent its first two slices undoing. TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1 MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing assumed a music system that does not exist. The ADPCM count remains unmeasured and is named as the open question. Deferred deliberately: #321's sound-cache decode-dedup race. It is a pre-existing concurrency flake rather than audio-parity behaviour, and shipping a speculative fix to a race I have not reproduced is exactly the shortcut this project's no-workarounds rule exists to prevent. Campaign A is code-complete; the plan carries the closeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
497 lines
20 KiB
C#
497 lines
20 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]!);
|
|
}
|
|
|
|
[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}).");
|
|
}
|
|
}
|