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:
1 parent
9f86bdbb0c
commit
bd55716349
86 files changed
+3173
-2181
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user