acdream/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs
Erik ec6eeb120d feat(quest): QT5/QT6 — the Journal panel, and the button that was already there
The quest log is on screen. Rows come from the live tracker joined to the
authored catalog, the Status column runs QT4's port of FillProgressString, and
the detail pane shows contact, locations, description and the other timer.

Two things measured rather than assumed, each now pinned by an installed-DAT
test rather than left to the commit message:

The tab pairing is read from the authored 0x2E table, not inferred from
x-order — the FA campaign had to correct exactly that mistake, and Contracts
turns out to be the authored DEFAULT tab (0x32 = True), so opening on the
wrong one would have looked like an empty panel.

The open path needed no keybind at all. Toolbar button 0x1000055A authors
0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds
since the toolbar was ported — it just had no panel behind it, so clicking it
did nothing. Registering slot 25 finished a wiring that was already
three-quarters present.

The list rebuild is revision-gated while the repeat countdown is not: nothing
on the wire changes as a cooldown runs down, so a rebuild-gated timer would
freeze on screen, and a per-frame rebuild would reset the player's scroll under
them. Both directions have a test.

Deliberately inert: the Abandon button (retail's abandon path is a
contract-registry command this campaign did not port — authored and visible,
but wiring a no-op handler would look responsive and lie), and the Journal
notes and Page List tabs, which are their own feature.

Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed.

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

186 lines
6.4 KiB
C#

using System;
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign QT slice QT5: pins the authored facts the Journal panel is built
/// on, against the live installed DATs rather than a committed fixture.
/// </summary>
/// <remarks>
/// These are the facts a reader would otherwise have to take on trust from a
/// commit message: the panel's slot key, its three-tab table, and which tab is
/// the authored default. The FA campaign had to CORRECT a tab pairing that had
/// been inferred from x-order, which is why the pairing is asserted here from
/// the authored <c>0x2E</c> table itself.
/// </remarks>
[Trait("Lane", "InstalledDat")]
public sealed class JournalPanelSlotProbeTests
{
private const uint SlotKeyPropertyId = 0x10000029u;
private const uint TabTablePropertyId = 0x2Eu;
private const uint TabButtonPropertyId = 0x30u;
private const uint TabPagePropertyId = 0x31u;
private const uint TabDefaultPropertyId = 0x32u;
private const uint ToolbarLayoutId = 0x21000016u;
private const uint JournalToolbarButtonId = 0x1000055Au;
private static ElementInfo Import(uint layoutId, uint rootElementId = 0u)
{
string? datDir = ContentConformanceDatDir();
if (datDir is null)
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; "
+ "see docs/release-gate.md.");
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? info = rootElementId == 0u
? LayoutImporter.ImportInfos(adapter, layoutId)
: LayoutImporter.ImportInfos(adapter, layoutId, rootElementId);
Assert.NotNull(info);
return info!;
}
private static string? ContentConformanceDatDir()
{
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
private static UiPropertyValue? Property(ElementInfo info, uint propertyId)
{
foreach (UiStateInfo state in info.States.Values)
{
if (state.Properties.Values.TryGetValue(propertyId, out UiPropertyValue? value))
return value;
}
return null;
}
[Fact]
public void TheSlotAuthorsTheCatalogPanelId()
{
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
UiPropertyValue? key = Property(slot, SlotKeyPropertyId);
Assert.NotNull(key);
Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue);
}
[Fact]
public void TheAuthoredTabTablePairsContractsWithTheGmContractsUiPage()
{
// Read from the table, never inferred from x-order.
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
UiPropertyValue? tabs = Property(slot, TabTablePropertyId);
Assert.NotNull(tabs);
Assert.Equal(3, tabs!.ArrayValue.Count);
UiPropertyValue first = tabs.ArrayValue[0];
Assert.Equal(
0x100005D3u,
(uint)first.StructValue[TabButtonPropertyId].UnsignedValue);
Assert.Equal(
JournalPanelController.ContractsPageId,
(uint)first.StructValue[TabPagePropertyId].UnsignedValue);
}
[Fact]
public void ContractsIsTheAuthoredDefaultTab()
{
// Opening on the wrong tab would look like the panel is empty.
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
UiPropertyValue tabs = Property(slot, TabTablePropertyId)!;
UiPropertyValue defaultTab = Assert.Single(
tabs.ArrayValue,
t => t.StructValue.TryGetValue(TabDefaultPropertyId, out UiPropertyValue? d)
&& d.BoolValue);
Assert.Equal(
JournalPanelController.ContractsPageId,
(uint)defaultTab.StructValue[TabPagePropertyId].UnsignedValue);
}
[Fact]
public void TheToolbarButtonAuthorsTheSamePanelId()
{
// The button was already in ToolbarController.PanelButtonIds with no
// panel behind it, so clicking it did nothing. This is the fact that
// makes it work rather than a keybind.
ElementInfo toolbar = Import(ToolbarLayoutId);
ElementInfo? button = Find(toolbar, JournalToolbarButtonId);
Assert.NotNull(button);
UiPropertyValue? key = Property(button!, SlotKeyPropertyId);
Assert.NotNull(key);
Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue);
}
[Fact]
public void TheContractsPageCarriesEveryChildTheControllerResolves()
{
// A renamed or missing child would silently leave a blank row rather
// than fail, because the controller binds defensively.
ElementInfo slot = Import(
JournalPanelController.HostLayoutId,
JournalPanelController.SlotElementId);
ElementInfo page = Assert.IsType<ElementInfo>(
Find(slot, JournalPanelController.ContractsPageId));
foreach (uint childId in new[]
{
0x100005CFu, // the list
0x100005DEu, // description
0x100005DFu, // status value
0x100005E0u, // contact value
0x100005E1u, // contact location
0x100005E2u, // quest location
0x100005E3u, // timed value
0x100005DCu, // Abandon
})
{
Assert.True(
Find(page, childId) is not null,
$"contracts page is missing authored child 0x{childId:X8}");
}
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
{
ElementInfo? hit = Find(child, id);
if (hit is not null) return hit;
}
return null;
}
}