acdream/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
Erik 56beeb720d fix(quest): QT5 — the rows were unclickable, and nothing showed which was selected
Two defects in the panel as committed, both found by checking the code against
the authored data rather than by a test.

The authored row template is a Type-3 generic container, which resolves through
DatWidgetFactory's fallback arm to UiDatElement — whose constructor sets
ClickThrough = true ("generic decoration; behavioral widgets opt back in").
Binding OnClick without clearing that compiles, reads correctly, and produces a
list in which nothing can be selected: every click sails past the row. The
skills page had already met this and left the precedent; I did not follow it.

And there was no selection highlight at all, so even once clicking worked the
player could not tell which row the detail pane was describing. UiTemplateListBox
has no selection mechanism of its own, so the page opts in directly and
re-PAINTS the highlight after a rebuild — a rebuild discards the row objects, so
remembering the selection is not enough to keep it visible.

The tests for both initially passed while the bugs were live, because the
fixture's row root was a UiPanel and its text started white. A UiPanel is not
click-through, so the first test was vacuous; white-on-white made the highlight
unobservable. The fixture now builds the same UiDatElement production does and
authors a non-white colour. This is the third time this campaign a fixture that
did not match the real widget hid a real defect.

Live mount confirmed against the installed dats in this session's client run:
"[UI] retail journal panel from LayoutDesc importer (0x2100006E slot
0x10000559)" with no bind failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:17:14 +02:00

500 lines
18 KiB
C#

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;
/// <summary>
/// Campaign QT slice QT5: the Journal panel's Contracts page.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>
/// A page carrying the same child ids the real one does, so the controller
/// resolves exactly what production resolves.
/// </summary>
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);
}
/// <summary>
/// One row: a name text and a status text, as retail authors.
/// </summary>
/// <remarks>
/// The root is a <see cref="UiDatElement"/> 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.
/// </remarks>
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<UiText>(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<UiElement> 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<uint, ContractTracker>());
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<UiDatElement>(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<UiDatElement>(
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<UiText>()
.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<UiText>()
.Where(t => t.DatElementId == RowNameId)
.ElementAt(1);
UiText unselected = UiElement.FindDescendant(page, ListId)!.Children
.SelectMany(Flatten)
.OfType<UiText>()
.Where(t => t.DatElementId == RowNameId)
.ElementAt(0);
Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor);
}
[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));
}
}