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