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));
}
}