using System; using System.Collections.Generic; using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Journal; using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign QJ slice QJ3: the journal's notes page. /// public sealed class JournalNotesPageControllerTests { private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); private const uint PreviousButtonId = 0x10000565u; private const uint NextButtonId = 0x10000566u; private const uint NewButtonId = 0x10000567u; private const uint LabelFieldId = 0x10000569u; private const uint TitleFieldId = 0x1000056Bu; private const uint NotesFieldId = 0x1000056Du; private const uint FirstButtonId = 0x1000056Fu; private const uint PageNumberId = 0x10000570u; private const uint LastButtonId = 0x10000571u; private const uint LocationFieldId = 0x10000573u; private const uint RecordButtonId = 0x10000574u; 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; /// An outdoor landcell, so Record has coordinates to stamp. private const uint OutdoorCell = 0xA9B4001Fu; private static UiField Field(uint id) => new() { ElementId = id, DatElementId = id, Width = 100f, Height = 18f }; private static UiText Text(uint id) => new() { DatElementId = id, Width = 120f, Height = 18f }; private static UiButton Button(uint id) => new( new ElementInfo { Id = id, Type = 1, Width = 60, Height = 20 }, static _ => (0u, 0, 0)) { DatElementId = id, }; /// /// The page, with each child built as the type the REAL layout produces — /// the location is a field, not a text element. /// private static UiElement BuildPage() { var page = new UiPanel { Width = 300f, Height = 500f }; foreach (uint id in new[] { LabelFieldId, TitleFieldId, NotesFieldId, LocationFieldId, TimerDaysFieldId, TimerHoursFieldId, TimerMinutesFieldId, }) { page.AddChild(Field(id)); } 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, FirstButtonId, LastButtonId, RecordButtonId, StartButtonId, }) { page.AddChild(Button(id)); } return page; } private static (JournalNotesPageController Controller, RuntimeJournalState State, UiElement Page) Bind(Func? now = null, params JournalPage[] pages) { var state = new RuntimeJournalState(); state.Load(pages); UiElement page = BuildPage(); var controller = new JournalNotesPageController( page, new JournalNotesPageController.Bindings( Journal: state.View, Commands: state, PlayerCell: () => OutdoorCell, Now: now ?? (() => Now))); return (controller, state, page); } private static void Click(UiElement page, uint id) => (UiElement.FindDescendant(page, id) as UiButton)!.OnClick!(); private static UiField FieldOf(UiElement page, uint id) => (UiField)UiElement.FindDescendant(page, id)!; private static string TextOf(UiElement page, uint id) { var text = UiElement.FindDescendant(page, id) as UiText; return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty; } // ── Record ────────────────────────────────────────────────────────── [Fact] public void RecordPutsTheLocationOnSCREENAndNotJustInTheModel() { // The readout is authored EDITABLE, so it builds as a UiField. Binding // it as UiText yielded null and threw the write away: the value // reached the model and the file, and the player saw nothing. var (_, state, page) = Bind(pages: new JournalPage()); Click(page, RecordButtonId); Assert.True(state.View.Current.HasLocation); Assert.NotEqual(string.Empty, FieldOf(page, LocationFieldId).Text); state.Dispose(); } [Fact] public void RecordOnAnEmptyJournalDoesNothing() { // No page to record onto. Retail's own guard. var (_, state, page) = Bind(); Click(page, RecordButtonId); Assert.Equal(string.Empty, FieldOf(page, LocationFieldId).Text); state.Dispose(); } // ── the timer ─────────────────────────────────────────────────────── [Fact] public void StartingATimerSwapsTheFieldsForTheRunningReadout() { // The readout is authored at the SAME x as the three number boxes, so // both visible at once overlaps illegibly. var (_, state, page) = Bind(pages: new JournalPage()); FieldOf(page, TimerHoursFieldId).SetText("1"); Click(page, StartButtonId); Assert.False(FieldOf(page, TimerDaysFieldId).Visible); Assert.False(FieldOf(page, TimerHoursFieldId).Visible); 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(); } [Fact] public void StartWithNoDurationEnteredDoesNothing() { // Empty boxes mean no countdown; the strip must stay editable rather // than swapping to a readout of nothing. var (_, state, page) = Bind(pages: new JournalPage()); Click(page, StartButtonId); Assert.True(FieldOf(page, TimerDaysFieldId).Visible); Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId)); state.Dispose(); } [Fact] public void TheRunningTimerCountsDownOnTick() { DateTime now = Now; var (controller, state, page) = Bind(now: () => now, pages: new JournalPage()); FieldOf(page, TimerHoursFieldId).SetText("1"); Click(page, StartButtonId); Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId)); now = Now.AddMinutes(30); controller.Tick(); Assert.Equal("30m 0s", TextOf(page, RunningTimerTextId)); state.Dispose(); } [Fact] public void PressingStartAgainStopsTheCountdownAndRestoresTheFields() { var (_, state, page) = Bind(pages: new JournalPage()); FieldOf(page, TimerHoursFieldId).SetText("1"); Click(page, StartButtonId); 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(); } // ── navigation ────────────────────────────────────────────────────── [Fact] public void NewAppendsAPageAndShowsIt() { var (_, state, page) = Bind(); Click(page, NewButtonId); Assert.Single(state.View.Pages); Assert.Equal("~ 1 ~", TextOf(page, PageNumberId)); state.Dispose(); } [Fact] public void EveryNavigationCommitsTheCurrentPageFirst() { // Retail calls SaveThisPage on the way out of all five navigation // buttons. Without it, typing and then paging away eats the edit. var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() }); FieldOf(page, TitleFieldId).SetText("typed but not committed"); Click(page, NextButtonId); Assert.Equal("typed but not committed", state.View.Pages[0].Title); state.Dispose(); } [Fact] public void FirstAndLastJumpToTheEnds() { var (_, state, page) = Bind( pages: new[] { new JournalPage(), new JournalPage(), new JournalPage() }); Click(page, LastButtonId); Assert.Equal("~ 3 ~", TextOf(page, PageNumberId)); Click(page, FirstButtonId); Assert.Equal("~ 1 ~", TextOf(page, PageNumberId)); state.Dispose(); } [Fact] public void PreviousAndNextStopAtTheEndsRatherThanWrapping() { var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() }); Click(page, PreviousButtonId); // already on page 1 Assert.Equal("~ 1 ~", TextOf(page, PageNumberId)); Click(page, NextButtonId); Click(page, NextButtonId); // already on the last Assert.Equal("~ 2 ~", TextOf(page, PageNumberId)); state.Dispose(); } [Fact] public void AnEmptyJournalShowsNoPageNumber() { // "~ 0 ~" would name a page that does not exist. var (_, state, page) = Bind(); Assert.Equal(string.Empty, TextOf(page, PageNumberId)); state.Dispose(); } [Fact] public void SwitchingPagesShowsThatPagesText() { var (_, state, page) = Bind(pages: new[] { new JournalPage(Title: "first", Notes: "one"), new JournalPage(Title: "second", Notes: "two"), }); Click(page, NextButtonId); Assert.Equal("second", FieldOf(page, TitleFieldId).Text); Assert.Equal("two", FieldOf(page, NotesFieldId).Text); state.Dispose(); } }