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; private static UiText Text(uint id) => new() { DatElementId = id, Width = 200f, Height = 18f, }; /// /// 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. private static UiElement? RowTemplate(uint layoutId, uint elementId) { if (layoutId != JournalContractsPageController.RowTemplateLayoutId || elementId != JournalContractsPageController.RowTemplateElementId) { return null; } var row = new UiPanel { Width = 270f, Height = 16f }; 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 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)); } }