using System; using System.Collections.Generic; using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Journal; using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign QJ slice QJ4: the journal's searchable index /// (gmPageListUI). /// public sealed class JournalPageListControllerTests { private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); private const uint ListId = 0x10000583u; private const uint SearchFieldId = 0x10000587u; private const uint DeleteButtonId = 0x10000585u; private const uint RowNumberId = 0x1000058Au; private const uint RowTitleId = 0x1000058Bu; // ── the search predicate, ported from PageContainsString ──────────── [Fact] public void SearchMatchesLabelTitleOrNotes() { var page = new JournalPage(Label: "lab", Title: "tit", Notes: "not"); Assert.True(JournalPageListController.PageContainsString(page, "lab")); Assert.True(JournalPageListController.PageContainsString(page, "tit")); Assert.True(JournalPageListController.PageContainsString(page, "not")); Assert.False(JournalPageListController.PageContainsString(page, "zzz")); } [Fact] public void SearchIsCaseSensitiveBecauseRetailUsesWcsstr() { // Making it insensitive would be friendlier and would be a divergence. var page = new JournalPage(Title: "Aerlinthe"); Assert.True(JournalPageListController.PageContainsString(page, "Aer")); Assert.False(JournalPageListController.PageContainsString(page, "aer")); } [Fact] public void AnEmptySearchMatchesEverything() { Assert.True(JournalPageListController.PageContainsString( new JournalPage(), string.Empty)); } // ── the list ──────────────────────────────────────────────────────── private static UiText Text(uint id) => new() { DatElementId = id, Width = 90f, Height = 20f, DefaultColor = new System.Numerics.Vector4(0.8f, 0.8f, 0.8f, 1f), }; private static UiElement? RowTemplate(uint layoutId, uint elementId) { if (layoutId != JournalPageListController.RowTemplateLayoutId || elementId != JournalPageListController.RowTemplateElementId) { return null; } // A UiDatElement, as the real Type-3 template resolves to. var row = new UiDatElement( new ElementInfo { Type = 3, Width = 270, Height = 20 }, static _ => (0u, 0, 0)); row.AddChild(Text(RowNumberId)); row.AddChild(Text(RowTitleId)); return row; } private static (UiElement Page, UiField Search) BuildPage() { var list = new UiTemplateListBox( new ElementInfo { Id = ListId, Type = 5, Width = 270, Height = 430 }, static _ => (0u, 0, 0), [new UiTemplateListEntry( JournalPageListController.RowTemplateLayoutId, JournalPageListController.RowTemplateElementId)], scrollbarElementId: 0u) { DatElementId = ListId, }; var search = new UiField { ElementId = SearchFieldId, Width = 118f, Height = 18f }; search.DatElementId = SearchFieldId; var deleteButton = new UiButton( new ElementInfo { Id = DeleteButtonId, Type = 1, Width = 60, Height = 18 }, static _ => (0u, 0, 0)) { DatElementId = DeleteButtonId, }; var page = new UiPanel { Width = 300f, Height = 500f }; page.AddChild(list); page.AddChild(search); page.AddChild(deleteButton); return (page, search); } private static (JournalPageListController Controller, RuntimeJournalState State, UiElement Page, UiField Search, List Opened) Bind(params JournalPage[] pages) { var state = new RuntimeJournalState(); state.Load(pages); (UiElement page, UiField search) = BuildPage(); var opened = new List(); var controller = new JournalPageListController( page, new JournalPageListController.Bindings( Journal: state.View, Commands: state, OpenPage: opened.Add, TemplateResolver: RowTemplate, Now: () => Now)); return (controller, state, page, search, opened); } [Fact] public void EveryPageIsListedWithItsNumber() { var (controller, state, page, _, _) = Bind( new JournalPage(Title: "one"), new JournalPage(Title: "two")); Assert.Equal(new[] { 1, 2 }, controller.RowPages.ToArray()); string[] numbers = Flatten(page) .OfType() .Where(t => t.DatElementId == RowNumberId) .Select(t => t.LinesProvider!()[0].Text) .ToArray(); Assert.Equal(new[] { "1", "2" }, numbers); state.Dispose(); } [Fact] public void SearchingFiltersTheListButKeepsRealPageNumbers() { // The row number must name the page in the JOURNAL, not its position // in the filtered list — otherwise opening row 1 of a filtered list // opens the wrong page. var (controller, state, _, search, _) = Bind( new JournalPage(Title: "alpha"), new JournalPage(Title: "beta"), new JournalPage(Title: "gamma")); search.SetText("beta"); controller.Tick(); Assert.Equal(new[] { 2 }, controller.RowPages.ToArray()); state.Dispose(); } [Fact] public void AFilteredOutSelectionIsDroppedSoDeleteCannotHitAHiddenPage() { var (controller, state, _, search, _) = Bind( new JournalPage(Title: "alpha"), new JournalPage(Title: "beta")); controller.Select(1); search.SetText("beta"); controller.Tick(); Assert.Equal(0, controller.SelectedPage); state.Dispose(); } [Fact] public void DeleteWithNothingSelectedDoesNothing() { var (_, state, page, _, _) = Bind(new JournalPage(Title: "only")); (UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!(); Assert.Single(state.View.Pages); state.Dispose(); } [Fact] public void DeleteRemovesTheSelectedPage() { var (controller, state, page, _, _) = Bind( new JournalPage(Title: "one"), new JournalPage(Title: "two")); controller.Select(1); (UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!(); Assert.Equal("two", Assert.Single(state.View.Pages).Title); Assert.Equal(0, controller.SelectedPage); state.Dispose(); } // ── CheckForDoubleClick ───────────────────────────────────────────── [Fact] public void OneClickSelectsAndDoesNotOpen() { var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); controller.Click(1); Assert.Equal(1, controller.SelectedPage); Assert.Empty(opened); state.Dispose(); } [Fact] public void TwoClicksOnTheSameRowOpenIt() { var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); controller.Click(1); controller.Click(1); Assert.Equal(new[] { 1 }, opened.ToArray()); state.Dispose(); } [Fact] public void AThirdClickDoesNotReopenBecauseFiringResetsTheTracker() { // Retail clears m_LastClickIndex on a successful double-click // (@0x0049318A). Without that, every click after the second re-opens. var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); controller.Click(1); controller.Click(1); controller.Click(1); Assert.Single(opened); state.Dispose(); } [Fact] public void ClicksOnDifferentRowsAreNotADoubleClick() { var (controller, state, _, _, opened) = Bind( new JournalPage(Title: "one"), new JournalPage(Title: "two")); controller.Click(1); controller.Click(2); Assert.Empty(opened); Assert.Equal(2, controller.SelectedPage); state.Dispose(); } [Fact] public void TheDoubleClickWindowIsAFullSecond() { // Retail's is m_LastClickTime + 1.0 (@0x00493158) — NOT the 500 ms the // item-interaction path uses. Borrowing the wrong constant makes the // list feel unresponsive. var state = new RuntimeJournalState(); state.Load([new JournalPage(Title: "one")]); (UiElement page, _) = BuildPage(); var opened = new List(); DateTime now = Now; var controller = new JournalPageListController( page, new JournalPageListController.Bindings( Journal: state.View, Commands: state, OpenPage: opened.Add, TemplateResolver: RowTemplate, Now: () => now)); controller.Click(1); now = Now.AddMilliseconds(900); controller.Click(1); Assert.Single(opened); opened.Clear(); now = Now.AddSeconds(10); controller.Click(1); now = now.AddMilliseconds(1100); controller.Click(1); Assert.Empty(opened); state.Dispose(); } private static IEnumerable Flatten(UiElement e) { yield return e; foreach (UiElement child in e.Children) { foreach (UiElement descendant in Flatten(child)) yield return descendant; } } }