acdream/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs
Erik c5cc8ae5fc feat(journal): QJ3/QJ4/QJ5 — both remaining tabs are live
The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.

Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:

Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.

The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.

Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.

DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.

The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.

Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.

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

184 lines
7.1 KiB
C#

using System;
using AcDream.Core.Quests;
using AcDream.Core.Ui;
namespace AcDream.Core.Tests.Quests;
/// <summary>
/// Campaign QT slice QT4: <c>gmContractsUI::FillProgressString @0x00498DE0</c>
/// and the <c>ClientUISystem::DeltaTimeToString @0x00565E10</c> it calls.
/// </summary>
public sealed class ContractProgressTextTests
{
private static readonly DateTime Arrival = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
private static ContractEntry Entry(
string descriptionProgress = "", string questflagRepeatTime = "")
=> ContractEntry.Unknown with
{
DescriptionProgress = descriptionProgress,
QuestflagRepeatTime = questflagRepeatTime,
};
// ── DeltaTimeToString ───────────────────────────────────────────────
[Theory]
[InlineData(0, "0s")]
[InlineData(45, "45s")]
[InlineData(60, "1m 0s")]
[InlineData(3600, "1h 0s")] // minutes are OMITTED when zero
[InlineData(3661, "1h 1m 1s")]
[InlineData(86400, "1d 0s")]
[InlineData(2592000, "1mo 0s")] // a "month" is a flat 30 days
[InlineData(2592000 + 86400 + 3600 + 61, "1mo 1d 1h 1m 1s")]
public void DeltaTimeFormatsLargestUnitFirstAndAlwaysShowsSeconds(
double seconds, string expected)
=> Assert.Equal(expected, RetailDurationText.Format(seconds));
[Fact]
public void DeltaTimeHasNoTrailingSpace()
{
// Retail emits every part WITH a trailing space and then writes the
// terminator over the last one (0x00565F0E). Missing that truncation
// gives "Done (30s to Repeat)" with a double space — and the
// instruction is invisible in the decompiler output, so this is the
// assertion that pins the byte-level reading.
string text = RetailDurationText.Format(30);
Assert.Equal("30s", text);
Assert.DoesNotContain(" ", ContractProgressText.Build(
3u, 30d, Arrival, Entry(questflagRepeatTime: "flag"), Arrival));
}
[Fact]
public void DeltaTimeTruncatesTowardZeroLikeRetailsFtol()
{
Assert.Equal("59s", RetailDurationText.Format(59.99));
}
// ── the stage arms ──────────────────────────────────────────────────
[Fact]
public void StageOneIsAvailable()
=> Assert.Equal("Available", ContractProgressText.Build(
1u, 0d, Arrival, Entry(), Arrival));
[Fact]
public void StageTwoIsInProgress()
=> Assert.Equal("In Progress", ContractProgressText.Build(
2u, 0d, Arrival, Entry(), Arrival));
[Fact]
public void StageThreeWithNoRepeatFlagIsDoneForGood()
{
// An empty QuestflagRepeatTime is the whole difference between a
// one-shot quest and a repeatable one on cooldown.
Assert.Equal("Done", ContractProgressText.Build(
3u, 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival));
}
[Fact]
public void StageThreeWithARepeatFlagAndNoTimerIsAvailableAgain()
{
Assert.Equal("Available", ContractProgressText.Build(
3u, 0d, Arrival, Entry(questflagRepeatTime: "SomeQuestRepeat"), Arrival));
}
[Fact]
public void StageThreeWithATimerStillRunningCountsDownToTheRepeat()
{
string text = ContractProgressText.Build(
3u,
timeWhenRepeats: 3661d,
Arrival,
Entry(questflagRepeatTime: "SomeQuestRepeat"),
now: Arrival);
Assert.Equal("Done (1h 1m 1s to Repeat)", text);
}
[Fact]
public void TheCountdownIsAnchoredAtArrivalNotRecomputedFromTheServerValue()
{
// The server sends the remaining seconds ONCE and never sends the
// instant it measured them from. Anchoring at arrival is what makes
// the timer tick; without it the same number would be shown forever.
string atArrival = ContractProgressText.Build(
3u, 600d, Arrival, Entry(questflagRepeatTime: "f"), Arrival);
string tenMinutesLater = ContractProgressText.Build(
3u, 600d, Arrival, Entry(questflagRepeatTime: "f"),
Arrival.AddMinutes(5));
Assert.Equal("Done (10m 0s to Repeat)", atArrival);
Assert.Equal("Done (5m 0s to Repeat)", tenMinutesLater);
}
[Fact]
public void ATimerThatHasRunOutSinceArrivalReadsAsAvailable()
{
Assert.Equal("Available", ContractProgressText.Build(
3u, 600d, Arrival, Entry(questflagRepeatTime: "f"),
now: Arrival.AddHours(1)));
}
[Fact]
public void TimeWhenDoneNeverReachesThisText()
{
// It IS on the wire and it does NOT drive the progress column. Passing
// it here instead of TimeWhenRepeats is the plausible misreading; the
// signature refuses it, and this test says why.
string text = ContractProgressText.Build(
3u, timeWhenRepeats: 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival);
Assert.Equal("Done", text);
}
// ── the progress counter ────────────────────────────────────────────
[Theory]
[InlineData(4u, "0/20 Tuskers")]
[InlineData(9u, "5/20 Tuskers")]
[InlineData(24u, "20/20 Tuskers")]
public void StageFourAndAboveSubstitutesTheCountIntoTheAuthoredFormat(
uint stage, string expected)
{
// The count is stage - 4, and DescriptionProgress is a printf format,
// not a literal — rendering it verbatim shows the player "%d/20".
Assert.Equal(expected, ContractProgressText.Build(
stage, 0d, Arrival, Entry(descriptionProgress: "%d/20 Tuskers"), Arrival));
}
[Fact]
public void AProgressStageWithNoAuthoredFormatFallsBackToInProgress()
{
Assert.Equal("In Progress", ContractProgressText.Build(
7u, 0d, Arrival, Entry(descriptionProgress: ""), Arrival));
}
[Fact]
public void AFormatWithoutASpecifierIsShownVerbatim()
{
Assert.Equal("Gathering herbs", ContractProgressText.Build(
6u, 0d, Arrival, Entry(descriptionProgress: "Gathering herbs"), Arrival));
}
[Fact]
public void OnlyTheFirstSpecifierIsSubstitutedBecauseRetailPassesOneArgument()
{
// A second %d would read past the argument in retail too. No installed
// contract has one (measured: 89 formats, all exactly one %d), so this
// pins the behaviour rather than describing shipped content.
Assert.Equal("3 of %d", ContractProgressText.Build(
7u, 0d, Arrival, Entry(descriptionProgress: "%d of %d"), Arrival));
}
[Fact]
public void AnUnknownStageProducesNothingRatherThanGuessing()
{
// Retail returns without writing, leaving the caller's string as it
// found it. Inventing a label here would put text on screen that the
// real client never shows.
Assert.Equal(string.Empty, ContractProgressText.Build(
0u, 0d, Arrival, Entry(), Arrival));
}
}