diff --git a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs index 45787285..a0370003 100644 --- a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs +++ b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs @@ -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 /// 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(); diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs index 6f1b1aaa..2404b662 100644 --- a/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs @@ -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(); } diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs new file mode 100644 index 00000000..dcc1bf74 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs @@ -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; + +/// +/// The controllers must actually WIRE the real panel. +/// +/// +/// 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. +/// +[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? 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(); + 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(); + 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(); + } +}