fix(journal): the location readout is a FIELD, refresh at the click, and Abandon works
Three fixes from the first connected round. The location readout is authored EDITABLE (0x16), so it builds as a UiField — not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved it as text, got null, and threw every write away in silence: Record reached the model and reached the FILE, and never reached the screen. That is exactly what was reported, and it is a whole class of bug, so the sweep that found it is now a test over every element all three controllers bind. The handlers mutated the model and left redrawing to the next frame's Tick. Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update() instead — at the moment of the click. The deferred version happened to work in the client and made the behaviour untestable and a frame late; the notes-page tests I had not written until now fail against it. Abandon is wired. "Retail's abandon path is a contract-registry command we have not ported" was wrong — it is game action 0x0316 with a single contract id, and ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed locally, so a refusal leaves the quest visibly intact rather than vanishing it optimistically and having it reappear on the next full table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0b27c5d0fe
commit
e35d9386e4
12 changed files with 556 additions and 16 deletions
|
|
@ -0,0 +1,283 @@
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QJ slice QJ3: the journal's notes page.
|
||||
/// </summary>
|
||||
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 RunningTimerTextId = 0x1000057Cu;
|
||||
private const uint StartButtonId = 0x1000057Du;
|
||||
|
||||
/// <summary>An outdoor landcell, so Record has coordinates to stamp.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The page, with each child built as the type the REAL layout produces —
|
||||
/// the location is a field, not a text element.
|
||||
/// </summary>
|
||||
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));
|
||||
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<DateTime>? 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));
|
||||
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.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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue