fix(journal): the timer's unit labels hide with their boxes
Reported as "1d 2h 51sh m" — the running countdown drawing straight through the d/h/m labels. The readout is authored across the same strip as the three number boxes, so the strip has to be one thing or the other; I hid the boxes and left their labels behind. Retail's ShowEditableTimer @0x00495770 toggles SIX elements, not three: m_pDaysEditBox AND m_pDaysStaticText, and the same for hours and minutes, plus the readout inverse. Reading the swap as "hide the inputs" instead of "hide the input ROWS" is what produced the overlap. Also settles the Record question the same round raised. Nothing was broken: indoors, retail's own gid_to_lcoord fails and nothing is recorded, and UpdateLocation @0x004958F0 only ever formats coordinates already stored — there is no "you are indoors" message in that function to port. The silence is faithful, and it is now commented as such rather than left looking like a gap. JournalPanelLiveBindTests is new and is the test that should have existed first: it builds the panel from the real DATs, constructs the controllers, and asserts every button actually receives an OnClick. Every other test so far checked either the layout or the logic — none of them proved the controller finds its elements in the real tree, which is where an id typo or a subtree assumption produces a panel where nothing responds and nothing fails. The temporary ACDREAM_PROBE_JOURNAL instrumentation is removed; the question it was added for is answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e35d9386e4
commit
eadedaea90
3 changed files with 220 additions and 1 deletions
|
|
@ -45,6 +45,14 @@ public sealed class JournalNotesPageController
|
|||
private const uint TimerDaysFieldId = 0x10000576u;
|
||||
private const uint TimerHoursFieldId = 0x10000578u;
|
||||
private const uint TimerMinutesFieldId = 0x1000057Au;
|
||||
|
||||
// The "d" / "h" / "m" unit labels. Retail's ShowEditableTimer @0x00495770
|
||||
// toggles each box AND its label — m_pDaysStaticText, m_pHoursStaticText,
|
||||
// m_pMinutesStaticText — so the running readout, which is authored over
|
||||
// the same strip, does not draw through them.
|
||||
private const uint TimerDaysLabelId = 0x10000577u;
|
||||
private const uint TimerHoursLabelId = 0x10000579u;
|
||||
private const uint TimerMinutesLabelId = 0x1000057Bu;
|
||||
private const uint RunningTimerTextId = 0x1000057Cu;
|
||||
private const uint StartButtonId = 0x1000057Du;
|
||||
|
||||
|
|
@ -79,6 +87,9 @@ public sealed class JournalNotesPageController
|
|||
/// </summary>
|
||||
private readonly UiField? _location;
|
||||
private readonly UiText? _runningTimer;
|
||||
private readonly UiElement? _timerDaysLabel;
|
||||
private readonly UiElement? _timerHoursLabel;
|
||||
private readonly UiElement? _timerMinutesLabel;
|
||||
private readonly UiButton? _start;
|
||||
|
||||
private long _renderedRevision = -1;
|
||||
|
|
@ -97,6 +108,9 @@ public sealed class JournalNotesPageController
|
|||
_pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText;
|
||||
_location = UiElement.FindDescendant(page, LocationTextId) as UiField;
|
||||
_runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText;
|
||||
_timerDaysLabel = UiElement.FindDescendant(page, TimerDaysLabelId);
|
||||
_timerHoursLabel = UiElement.FindDescendant(page, TimerHoursLabelId);
|
||||
_timerMinutesLabel = UiElement.FindDescendant(page, TimerMinutesLabelId);
|
||||
_start = UiElement.FindDescendant(page, StartButtonId) as UiButton;
|
||||
|
||||
// The three timer boxes take digits only. Their authored 0x1E is 2, so
|
||||
|
|
@ -210,9 +224,15 @@ public sealed class JournalNotesPageController
|
|||
double remaining = _bindings.Journal.RemainingTimerSeconds(_bindings.Now());
|
||||
bool running = remaining > 0d;
|
||||
|
||||
// Each box AND its unit label, exactly the six elements retail toggles.
|
||||
// Hiding only the boxes leaves "d h m" drawn underneath the readout,
|
||||
// which is authored across the same strip.
|
||||
if (_timerDays is not null) _timerDays.Visible = !running;
|
||||
if (_timerHours is not null) _timerHours.Visible = !running;
|
||||
if (_timerMinutes is not null) _timerMinutes.Visible = !running;
|
||||
if (_timerDaysLabel is not null) _timerDaysLabel.Visible = !running;
|
||||
if (_timerHoursLabel is not null) _timerHoursLabel.Visible = !running;
|
||||
if (_timerMinutesLabel is not null) _timerMinutesLabel.Visible = !running;
|
||||
if (_runningTimer is not null) _runningTimer.Visible = running;
|
||||
|
||||
SetText(_runningTimer, running
|
||||
|
|
@ -236,8 +256,12 @@ public sealed class JournalNotesPageController
|
|||
if (cell == 0u)
|
||||
return;
|
||||
|
||||
// Indoors, retail's own gid_to_lcoord fails and nothing is recorded —
|
||||
// UpdateLocation @0x004958F0 only ever formats coordinates already
|
||||
// stored, so there is no "you are indoors" message to port. Silence
|
||||
// here is faithful, not an omission.
|
||||
if (!AcDream.Core.Ui.RadarCoordinates.TryFromCell(cell, out var coordinates))
|
||||
return; // indoors: retail's own gid_to_lcoord failure
|
||||
return;
|
||||
|
||||
_bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y);
|
||||
Refresh();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ public sealed class JournalNotesPageControllerTests
|
|||
private const uint TimerDaysFieldId = 0x10000576u;
|
||||
private const uint TimerHoursFieldId = 0x10000578u;
|
||||
private const uint TimerMinutesFieldId = 0x1000057Au;
|
||||
private const uint TimerDaysLabelId = 0x10000577u;
|
||||
private const uint TimerHoursLabelId = 0x10000579u;
|
||||
private const uint TimerMinutesLabelId = 0x1000057Bu;
|
||||
private const uint RunningTimerTextId = 0x1000057Cu;
|
||||
private const uint StartButtonId = 0x1000057Du;
|
||||
|
||||
|
|
@ -64,6 +67,9 @@ public sealed class JournalNotesPageControllerTests
|
|||
|
||||
page.AddChild(Text(PageNumberId));
|
||||
page.AddChild(Text(RunningTimerTextId));
|
||||
page.AddChild(Text(TimerDaysLabelId));
|
||||
page.AddChild(Text(TimerHoursLabelId));
|
||||
page.AddChild(Text(TimerMinutesLabelId));
|
||||
foreach (uint id in new[]
|
||||
{
|
||||
PreviousButtonId, NextButtonId, NewButtonId,
|
||||
|
|
@ -152,6 +158,12 @@ public sealed class JournalNotesPageControllerTests
|
|||
Assert.False(FieldOf(page, TimerMinutesFieldId).Visible);
|
||||
Assert.True(UiElement.FindDescendant(page, RunningTimerTextId)!.Visible);
|
||||
Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId));
|
||||
|
||||
// The unit labels go with their boxes. Leaving them drew "d h m"
|
||||
// through the readout: "1d 2h 51sh m".
|
||||
Assert.False(UiElement.FindDescendant(page, TimerDaysLabelId)!.Visible);
|
||||
Assert.False(UiElement.FindDescendant(page, TimerHoursLabelId)!.Visible);
|
||||
Assert.False(UiElement.FindDescendant(page, TimerMinutesLabelId)!.Visible);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +207,7 @@ public sealed class JournalNotesPageControllerTests
|
|||
Click(page, StartButtonId);
|
||||
|
||||
Assert.True(FieldOf(page, TimerDaysFieldId).Visible);
|
||||
Assert.True(UiElement.FindDescendant(page, TimerDaysLabelId)!.Visible);
|
||||
Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId));
|
||||
state.Dispose();
|
||||
}
|
||||
|
|
|
|||
182
tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs
Normal file
182
tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Journal;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The controllers must actually WIRE the real panel.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything else so far tested the layout (do the widgets build right?) or
|
||||
/// the controller against a hand-built page (does the logic work?). Neither
|
||||
/// proves the controller finds its elements in the REAL tree — which is the
|
||||
/// one step where an id typo or a subtree assumption silently produces a panel
|
||||
/// where nothing responds.
|
||||
/// </remarks>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class JournalPanelLiveBindTests
|
||||
{
|
||||
private const uint OutdoorCell = 0xA9B4001Fu;
|
||||
|
||||
private static ImportedLayout BuildPanel()
|
||||
{
|
||||
string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir))
|
||||
{
|
||||
datDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(datDir))
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ElementInfo? root = LayoutImporter.ImportInfos(
|
||||
adapter,
|
||||
JournalPanelController.HostLayoutId,
|
||||
JournalPanelController.SlotElementId);
|
||||
Assert.NotNull(root);
|
||||
|
||||
var strings = new DatStringResolver(adapter);
|
||||
return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve);
|
||||
}
|
||||
|
||||
private static (JournalNotesPageController Controller, RuntimeJournalState State,
|
||||
UiElement Page) BindNotes(ImportedLayout layout, Func<uint>? playerCell = null)
|
||||
{
|
||||
UiElement? page = layout.FindElement(JournalPanelController.NotesPageId);
|
||||
Assert.NotNull(page);
|
||||
|
||||
var state = new RuntimeJournalState();
|
||||
state.Load([new JournalPage()]);
|
||||
|
||||
var controller = new JournalNotesPageController(
|
||||
page!,
|
||||
new JournalNotesPageController.Bindings(
|
||||
Journal: state.View,
|
||||
Commands: state,
|
||||
PlayerCell: playerCell ?? (() => OutdoorCell),
|
||||
Now: () => new DateTime(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc)));
|
||||
|
||||
return (controller, state, page!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheNotesPageWiresEveryButtonItOwns()
|
||||
{
|
||||
// If FindDescendant misses one — wrong id, or the element is not in the
|
||||
// subtree the controller searches — that button silently does nothing
|
||||
// and nothing else fails.
|
||||
ImportedLayout layout = BuildPanel();
|
||||
(_, RuntimeJournalState state, UiElement page) = BindNotes(layout);
|
||||
|
||||
var unwired = new List<string>();
|
||||
foreach ((uint id, string name) in new[]
|
||||
{
|
||||
(0x10000565u, "Previous"), (0x10000566u, "Next"), (0x10000567u, "New"),
|
||||
(0x1000056Fu, "First"), (0x10000571u, "Last"),
|
||||
(0x10000574u, "Record"), (0x1000057Du, "Start"),
|
||||
})
|
||||
{
|
||||
if (UiElement.FindDescendant(page, id) is not UiButton button)
|
||||
unwired.Add($"{name} (0x{id:X8}) not found under the notes page");
|
||||
else if (button.OnClick is null)
|
||||
unwired.Add($"{name} (0x{id:X8}) has no OnClick");
|
||||
}
|
||||
|
||||
Assert.Empty(unwired);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordOnTheRealPageStampsAndDisplaysTheLocation()
|
||||
{
|
||||
ImportedLayout layout = BuildPanel();
|
||||
(_, RuntimeJournalState state, UiElement page) = BindNotes(layout);
|
||||
|
||||
(UiElement.FindDescendant(page, 0x10000574u) as UiButton)!.OnClick!();
|
||||
|
||||
Assert.True(state.View.Current.HasLocation);
|
||||
var readout = UiElement.FindDescendant(page, 0x10000573u) as UiField;
|
||||
Assert.NotNull(readout);
|
||||
Assert.NotEqual(string.Empty, readout!.Text);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordIndoorsDoesNothingBecauseThereAreNoCoordinates()
|
||||
{
|
||||
// retail's own gid_to_lcoord failure. Worth pinning because it looks
|
||||
// identical to a broken button from the player's side.
|
||||
ImportedLayout layout = BuildPanel();
|
||||
(_, RuntimeJournalState state, UiElement page) =
|
||||
BindNotes(layout, playerCell: () => 0x01020304u);
|
||||
|
||||
(UiElement.FindDescendant(page, 0x10000574u) as UiButton)!.OnClick!();
|
||||
|
||||
Assert.False(state.View.Current.HasLocation);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartOnTheRealPageSwapsToTheRunningReadout()
|
||||
{
|
||||
ImportedLayout layout = BuildPanel();
|
||||
(_, RuntimeJournalState state, UiElement page) = BindNotes(layout);
|
||||
|
||||
var hours = UiElement.FindDescendant(page, 0x10000578u) as UiField;
|
||||
Assert.NotNull(hours);
|
||||
hours!.SetText("1");
|
||||
|
||||
(UiElement.FindDescendant(page, 0x1000057Du) as UiButton)!.OnClick!();
|
||||
|
||||
Assert.False(hours.Visible);
|
||||
UiElement? readout = UiElement.FindDescendant(page, 0x1000057Cu);
|
||||
Assert.NotNull(readout);
|
||||
Assert.True(readout!.Visible);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThePageListWiresItsButtonsToo()
|
||||
{
|
||||
ImportedLayout layout = BuildPanel();
|
||||
UiElement? page = layout.FindElement(JournalPanelController.PageListPageId);
|
||||
Assert.NotNull(page);
|
||||
|
||||
var state = new RuntimeJournalState();
|
||||
state.Load([new JournalPage(Title: "one")]);
|
||||
_ = new JournalPageListController(
|
||||
page!,
|
||||
new JournalPageListController.Bindings(
|
||||
Journal: state.View,
|
||||
Commands: state,
|
||||
OpenPage: _ => { },
|
||||
TemplateResolver: (_, _) => null));
|
||||
|
||||
var unwired = new List<string>();
|
||||
foreach ((uint id, string name) in new[]
|
||||
{
|
||||
(0x10000585u, "Delete"), (0x10000588u, "Reset"),
|
||||
})
|
||||
{
|
||||
if (UiElement.FindDescendant(page!, id) is not UiButton button)
|
||||
unwired.Add($"{name} (0x{id:X8}) not found");
|
||||
else if (button.OnClick is null)
|
||||
unwired.Add($"{name} (0x{id:X8}) has no OnClick");
|
||||
}
|
||||
|
||||
Assert.Empty(unwired);
|
||||
state.Dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue