using System; using System.Collections.Generic; using System.Linq; using AcDream.Core.Journal; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// /// Campaign QJ slice QJ2: the per-character journal owner. /// public sealed class RuntimeJournalStateTests { private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); private static RuntimeJournalState WithPages(params string[] labels) { var state = new RuntimeJournalState(); state.Load(labels.Select(l => new JournalPage(Label: l)).ToArray()); return state; } [Fact] public void AFreshJournalHasNoPagesAndNoCurrentPage() { using var state = new RuntimeJournalState(); RuntimeJournalSnapshot snapshot = state.View.Snapshot; Assert.Equal(0, snapshot.PageCount); Assert.Equal(0, snapshot.CurrentPage); Assert.Equal(JournalPage.Empty, state.View.Current); } [Fact] public void LoadingOpensOnTheFirstPageAndIsNotAnEdit() { // Pages are 1-BASED, matching retail's m_CurrentPage and the "~ 1 ~" // the panel shows. And a load must not mark the journal dirty — the // file already says exactly this, so saving straight back would be // pure churn. using RuntimeJournalState state = WithPages("a", "b"); Assert.Equal(1, state.View.Snapshot.CurrentPage); Assert.Equal("a", state.View.Current.Label); Assert.False(state.IsDirty); } [Fact] public void NewPageAppendsAndMovesToIt() { using RuntimeJournalState state = WithPages("a"); state.NewPage(); Assert.Equal(2, state.View.Snapshot.PageCount); Assert.Equal(2, state.View.Snapshot.CurrentPage); Assert.Equal(JournalPage.Empty, state.View.Current); Assert.True(state.IsDirty); } [Fact] public void EditsLandOnTheCurrentPageOnly() { using RuntimeJournalState state = WithPages("a", "b"); state.GotoPage(2); state.UpdateCurrent("label", "title", "notes"); Assert.Equal("a", state.View.Pages[0].Label); Assert.Equal("label", state.View.Pages[1].Label); Assert.Equal("title", state.View.Pages[1].Title); } [Fact] public void AnEditThatChangesNothingDoesNotDirtyTheJournal() { // The panel commits the edit boxes on every page change and hide, so a // no-op commit must not make a file write look necessary. using RuntimeJournalState state = WithPages("a"); state.UpdateCurrent("a", string.Empty, string.Empty); Assert.False(state.IsDirty); } [Fact] public void EditsAreClippedToTheAuthoredMaximums() { using RuntimeJournalState state = WithPages("a"); state.UpdateCurrent(new string('x', 100), new string('y', 100), "notes"); Assert.Equal(JournalPage.MaxLabelLength, state.View.Current.Label.Length); Assert.Equal(JournalPage.MaxTitleLength, state.View.Current.Title.Length); } [Fact] public void DeletingTheLastRemainingPageEmptiesTheJournal() { // Inventing a replacement blank page would make the journal impossible // to empty. using RuntimeJournalState state = WithPages("only"); Assert.True(state.DeletePage(1)); Assert.Equal(0, state.View.Snapshot.PageCount); Assert.Equal(0, state.View.Snapshot.CurrentPage); } [Fact] public void DeletingPastTheEndPullsTheCurrentPageBack() { using RuntimeJournalState state = WithPages("a", "b", "c"); state.GotoPage(3); state.DeletePage(3); Assert.Equal(2, state.View.Snapshot.CurrentPage); Assert.Equal("b", state.View.Current.Label); } [Theory] [InlineData(0)] [InlineData(4)] [InlineData(-1)] public void AnOutOfRangePageIsRefusedRatherThanClamped(int page) { // Clamping would move the player somewhere they did not ask for. using RuntimeJournalState state = WithPages("a", "b", "c"); Assert.False(state.GotoPage(page)); Assert.False(state.DeletePage(page)); Assert.Equal(1, state.View.Snapshot.CurrentPage); } [Fact] public void IsLastPageTracksTheCurrentPage() { using RuntimeJournalState state = WithPages("a", "b"); Assert.False(state.IsLastPage); state.GotoPage(2); Assert.True(state.IsLastPage); } [Fact] public void RecordingALocationOfZeroStillCountsAsRecorded() { using RuntimeJournalState state = WithPages("a"); state.RecordLocation(0f, 0f); Assert.True(state.View.Current.HasLocation); } // ── the timer ─────────────────────────────────────────────────────── [Fact] public void StartingATimerNeedsADurationToCountDown() { using RuntimeJournalState state = WithPages("a"); Assert.False(state.StartTimer(Now)); state.SetTimer(0, 0, 5); Assert.True(state.StartTimer(Now)); } [Fact] public void TheCountdownRunsAgainstTheClock() { using RuntimeJournalState state = WithPages("a"); state.SetTimer(0, 1, 0); state.StartTimer(Now); Assert.Equal(3600d, state.View.RemainingTimerSeconds(Now)); Assert.Equal(3540d, state.View.RemainingTimerSeconds(Now.AddMinutes(1))); } [Fact] public void AnExpiredCountdownStopsAtZeroRatherThanGoingNegative() { using RuntimeJournalState state = WithPages("a"); state.SetTimer(0, 0, 1); state.StartTimer(Now); Assert.Equal(0d, state.View.RemainingTimerSeconds(Now.AddHours(1))); } [Fact] public void LeavingThePageStopsItsCountdown() { // The countdown belongs to the page it was started on; carrying it to // another page would attribute one page's timer to another. using RuntimeJournalState state = WithPages("a", "b"); state.SetTimer(0, 1, 0); state.StartTimer(Now); state.GotoPage(2); Assert.Equal(0d, state.View.RemainingTimerSeconds(Now)); } [Fact] public void TheSaveSnapshotFoldsInTheLiveCountdown() { // The page record holds the value at START; what belongs in the file // is what is left NOW, or a reload would resurrect the full duration. using RuntimeJournalState state = WithPages("a"); state.SetTimer(0, 1, 0); state.StartTimer(Now); IReadOnlyList saved = state.CaptureForSave(Now.AddMinutes(30)); Assert.Equal(1800d, Assert.Single(saved).RunningTimerSeconds); } [Fact] public void MarkSavedClearsTheDirtyFlag() { using RuntimeJournalState state = WithPages("a"); state.NewPage(); Assert.True(state.IsDirty); state.MarkSaved(); Assert.False(state.IsDirty); } // ── lifetime ──────────────────────────────────────────────────────── [Fact] public void ResetSessionClearsBecauseTheJournalIsPerCharacter() { // Carrying it across a reconnect would show one character another's // notes. using RuntimeJournalState state = WithPages("a", "b"); state.ResetSession(); Assert.Equal(0, state.View.Snapshot.PageCount); Assert.Equal(0, state.View.Snapshot.CurrentPage); } [Fact] public void EveryMutationAdvancesTheRevision() { using RuntimeJournalState state = WithPages("a"); long start = state.View.Snapshot.Revision; state.NewPage(); long afterNew = state.View.Snapshot.Revision; state.UpdateCurrent("x", "y", "z"); Assert.True(afterNew > start); Assert.True(state.View.Snapshot.Revision > afterNew); } [Fact] public void OwnershipConvergesOnlyAfterDisposal() { RuntimeJournalState state = WithPages("a"); Assert.False(state.CaptureOwnership().IsConverged); state.Dispose(); Assert.True(state.CaptureOwnership().IsConverged); } [Fact] public void MutationsAfterDisposalAreIgnoredRatherThanThrowing() { RuntimeJournalState state = WithPages("a"); state.Dispose(); state.NewPage(); state.UpdateCurrent("x", "y", "z"); state.Load([new JournalPage(Label: "b")]); Assert.True(state.CaptureOwnership().IsConverged); } }