Event-driven dialog queue, story flag command, mod unit tests

Push-based event chains, runtime fixes (single end, goto lifecycle, if,
busy box, held delay, empty options, timed/animated/effect actions), the
'flag name value' DSL command, autoplay --dialogs=N, 19 mod unit tests.
This commit is contained in:
cthangover committed 2026-10-21 12:31:52 +02:00
1 parent 9f86bdbb0c
commit bd55716349
86 files changed
+3173 -2181

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Action.Impls;
using Godot;
/// <summary>
/// <see cref="ActionAnimatedForeground"/> really animates: frames are cut on the
/// ViewBox foreground while the dialog goes on, a one-shot animation stops on its
/// last frame, and a looping one stops when the dialog ends.
/// </summary>
public class ActionAnimatedForegroundTests
{
[ModTest("animation/one_shot_reaches_last_frame", TimeoutSeconds = 10)]
static async Task OneShotReachesLastFrame(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var view = stage.AddViewBox();
await DialogStage.Frames(1);
try
{
var frames = Frames(3);
var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = false };
var dlg = new DialogQueue();
dlg.Queue.Add(anim);
dlg.Queue.Add(new ProbeAction());
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.True(view.Foreground.Texture == frames[0], "first frame is shown at once");
ModAssert.True(anim.IsAnimating, "animation outlives its NoWait action");
await DialogStage.Seconds(0.6);
ModAssert.True(view.Foreground.Texture == frames[2], "animation reached the last frame");
ModAssert.False(anim.IsAnimating, "one-shot animation stopped");
}
finally
{
stage.Free();
}
}
[ModTest("animation/loop_stops_with_dialog_end", TimeoutSeconds = 10)]
static async Task LoopStopsWithDialogEnd(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var view = stage.AddViewBox();
await DialogStage.Frames(1);
try
{
var frames = Frames(2);
var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = true };
var dlg = new DialogQueue();
dlg.Queue.Add(anim);
dlg.Queue.Add(new ProbeAction());
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
await DialogStage.Seconds(0.3);
ModAssert.True(anim.IsAnimating, "looping animation keeps playing while the dialog waits");
stage.Box.NextAction();
ModAssert.False(anim.IsAnimating, "dialog end stops the animation");
var shown = view.Foreground.Texture;
await DialogStage.Seconds(0.3);
ModAssert.True(view.Foreground.Texture == shown, "no frame is cut after the dialog ended");
}
finally
{
stage.Free();
}
}
private static List<Texture2D> Frames(int count)
{
var list = new List<Texture2D>();
for (var i = 0; i < count; i++)
list.Add(ImageTexture.CreateFromImage(Image.CreateEmpty(2, 2, false, Image.Format.Rgba8)));
return list;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Action.Impls;
/// <summary>
/// <see cref="ActionBackgroundShowHide"/> without <c>wait</c>: the dialog goes on at
/// once and the fade still plays to the end.
/// </summary>
public class ActionBackgroundShowHideTests
{
[ModTest("background_fade/no_wait_fade_completes", TimeoutSeconds = 10)]
static async Task NoWaitFadeCompletes(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var after = new ProbeAction();
var dlg = new DialogQueue();
dlg.BackgroundShowHide(BackgroundActionType.Hide, 0.1f);
dlg.Queue.Add(after);
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.Equal(1, after.Runs, "dialog does not wait for the fade");
await DialogStage.Seconds(0.4);
ModAssert.True(stage.Host.Modulate.A < 0.01f, $"background faded out (alpha {stage.Host.Modulate.A})");
}
finally
{
stage.Free();
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Action.Impls;
using Godot;
/// <summary>
/// <see cref="ActionEffect"/> shows its effect: the instance lands in the ViewBox UI
/// layer when the action runs and is freed when the dialog ends.
/// </summary>
public class ActionEffectTests
{
[ModTest("effect/spawned_into_view_until_dialog_end", TimeoutSeconds = 10)]
static async Task SpawnedIntoViewUntilDialogEnd(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var view = stage.AddViewBox();
var template = new Node2D { Name = "TestEffect" };
var prefab = new PackedScene();
prefab.Pack(template);
template.Free();
try
{
var dlg = new DialogQueue();
dlg.Queue.Add(new ActionEffect { Prefab = prefab });
dlg.Queue.Add(new ProbeAction());
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
var layer = view.GetInteractiveLayer("ui");
ModAssert.Equal(1, layer.GetChildCount(), "effect is shown in the UI layer");
ModAssert.Equal("TestEffect", layer.GetChild(0).Name.ToString(), "the shown node is the effect");
stage.Box.NextAction();
await DialogStage.Frames(2);
ModAssert.Equal(0, layer.GetChildCount(), "effect is freed when the dialog ends");
}
finally
{
stage.Free();
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Action.Impls;
/// <summary>
/// <see cref="ActionTimedUpdate"/> completes itself: when its timeline elapses the
/// dialog goes on, including the timer of a <c>delay</c> right after it.
/// </summary>
public class ActionTimedUpdateTests
{
[ModTest("timed_update/completion_continues_dialog", TimeoutSeconds = 10)]
static async Task CompletionContinuesDialog(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var timed = new ActionTimedUpdate { WaitTime = 0.1f };
var after = new ProbeAction();
var dlg = new DialogQueue();
dlg.Queue.Add(timed);
dlg.Delay(0.1f);
dlg.Queue.Add(after);
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.True(stage.Box.Runtime.CurrentAction == timed, "dialog waits on the timeline");
await DialogStage.Seconds(0.8);
ModAssert.True(timed.IsDestructed, "timeline is finished and detached");
ModAssert.Equal(1, after.Runs, "timeline and the delay after it elapsed, the dialog went on");
ModAssert.True(stage.Box.Runtime.CurrentAction == after, "runtime waits on the action after the delay");
}
finally
{
stage.Free();
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Action.Impls;
using Cthangover.Mods.Dialog.Executable;
/// <summary>
/// <see cref="DialogBox"/> as the single owner of the dialog: a locker-less click
/// dialog keeps the box busy, a debugger hold keeps a <c>delay</c> from being
/// skipped by its timer, and choice answers advance correctly with and without a
/// target.
/// </summary>
public class DialogBoxTests
{
[ModTest("box/click_dialog_blocks_event_dialog", TimeoutSeconds = 10)]
static async Task ClickDialogBlocksEventDialog(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var click = new ProbeAction();
var dlg = new DialogQueue();
dlg.Queue.Add(click);
ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "click dialog starts");
ModAssert.True(stage.Box.IsRunning, "a locker-less dialog occupies the box");
var evt = new ScenarioEvent { ScenarioText = "text \"event\"", ScenarioId = "dialog_base_test_one_run", IsOneRun = true };
stage.Host.AddChild(evt);
ModAssert.False(evt.RunDialog(), "event dialog is refused while the click dialog runs");
await DialogStage.Frames(3);
ModAssert.Null(stage.Box.Locker, "the click dialog keeps no locker");
ModAssert.True(stage.Box.Visible, "box stays visible");
ModAssert.True(stage.Box.Runtime.CurrentAction == click, "click dialog still waits on its action");
ModAssert.True(evt.IsFirstRun, "refused event is not counted as run");
var completed = GameData.Instance?.Runtime?.CompletedScenarioIds;
ModAssert.False(completed != null && completed.Contains("dialog_base_test_one_run"), "refused one-run scenario is not marked completed");
var other = new DialogQueue();
other.Queue.Add(new ProbeAction());
ModAssert.False(stage.Box.SetDialogQueueAndRun(other, null, 0, null), "a second click dialog is refused too");
}
finally
{
stage.Free();
}
}
[ModTest("box/held_delay_is_not_skipped", TimeoutSeconds = 10)]
static async Task HeldDelayIsNotSkipped(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var previous = DialogRuntime.Debugger;
try
{
var delay = new ActionDelay { WaitTime = 0.05f };
var after = new ProbeAction();
var dlg = new DialogQueue();
dlg.Queue.Add(delay);
dlg.Queue.Add(after);
DialogRuntime.Debugger = new HoldDebugger { Target = delay };
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.True(stage.Box.Runtime.IsHeld, "runtime is held before the delay");
await DialogStage.Seconds(0.3);
ModAssert.True(stage.Box.Runtime.IsHeld, "hold survives the delay's duration");
ModAssert.True(stage.Box.Runtime.CurrentAction == delay, "held delay stays current");
ModAssert.Equal(0, after.Runs, "action after the held delay has not run");
DialogRuntime.Debugger = null;
stage.Box.Resume();
await DialogStage.Seconds(0.3);
ModAssert.Equal(1, after.Runs, "after resume the delay elapses and the dialog goes on");
ModAssert.True(stage.Box.Runtime.CurrentAction == after, "runtime waits on the action after the delay");
}
finally
{
DialogRuntime.Debugger = previous;
stage.Free();
}
}
[ModTest("box/option_without_target_falls_through", TimeoutSeconds = 10)]
static async Task OptionWithoutTargetFallsThrough(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var after = new ProbeAction();
var dlg = new DialogQueue();
dlg.Select(new List<SelectVariant> { SelectVariant.New("go on", null) }, "choose");
dlg.Queue.Add(after);
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.True(stage.Box.IsWaitingAnswer, "choice is shown");
ModAssert.True(stage.Box.TrySelectVariant(0), "variant is picked");
ModAssert.False(stage.Box.IsWaitingAnswer, "the same choice is not shown again");
ModAssert.Equal(1, after.Runs, "action after the choice runs");
ModAssert.True(stage.Box.Runtime.CurrentAction == after, "runtime waits on the action after the choice");
}
finally
{
stage.Free();
}
}
[ModTest("box/option_to_missing_label_ends_once", TimeoutSeconds = 10)]
static async Task OptionToMissingLabelEndsOnce(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var recorder = new DialogEndRecorder();
recorder.Start();
try
{
var dlg = new DialogQueue();
dlg.Select(new List<SelectVariant> { SelectVariant.New("broken", "no_such_label") }, "choose");
dlg.Queue.Add(new ProbeAction());
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
stage.Box.TrySelectVariant(0);
ModAssert.Equal(1, recorder.LockerIds.Count, "DialogEndedEvent count after an option to a missing label");
ModAssert.False(stage.Box.IsRunning, "box is free");
}
finally
{
recorder.Stop();
stage.Free();
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using Cthangover.Core.Events;
/// <summary>
/// Records every <see cref="DialogEndedEvent"/> published on the mod event bus
/// between <see cref="Start"/> and <see cref="Stop"/> — the locker id of each, in
/// publication order. Tests count how often a queue reported its end.
/// </summary>
public class DialogEndRecorder
{
/// <summary>Locker ids of the recorded events (null for a locker-less dialog).</summary>
public List<string> LockerIds { get; } = new();
/// <summary>Subscribes to the event bus.</summary>
public void Start()
{
ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);
}
/// <summary>Unsubscribes; call it in <c>finally</c> so a failed test leaves no handler behind.</summary>
public void Stop()
{
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
}
private void OnDialogEnded(DialogEndedEvent evt)
{
LockerIds.Add(evt.LockerId);
}
}
}
+128
View File
@@ -0,0 +1,128 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Action.Impls;
/// <summary>
/// Queue mechanics of <see cref="DialogRuntime"/>: a queue ends exactly once however
/// it reaches the end, actions reached again through a looping goto get a full
/// construct/destruct life every pass, and a fall-through branch does not skip the
/// action after it.
/// </summary>
public class DialogRuntimeTests
{
[ModTest("runtime/end_command_publishes_dialog_ended_once", TimeoutSeconds = 10)]
static async Task EndCommandPublishesOnce(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var recorder = new DialogEndRecorder();
recorder.Start();
try
{
var after = new ProbeAction();
var dlg = new DialogQueue();
dlg.Set("x", "1");
dlg.End();
dlg.Queue.Add(after);
ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts");
ModAssert.Equal(1, recorder.LockerIds.Count, "DialogEndedEvent count after 'end'");
ModAssert.Equal(0, after.Runs, "action after 'end' never runs");
ModAssert.False(stage.Box.IsRunning, "box is free after 'end'");
}
finally
{
recorder.Stop();
stage.Free();
}
}
[ModTest("runtime/goto_missing_label_publishes_dialog_ended_once", TimeoutSeconds = 10)]
static async Task GotoMissingLabelPublishesOnce(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var recorder = new DialogEndRecorder();
recorder.Start();
try
{
var after = new ProbeAction();
var dlg = new DialogQueue();
dlg.GoTo("no_such_label");
dlg.Queue.Add(after);
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.Equal(1, recorder.LockerIds.Count, "DialogEndedEvent count after a goto to a missing label");
ModAssert.Equal(0, after.Runs, "action after the broken goto never runs");
ModAssert.False(stage.Box.IsRunning, "box is free after the broken goto");
}
finally
{
recorder.Stop();
stage.Free();
}
}
[ModTest("runtime/goto_loop_destructs_every_pass")]
static void GotoLoopDestructsEveryPass(ModTestContext ctx)
{
var probe = new ProbeAction();
var dlg = new DialogQueue();
dlg.Point("loop");
dlg.Queue.Add(probe);
dlg.GoTo("loop");
var runtime = new DialogRuntime();
runtime.SetDialogQueueAndRun(null, dlg.Queue, null);
runtime.Run();
for (var pass = 0; pass < 2; pass++)
{
runtime.Next();
runtime.Run();
}
ModAssert.Equal(3, probe.Runs, "probe ran on every pass");
ModAssert.Equal(3, probe.Constructs, "probe constructed on every pass");
ModAssert.Equal(2, probe.Destructs, "probe destructed after every finished pass");
ModAssert.True(runtime.CurrentAction == probe, "runtime waits on the probe again");
runtime.Abort();
ModAssert.Equal(3, probe.Destructs, "abort destructs the live pass");
}
[ModTest("runtime/if_fall_through_runs_next_action")]
static void IfFallThroughRunsNextAction(ModTestContext ctx)
{
var probe = new ProbeAction();
var dlg = new DialogQueue();
dlg.IfGoTo(AlwaysTrue, null, null);
dlg.Queue.Add(probe);
var runtime = new DialogRuntime();
runtime.SetDialogQueueAndRun(null, dlg.Queue, null);
runtime.Run();
ModAssert.Equal(1, probe.Runs, "action after a fall-through if runs");
ModAssert.True(runtime.CurrentAction == probe, "runtime waits on the action after the if");
}
[ModTest("runtime/if_without_condition_runs_next_action")]
static void IfWithoutConditionRunsNextAction(ModTestContext ctx)
{
var probe = new ProbeAction();
var dlg = new DialogQueue();
dlg.Queue.Add(new ActionIf { TrueGoTo = "nowhere" });
dlg.Queue.Add(probe);
var runtime = new DialogRuntime();
runtime.SetDialogQueueAndRun(null, dlg.Queue, null);
runtime.Run();
ModAssert.Equal(1, probe.Runs, "action after an if without condition runs");
ModAssert.True(runtime.CurrentAction == probe, "runtime waits on the action after the if");
}
private static bool AlwaysTrue() => true;
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Interface.View;
using Godot;
/// <summary>
/// A minimal live dialog stage for mod tests: a host Control under <c>/root</c>
/// with the real <c>dialog_box.tscn</c> prefab (named <c>DialogBox</c>, so
/// <see cref="SceneContextNode.FindNode{T}"/> and the event classes find it like in
/// a scene) and, on request, a bare <see cref="ViewBox"/> with a foreground slot.
/// The scene-context lookup cache is cleared on build and on <see cref="Free"/>:
/// it remembers misses, and a test box must be found even if an earlier lookup
/// failed. The host is the box's parent Control — background fades animate it.
/// </summary>
public class DialogStage
{
/// <summary>Parent of every stage node; freed by <see cref="Free"/>.</summary>
public Control Host { get; private set; }
/// <summary>The dialog box built from its prefab.</summary>
public DialogBox Box { get; private set; }
/// <summary>The view box, after <see cref="AddViewBox"/>.</summary>
public ViewBox View { get; private set; }
/// <summary>The running scene tree.</summary>
public static SceneTree Tree => (SceneTree)Engine.GetMainLoop();
/// <summary>Builds the host and the dialog box and waits a frame for their <c>_Ready</c>.</summary>
public static async Task<DialogStage> Create()
{
SceneContextNode.Instance?.ClearData();
var stage = new DialogStage { Host = new Control { Name = "DialogTestHost" } };
Tree.Root.AddChild(stage.Host);
stage.Box = new ModScenePrefab("scenes/ui/dialog_box.tscn").Instantiate<DialogBox>();
ModAssert.NotNull(stage.Box, "dialog_box.tscn builds a DialogBox");
stage.Box.Name = "DialogBox";
stage.Host.AddChild(stage.Box);
await Frames(1);
return stage;
}
/// <summary>Adds a <see cref="ViewBox"/> named <c>ViewBox</c> with a <c>Foreground</c> TextureRect.</summary>
public ViewBox AddViewBox()
{
View = new ViewBox { Name = "ViewBox" };
View.AddChild(new TextureRect { Name = "Foreground" });
Host.AddChild(View);
SceneContextNode.Instance?.ClearData();
return View;
}
/// <summary>Removes the stage from the tree and forgets cached lookups of its nodes.</summary>
public void Free()
{
if (Box != null && Box.IsRunning)
Box.Abort();
if (GodotObject.IsInstanceValid(Host))
{
Tree.Root.RemoveChild(Host);
Host.QueueFree();
}
SceneContextNode.Instance?.ClearData();
}
/// <summary>Waits <paramref name="count"/> process frames (deferred calls run at the end of each).</summary>
public static async Task Frames(int count)
{
for (var i = 0; i < count; i++)
await Tree.ToSignal(Tree, SceneTree.SignalName.ProcessFrame);
}
/// <summary>Waits <paramref name="seconds"/> of game time.</summary>
public static async Task Seconds(double seconds)
{
await Tree.ToSignal(Tree.CreateTimer(seconds), SceneTreeTimer.SignalName.Timeout);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog.Executable;
/// <summary>
/// A standalone <see cref="ExecutableEvent"/> (not inside a chain): a run the dialog
/// box refused is not counted, and the event starts by itself on the next state
/// change, without polling.
/// </summary>
public class ExecutableEventTests
{
[ModTest("event/refused_run_is_not_counted", TimeoutSeconds = 10)]
static async Task RefusedRunIsNotCounted(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
stage.Box.IsActive = false;
var evt = new ScenarioEvent { ScenarioText = "text \"S\"", ScenarioId = "dialog_base_test_standalone", IsOneRun = true };
stage.Host.AddChild(evt);
await DialogStage.Frames(2);
ModAssert.False(stage.Box.IsRunning, "inactive box refused the event");
ModAssert.True(evt.IsFirstRun, "refused automatic run is not counted");
ModAssert.False(evt.RunDialog(), "direct run is refused too");
ModAssert.True(evt.IsFirstRun, "refused direct run is not counted");
stage.Box.IsActive = true;
ConditionStateMonitor.MarkStateChanged();
await DialogStage.Frames(2);
ModAssert.True(stage.Box.Locker == evt, "one-run event starts on the next state change");
ModAssert.False(evt.IsFirstRun, "the real run is counted");
}
finally
{
stage.Free();
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog.Executable;
/// <summary>
/// <see cref="ExecutableMainEventChain"/> is event-driven and loop-safe: the next
/// event starts within a couple of frames after a dialog ends (no polling delay),
/// two repeatable events do not ping-pong while the state stays the same, and a
/// state change makes them eligible again.
/// </summary>
public class ExecutableMainEventChainTests
{
[ModTest("chain/push_start_and_no_ping_pong", TimeoutSeconds = 15)]
static async Task PushStartAndNoPingPong(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var chain = new ExecutableMainEventChain { Name = "TestChain" };
stage.Host.AddChild(chain);
var a = new ScenarioEvent { ScenarioText = "text \"A\"", ScenarioId = "dialog_base_test_chain_a" };
var b = new ScenarioEvent { ScenarioText = "text \"B\"", ScenarioId = "dialog_base_test_chain_b" };
chain.AddEvent(a);
chain.AddEvent(b);
await DialogStage.Frames(2);
ModAssert.True(stage.Box.Locker == a, "first event starts right after it was added");
stage.Box.NextAction();
await DialogStage.Frames(2);
ModAssert.True(stage.Box.Locker == b, "second event starts within two frames after the first ended");
stage.Box.NextAction();
await DialogStage.Frames(4);
ModAssert.False(stage.Box.IsRunning, "no event re-runs while the state did not change");
ConditionStateMonitor.MarkStateChanged();
await DialogStage.Frames(2);
ModAssert.True(stage.Box.Locker == a, "a state change makes the first event eligible again");
}
finally
{
stage.Free();
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Mods.Dialog.Action;
/// <summary>
/// Debugger stand-in that holds the runtime once, right before
/// <see cref="Target"/> — what the scenario studio does on a breakpoint.
/// </summary>
public class HoldDebugger : IDialogDebugger
{
private bool held;
/// <summary>The action to hold before.</summary>
public IActionCommand Target { get; set; }
/// <summary>Holds before <see cref="Target"/> the first time it is reached.</summary>
public bool ShouldHold(DialogRuntime runtime, IActionCommand action)
{
if (held || action != Target)
return false;
held = true;
return true;
}
}
}
+49
View File
@@ -0,0 +1,49 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using Cthangover.Core.UI.Event;
/// <summary>
/// Update listener for <see cref="SceneEventController"/> tests: appends its
/// <see cref="Name"/> to a shared <see cref="Log"/> on every update and may change
/// the controller's listener list from inside the callback — remove
/// <see cref="RemoveOnUpdate"/>, add <see cref="AddOnUpdate"/> — to check how a
/// dispatch copes with a list changed under it.
/// </summary>
public class OrderListener : IOnUpdateEvent
{
/// <summary>Name written to <see cref="Log"/>.</summary>
public string Name { get; set; }
/// <summary>Dispatch priority.</summary>
public int Priority { get; set; }
/// <summary>Shared call log.</summary>
public List<string> Log { get; set; }
/// <summary>Controller whose list the callback changes.</summary>
public SceneEventController Controller { get; set; }
/// <summary>Listener to unregister during the update, if any.</summary>
public IOnUpdateEvent RemoveOnUpdate { get; set; }
/// <summary>Listener to register during the update, if any.</summary>
public IOnUpdateEvent AddOnUpdate { get; set; }
/// <summary>Logs the call and applies the requested list changes once.</summary>
public void OnUpdate()
{
Log.Add(Name);
if (RemoveOnUpdate != null)
{
Controller.RemoveUpdateEventListener(RemoveOnUpdate);
RemoveOnUpdate = null;
}
if (AddOnUpdate != null)
{
Controller.AddUpdateEventListener(AddOnUpdate);
AddOnUpdate = null;
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Mods.Dialog.Action;
using Cthangover.Core.UI.Dialog;
/// <summary>
/// Test action that only counts its lifecycle calls: how often the runtime ran,
/// constructed and destructed it. Waits for a click by default, so a test can
/// park the dialog on it and look at where the runtime stands.
/// </summary>
public class ProbeAction : ActionCommand
{
/// <summary>Number of <see cref="DoRun"/> calls.</summary>
public int Runs { get; private set; }
/// <summary>Number of <see cref="DoConstruct"/> calls.</summary>
public int Constructs { get; private set; }
/// <summary>Number of <see cref="DoDestruct"/> calls.</summary>
public int Destructs { get; private set; }
/// <summary>Counts the run.</summary>
public override void DoRun(DialogRuntime runtime)
{
Runs++;
}
/// <summary>Counts the construction.</summary>
public override void DoConstruct()
{
Constructs++;
}
/// <summary>Counts the destruction.</summary>
public override void DoDestruct()
{
Destructs++;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.UI.Event;
/// <summary>
/// Dispatch contract of the core <see cref="SceneEventController"/> (tested here:
/// it is a Godot node, xUnit cannot create it; the dialog actions are its main
/// listeners): lower priority first, registration order within a priority, and a
/// listener list changed during a dispatch neither skips nor double-calls anyone.
/// </summary>
public class SceneEventControllerTests
{
[ModTest("event_controller/lower_priority_first_then_registration_order")]
static void LowerPriorityFirst(ModTestContext ctx)
{
var log = new List<string>();
var controller = new SceneEventController();
try
{
controller.AddUpdateEventListener(new OrderListener { Name = "b", Priority = 1, Log = log });
controller.AddUpdateEventListener(new OrderListener { Name = "a", Priority = 0, Log = log });
controller.AddUpdateEventListener(new OrderListener { Name = "c", Priority = 1, Log = log });
controller._Process(0.0);
ModAssert.Equal("a,b,c", string.Join(",", log), "dispatch order");
}
finally
{
controller.Free();
}
}
[ModTest("event_controller/list_changed_during_dispatch")]
static void ListChangedDuringDispatch(ModTestContext ctx)
{
var log = new List<string>();
var controller = new SceneEventController();
try
{
var removed = new OrderListener { Name = "removed", Priority = 1, Log = log };
var added = new OrderListener { Name = "added", Priority = 0, Log = log };
var changer = new OrderListener
{
Name = "changer", Priority = 0, Log = log, Controller = controller,
RemoveOnUpdate = removed, AddOnUpdate = added,
};
var last = new OrderListener { Name = "last", Priority = 2, Log = log };
controller.AddUpdateEventListener(changer);
controller.AddUpdateEventListener(removed);
controller.AddUpdateEventListener(last);
controller._Process(0.0);
ModAssert.Equal("changer,last", string.Join(",", log), "first dispatch: removed one skipped, added one waits");
log.Clear();
controller._Process(0.0);
ModAssert.Equal("changer,added,last", string.Join(",", log), "second dispatch includes the added listener");
}
finally
{
controller.Free();
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Scenarios;
/// <summary>
/// The scenario <c>flag</c> command sets a story flag of the game: once the
/// dialog reaches it, scenario conditions see the value, the game state
/// holds it (so saves carry it), and conditions are rescanned.
/// </summary>
public class StoryFlagTests
{
private const string Flag = "__test_story_flag";
[ModTest("flags/flag_command_sets_game_flag", TimeoutSeconds = 10)]
static async Task FlagCommandSetsGameFlag(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var flags = GameData.Instance.Runtime.Flags;
try
{
var dlg = new ScenarioConverter().Convert($"flag {Flag} open\nend\n");
var versionBefore = ConditionStateMonitor.Version;
ModAssert.False(ScenarioCondition.Evaluate($"{Flag} == \"open\""), "condition false before the dialog");
ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts");
ModAssert.True(ScenarioCondition.Evaluate($"{Flag} == \"open\""), "condition sees the flag");
ModAssert.Equal("open", flags.TryGetValue(Flag, out var value) ? value : null, "game state holds the flag");
ModAssert.True(ConditionStateMonitor.Version > versionBefore, "conditions are rescanned");
}
finally
{
flags.Remove(Flag);
stage.Free();
}
}
}
}