using System; using System.Collections.Generic; using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Net.Messages; using AcDream.Core.Quests; using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign QT slice QT5: the Journal panel's Contracts page. /// public sealed class JournalContractsPageControllerTests { private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); // The authored ids, from the installed dats. private const uint ListId = 0x100005CFu; private const uint RowNameId = 0x100005D1u; private const uint RowStatusId = 0x100005D2u; private const uint StatusValueId = 0x100005DFu; private const uint ContactValueId = 0x100005E0u; private const uint ContactLocationValueId = 0x100005E1u; private const uint QuestLocationValueId = 0x100005E2u; private const uint DescriptionId = 0x100005DEu; private const uint TimedValueId = 0x100005E3u; /// /// The authored row name/status colour. Deliberately NOT white: the /// selection highlight paints white, so a fixture that starts white cannot /// observe the highlight at all. /// private static readonly System.Numerics.Vector4 AuthoredTextColor = new(0.8f, 0.8f, 0.8f, 1f); private static UiText Text(uint id) => new() { DatElementId = id, Width = 200f, Height = 18f, DefaultColor = AuthoredTextColor, }; /// /// A page carrying the same child ids the real one does, so the controller /// resolves exactly what production resolves. /// private static (UiElement Page, UiTemplateListBox List) BuildPage() { var listInfo = new ElementInfo { Id = ListId, Type = 5, Width = 270, Height = 298, }; var list = new UiTemplateListBox( listInfo, static _ => (0u, 0, 0), [new UiTemplateListEntry( JournalContractsPageController.RowTemplateLayoutId, JournalContractsPageController.RowTemplateElementId)], scrollbarElementId: 0u) { // LayoutImporter.Build sets this in production; a directly // constructed widget has it at 0 and FindDescendant never sees it. DatElementId = ListId, }; var page = new UiPanel { Width = 300f, Height = 500f }; page.AddChild(list); foreach (uint id in new[] { StatusValueId, ContactValueId, ContactLocationValueId, QuestLocationValueId, DescriptionId, TimedValueId, }) { page.AddChild(Text(id)); } return (page, list); } /// /// One row: a name text and a status text, as retail authors. /// /// /// The root is a because that is what the real /// Type-3 template resolves to through DatWidgetFactory's generic fallback /// arm — and its click-through default is the entire point of one of the /// tests below. A UiPanel here would make that test pass vacuously. /// private static UiElement? RowTemplate(uint layoutId, uint elementId) { if (layoutId != JournalContractsPageController.RowTemplateLayoutId || elementId != JournalContractsPageController.RowTemplateElementId) { return null; } var row = new UiDatElement( new ElementInfo { Id = JournalContractsPageController.RowTemplateElementId, Type = 3, Width = 270, Height = 20, }, static _ => (0u, 0, 0)); row.AddChild(Text(RowNameId)); row.AddChild(Text(RowStatusId)); return row; } private static ContractCatalog Catalog(params ContractEntry[] entries) => new(entries.ToDictionary(e => e.ContractId)); private static ContractEntry Entry( uint id, string name, string description = "", string contact = "", string progressFormat = "", string repeatFlag = "", uint contactCell = 0u, uint questCell = 0u) => ContractEntry.Unknown with { ContractId = id, ContractName = name, Description = description, NameNpcStart = contact, DescriptionProgress = progressFormat, QuestflagRepeatTime = repeatFlag, LocationNpcStartCell = contactCell, LocationQuestAreaCell = questCell, }; private static JournalContractsPageController Bind( UiElement page, RuntimeContractState state, ContractCatalog catalog, DateTime? now = null) => new(page, new JournalContractsPageController.Bindings( Contracts: state.View, Catalog: () => catalog, Now: () => now ?? Now, TemplateResolver: RowTemplate)); private static void Track( RuntimeContractState state, uint contractId, uint stage, double whenRepeats = 0d, double whenDone = 0d, bool setAsDisplay = false) => state.ApplyUpdate(new ContractTrackerUpdate( new ContractTracker(1u, contractId, (ContractStage)stage, whenDone, whenRepeats, Now), Delete: false, SetAsDisplay: setAsDisplay)); private static string TextOf(UiElement page, uint id) { var text = UiElement.FindDescendant(page, id) as UiText; return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty; } [Fact] public void RowsCarryTheAuthoredNameAndTheRetailProgressText() { // The wire sends only an id and a stage; the name comes from the dat // and the status from FillProgressString. Getting either from the // wrong source is the failure this pins. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); JournalContractsPageController page1 = Bind( page, state, Catalog(Entry(0x10u, "Aerlinthe Recall Ring"))); Assert.Equal(new[] { 0x10u }, page1.RowContractIds.ToArray()); UiText name = Assert.IsType(Assert.Single( UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten), e => (e as UiText)?.DatElementId == RowNameId)); Assert.Equal("Aerlinthe Recall Ring", name.LinesProvider!()[0].Text); } private static IEnumerable Flatten(UiElement e) { yield return e; foreach (UiElement child in e.Children) { foreach (UiElement descendant in Flatten(child)) yield return descendant; } } [Fact] public void TheDetailPaneShowsTheSelectedContract() { (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Track(state, 0x20u, stage: 1u); JournalContractsPageController controller = Bind(page, state, Catalog( Entry(0x10u, "First", description: "Kill the thing.", contact: "Bob"), Entry(0x20u, "Second", description: "Find the other thing.", contact: "Alice"))); controller.Select(0x20u); Assert.Equal("Find the other thing.", TextOf(page, DescriptionId)); Assert.Equal("Alice", TextOf(page, ContactValueId)); Assert.Equal("Available", TextOf(page, StatusValueId)); } [Fact] public void TheServersDisplayContractIsWhatOpensSelected() { // SetAsDisplayContract is the server nominating what to show. Ignoring // it and always selecting the first row would show the wrong quest // right after the one the player just accepted. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Track(state, 0x20u, stage: 2u, setAsDisplay: true); JournalContractsPageController controller = Bind(page, state, Catalog( Entry(0x10u, "First"), Entry(0x20u, "Second"))); Assert.Equal(0x20u, controller.SelectedContractId); } [Fact] public void WithNoDisplayContractTheFirstRowStands() { (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x30u, stage: 2u); Track(state, 0x10u, stage: 2u); JournalContractsPageController controller = Bind(page, state, Catalog( Entry(0x10u, "First"), Entry(0x30u, "Third"))); // GetContracts orders by id, so 0x10 is first. Assert.Equal(0x10u, controller.SelectedContractId); } [Fact] public void AnEmptyTrackerClearsTheDetailPaneRatherThanStrandingText() { (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); JournalContractsPageController controller = Bind( page, state, Catalog(Entry(0x10u, "First", description: "Kill it."))); Assert.Equal("Kill it.", TextOf(page, DescriptionId)); state.ApplyTable(new Dictionary()); controller.Tick(); Assert.Equal(0u, controller.SelectedContractId); Assert.Equal(string.Empty, TextOf(page, DescriptionId)); Assert.Empty(controller.RowContractIds); } [Fact] public void TheListRebuildsOnlyWhenTheTrackerActuallyMoved() { // Tick runs every frame while the tracker changes rarely. A rebuild // per frame would re-Build a template row list continuously and reset // the player's scroll under them. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); int builds = 0; var controller = new JournalContractsPageController( page, new JournalContractsPageController.Bindings( Contracts: state.View, Catalog: () => Catalog(Entry(0x10u, "First")), Now: () => Now, TemplateResolver: (l, e) => { builds++; return RowTemplate(l, e); })); Track(state, 0x10u, stage: 2u); controller.Tick(); int afterFirstChange = builds; for (int frame = 0; frame < 10; frame++) controller.Tick(); Assert.Equal(afterFirstChange, builds); } [Fact] public void AContractTheDatHasNeverHeardOfStillGetsARow() { // The server can track a contract this dat build does not carry. The // row must still appear — dropping it would hide a live quest. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0xDEADu, stage: 2u); JournalContractsPageController controller = Bind(page, state, ContractCatalog.Empty); Assert.Equal(new[] { 0xDEADu }, controller.RowContractIds.ToArray()); Assert.Equal("In Progress", TextOf(page, StatusValueId)); } [Fact] public void ALocationWithNoOutdoorCoordinatesReadsAsIndoors() { // Retail's literal string when LandDefs::gid_to_lcoord fails // (@0x0049937F). An indoor cell must not render as blank or as raw // numbers. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Bind(page, state, Catalog(Entry(0x10u, "First", contactCell: 0x01020304u))); Assert.Equal("Indoors", TextOf(page, ContactLocationValueId)); } [Fact] public void AnUnsetLocationIsBlankRatherThanIndoors() { // A contract that authors no location at all is different from one // whose location is inside a dungeon. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Bind(page, state, Catalog(Entry(0x10u, "First"))); Assert.Equal(string.Empty, TextOf(page, QuestLocationValueId)); } [Fact] public void TheTimedRowUsesTimeWhenDoneNotTimeWhenRepeats() { // The two wire timers mean different things and land in different // places: TimeWhenRepeats drives the Status column, TimeWhenDone this // row. Swapping them shows a plausible-looking wrong number. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u, whenDone: 3661d, whenRepeats: 90d); Bind(page, state, Catalog(Entry(0x10u, "First"))); Assert.Equal("1h 1m 1s", TextOf(page, TimedValueId)); } [Fact] public void TheRepeatCountdownTicksWithoutRebuildingTheList() { // Nothing on the wire changes while a cooldown runs down, so a // revision-gated rebuild alone would freeze the timer on screen. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 3u, whenRepeats: 600d); DateTime now = Now; var controller = new JournalContractsPageController( page, new JournalContractsPageController.Bindings( Contracts: state.View, Catalog: () => Catalog(Entry(0x10u, "First", repeatFlag: "f")), Now: () => now, TemplateResolver: RowTemplate)); Assert.Equal("Done (10m 0s to Repeat)", TextOf(page, StatusValueId)); now = Now.AddMinutes(5); controller.Tick(); Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId)); } [Fact] public void RowsOptOutOfClickThroughOrTheListIsDead() { // The authored row template is a Type-3 generic container, and those // are click-THROUGH by default — the click sails past the row to // whatever is behind it and the handler never runs. Binding OnClick // without clearing ClickThrough compiles, looks right, and produces a // list you cannot select anything in. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Bind(page, state, Catalog(Entry(0x10u, "First"))); UiDatElement row = Assert.IsAssignableFrom(Assert.Single( UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten), e => e is UiDatElement { OnClick: not null })); Assert.False(row.ClickThrough); } [Fact] public void ClickingARowSelectsIt() { (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Track(state, 0x20u, stage: 2u); JournalContractsPageController controller = Bind(page, state, Catalog( Entry(0x10u, "First"), Entry(0x20u, "Second", description: "Second desc."))); Assert.Equal(0x10u, controller.SelectedContractId); UiDatElement second = Assert.IsAssignableFrom( UiElement.FindDescendant(page, ListId)!.Children .SelectMany(Flatten) .Where(e => e is UiDatElement { OnClick: not null }) .ElementAt(1)); second.OnClick!(); Assert.Equal(0x20u, controller.SelectedContractId); Assert.Equal("Second desc.", TextOf(page, DescriptionId)); } [Fact] public void TheSelectedRowIsHighlightedAndTheOthersAreNot() { // Without this the player cannot tell which row the detail pane is // describing. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Track(state, 0x20u, stage: 2u); JournalContractsPageController controller = Bind(page, state, Catalog( Entry(0x10u, "First"), Entry(0x20u, "Second"))); UiText[] names = UiElement.FindDescendant(page, ListId)!.Children .SelectMany(Flatten) .OfType() .Where(t => t.DatElementId == RowNameId) .ToArray(); Assert.Equal(2, names.Length); // Row 0 opens selected (no display contract, so the first row stands). System.Numerics.Vector4 selectedColor = names[0].DefaultColor; System.Numerics.Vector4 unselectedColor = names[1].DefaultColor; Assert.NotEqual(unselectedColor, selectedColor); controller.Select(0x20u); // The highlight MOVES: the old row goes back to its authored colour // rather than both rows ending up lit. Assert.Equal(unselectedColor, names[0].DefaultColor); Assert.Equal(selectedColor, names[1].DefaultColor); } [Fact] public void TheHighlightSurvivesARebuild() { // A rebuild discards the row objects, so a remembered selection has to // be re-PAINTED onto the new ones rather than merely kept. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); Track(state, 0x20u, stage: 2u); JournalContractsPageController controller = Bind(page, state, Catalog( Entry(0x10u, "First"), Entry(0x20u, "Second"))); controller.Select(0x20u); Track(state, 0x30u, stage: 2u); // forces a rebuild controller.Tick(); Assert.Equal(0x20u, controller.SelectedContractId); UiText selected = UiElement.FindDescendant(page, ListId)!.Children .SelectMany(Flatten) .OfType() .Where(t => t.DatElementId == RowNameId) .ElementAt(1); UiText unselected = UiElement.FindDescendant(page, ListId)!.Children .SelectMany(Flatten) .OfType() .Where(t => t.DatElementId == RowNameId) .ElementAt(0); Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor); } // ── Abandon (game action 0x0316) ──────────────────────────────────── [Fact] public void AbandonSendsTheSelectedContractAndRemovesNothingLocally() { // The row disappears when the SERVER answers with its own 0x0315 // delete. Removing it optimistically would vanish a quest the server // refused to drop, and it would reappear on the next full table. (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 2u); var abandoned = new List(); var controller = new JournalContractsPageController( page, new JournalContractsPageController.Bindings( Contracts: state.View, Catalog: () => Catalog(Entry(0x10u, "First")), Now: () => Now, TemplateResolver: RowTemplate, Abandon: abandoned.Add)); controller.AbandonSelected(); Assert.Equal(new[] { 0x10u }, abandoned.ToArray()); Assert.Equal(1, state.View.Snapshot.ContractCount); } [Fact] public void AbandonWithNothingSelectedSendsNothing() { (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); var abandoned = new List(); var controller = new JournalContractsPageController( page, new JournalContractsPageController.Bindings( Contracts: state.View, Catalog: () => ContractCatalog.Empty, Now: () => Now, TemplateResolver: RowTemplate, Abandon: abandoned.Add)); controller.AbandonSelected(); Assert.Empty(abandoned); } [Fact] public void AProgressCounterRendersThroughTheAuthoredFormat() { (UiElement page, _) = BuildPage(); using var state = new RuntimeContractState(); Track(state, 0x10u, stage: 9u); // ProgressCounter + 5 Bind(page, state, Catalog( Entry(0x10u, "First", progressFormat: "%d/20 Tuskers"))); Assert.Equal("5/20 Tuskers", TextOf(page, StatusValueId)); } }