This commit is contained in:
cthangover committed 2026-10-19 20:21:01 +03:00
commit 7a088b25a9
58 files changed
+6053

No files matched your search

@@ -0,0 +1,583 @@
namespace Cthangover.Mods.ScenarioStudio.Tests.Live
{
using System.IO;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action.Impls;
using Cthangover.Mods.Dialog.Executable;
using Godot;
/// <summary>
/// Drives the studio on the real kitchen scenario the way a player would,
/// one step per tick, checking after each step what it must have changed:
/// <list type="number">
/// <item>waits for the kitchen scenario's question, opens the dock with the
/// <c>studio</c> UI toggle (the toolbar button) and checks the current
/// line is the running action's line;</item>
/// <item>widens the dock with its left grip and moves it by the header;</item>
/// <item>pause and continue;</item>
/// <item>novice mode: the question and its answers with outcomes; adds an
/// answer (broken), redirects it to a scene through "Choose where",
/// inserts a line before the question;</item>
/// <item>applies: the scenario restarts with rollback and first shows the
/// inserted line, the edit is in the test layer and the cooking mod's
/// file is untouched; the question then has the new answer;</item>
/// <item>breakpoint on the question, restart, hold before it; drags the
/// ▶ marker back onto the inserted line, continues from there and is held
/// by the breakpoint again, continue;</item>
/// <item>the Scene, State (a checkpoint is saved there), Log and Map tabs;</item>
/// <item>creates a scene in the layer, goes there and sees its scenario run
/// from the layer, exports the layer as a mod, returns to the checkpoint
/// and closes the dock.</item>
/// </list>
/// The test layer is deleted at the end. Ends with
/// <c>[TEST_RESULT] name=studio_live passed=N failed=M</c>.
/// </summary>
public partial class StudioLiveDriverNode : Node
{
private const string ResultName = "studio_live";
private const string EntryId = "studio";
private const string Root = "Frame/Dock/Column/";
private const float TickSeconds = 0.5f;
private static readonly Vector2 Widen = new(-100, 0);
private static readonly Vector2 Shift = new(-60, -40);
private const float TimeoutSeconds = 150f;
private const string NewScene = "studio_test_yard";
private const string ExportId = "studio_test_export";
private readonly string _shotsDir;
private StudioPanel _panel;
private string _scenario;
private string _sourceMod;
private string _sourcePath;
private string _originalText;
private int _questionLine;
private int _answers;
private int _movedLine;
private Rect2 _frameRect;
private int _step = -1;
private int _passed;
private int _failed;
private double _elapsed;
private bool _finished;
public StudioLiveDriverNode(string shotsDir)
{
_shotsDir = shotsDir;
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private static StudioLive Live => StudioLive.Instance;
private DialogBox Box => Live?.Box;
private void Tick()
{
if (_finished)
return;
_elapsed += TickSeconds;
if (_elapsed >= TimeoutSeconds)
{
Fail($"timeout ({TimeoutSeconds:0}s) at step {_step}");
Finish();
return;
}
if (_step < 0 && !QuestionIsUp())
return;
_step++;
switch (_step)
{
case 0:
var scenario = (ScenarioEvent)Box.Locker;
_scenario = scenario.ID;
_sourceMod = scenario.SourceModId;
_sourcePath = scenario.SourcePath;
_originalText = ModManager.Instance.ReadFileText(_sourceMod, _sourcePath);
_questionLine = CurrentAction()?.SourceLine ?? 0;
_answers = Box.AnswerVariantCount;
Check(Live != null && Live.Layer.ForceLayer, "studio live is up with the layer forced");
Check(_sourceMod != null && _sourcePath != null, $"running scenario '{_scenario}' knows its file ({_sourceMod}:{_sourcePath})");
Check(_questionLine > 0, $"question action carries its source line ({_questionLine})");
AppServices.Current.Ui.ToggleEntry(EntryId);
break;
case 1:
Check(UiHost.Instance.TryGetInstance(EntryId, out var control) && control is StudioPanel, "dock opened by the toggle");
_panel = control as StudioPanel;
if (_panel == null)
{
Finish();
return;
}
var session = _panel.Session;
Check(session.ScenarioName == _scenario, $"dock shows the running scenario ('{session.ScenarioName}')");
Check(IsCurrentLine(session, _questionLine), "current line is the question's line");
Check(session.FileScope?.ModId == _sourceMod, $"file scope is the scenario's mod ({session.FileScope?.ModId})");
Check(session.EditScope?.Kind == StudioScopeKind.Layer, "edits go to the studio layer");
Check(Mathf.Abs(_panel.GetNode<Control>("Frame/Dock").Size.X - StudioStyles.DockWidth) < 1, "dock keeps its width");
Shot("author");
_frameRect = FrameRect();
Drag("Frame/LeftGrip", Widen);
Press(Root + "Transport/Row/PauseButton");
break;
case 2:
var widened = FrameRect();
Check(Mathf.Abs(widened.Size.X - (_frameRect.Size.X - Widen.X)) < 1 && Mathf.Abs(widened.End.X - _frameRect.End.X) < 1,
$"left grip widens the dock to the left ({_frameRect} → {widened})");
Check(Mathf.Abs(_panel.GetNode<Control>("Frame/Dock").Size.X - widened.Size.X) < 1, "the panel fills the widened frame");
_frameRect = widened;
Drag(Root + "Header", Shift);
Check(Live.Debugger.IsPaused && _panel.Session.RunState == StudioRunState.Paused, "pause is on");
Press(Root + "Transport/Row/ContinueButton");
Press(Root + "Header/Row/ModeSwitch/Items/NoviceButton");
break;
case 3:
var moved = FrameRect();
Check(moved.Position.DistanceTo(_frameRect.Position + Shift) < 1 && moved.Size.DistanceTo(_frameRect.Size) < 1,
$"header drag moves the dock ({_frameRect} → {moved})");
Check(!Live.Debugger.IsPaused, "continue turns pause off");
Check(_panel.Mode == StudioMode.Novice, "novice mode selected");
Check(CountOf<StudioSayView>() == 1, "novice: the question on screen");
Check(CountOf<StudioBranchView>() == _answers, $"novice: {_answers} answers");
Check(Broken() == 0, "novice: every answer leads somewhere");
PressText("studio/add_option");
break;
case 4:
Check(CountOf<StudioBranchView>() == _answers + 1, "new answer added");
Check(Broken() == 1, "the new answer leads nowhere yet");
PressBrokenTarget();
break;
case 5:
PickScene("home_vestibule");
break;
case 6:
Check(Broken() == 0, "the new answer now leads to a scene");
PressText("studio/add_line_before");
break;
case 7:
Check(CountOf<StudioSayView>() == 2, "new line shown before the question");
Check(_panel.Session.PendingCount > 0, $"pending edits counted ({_panel.Session.PendingCount})");
Shot("novice");
Press(Root + "Footer/Row/ApplyButton");
break;
case 8:
var line = CurrentAction() as ActionText;
Check(Box.IsRunning && Box.Locker is ScenarioEvent { ID: var id } && id == _scenario, "scenario restarted after apply");
Check(line != null && line.Text == TranslationServer.Translate("studio/new_line_text"), $"restart shows the inserted line first ('{line?.Text}')");
Check(File.Exists(Path.Combine(Live.Layer.RootPath, _sourcePath)), "edit written to the studio layer");
Check(ModManager.Instance.ReadFileText(_sourceMod, _sourcePath) == _originalText, "the mod's own file is untouched");
Shot("applied");
Box.NextAction();
break;
case 9:
Check(Box.IsWaitingAnswer && Box.AnswerVariantCount == _answers + 1, $"question now has the new answer ({Box.AnswerVariantCount})");
_questionLine = CurrentAction()?.SourceLine ?? 0;
Press(Root + "Header/Row/ModeSwitch/Items/AuthorButton");
break;
case 10:
ToggleBreakpoint(_questionLine);
break;
case 11:
Check(Live.Debugger.HasBreakpoint(_scenario, _questionLine), $"breakpoint set on line {_questionLine}");
Restart(StudioRestartMode.Scenario);
break;
case 12:
Box.NextAction();
break;
case 13:
Check(Box.Runtime.IsHeld && Box.AnswerVariantCount == 0, "held at the breakpoint before the question runs");
Check(_panel.Session.RunState == StudioRunState.Held, "dock shows the hold");
Shot("breakpoint");
DragMarkerBack();
break;
case 14:
Check(Box.Runtime.IsHeld && Live.CurrentLine() == _movedLine && Box.AnswerVariantCount == 0,
$"marker drag makes line {_movedLine} the next one (now {Live.CurrentLine()})");
Check(IsCurrentLine(_panel.Session, _movedLine), "dock shows the moved line as current");
Press(Root + "Transport/Row/ContinueButton");
break;
case 15:
var rerun = CurrentAction() as ActionText;
Check(!Box.Runtime.IsHeld && rerun != null && rerun.Text == TranslationServer.Translate("studio/new_line_text"),
$"continue runs from the moved line ('{rerun?.Text}')");
Box.NextAction();
break;
case 16:
Check(Box.Runtime.IsHeld && Live.CurrentLine() == _questionLine, "the breakpoint holds the question again");
Press(Root + "Transport/Row/ContinueButton");
break;
case 17:
Check(!Box.Runtime.IsHeld && Box.AnswerVariantCount == _answers + 1, "continue runs the question");
Press(Root + "Tabs/Row/SceneTab");
break;
case 18:
Check(_panel.Tab == StudioTab.Scene && IsRunningCandidate(_panel.Session, _scenario), "scene tab: the scenario is the running candidate");
Shot("scene");
Press(Root + "Tabs/Row/StateTab");
break;
case 19:
Check(_panel.Session.State.Count > 0 && CountOf<StudioStateRow>() == _panel.Session.State.Count, "state tab: a row per state value");
Shot("state");
MenuPick(100);
Check(Live.HasCheckpoint, "checkpoint remembered from the restart menu");
Press(Root + "Tabs/Row/LogTab");
break;
case 20:
Check(HasEditLog(_panel.Session), "log tab: the applied edit is logged");
Shot("log");
Press(Root + "Tabs/Row/MapTab");
break;
case 21:
Check(HasNode(_panel.Session, StudioNodeState.Here, "home_kitchen"), "map tab: you are in the kitchen");
Check(HasNode(_panel.Session, StudioNodeState.Next, "home_vestibule"), "map tab: the vestibule is an exit");
Shot("map");
PressText("studio/map_new_scene");
break;
case 22:
AnswerPrompt(NewScene);
break;
case 23:
Check(File.Exists(Path.Combine(Live.Layer.RootPath, "scenes", NewScene + ".json")), "new scene written to the layer");
Check(GetNodeOrNull<SceneManager>("/root/SceneManager")?.HasScene(NewScene) == true, "new scene is known to the scene manager");
PressPayload(NewScene);
break;
case 24:
if (SceneManager.GetCurrentSceneName() != NewScene || Box?.Locker is not ScenarioEvent)
{
_step--;
break;
}
Check(((ScenarioEvent)Box.Locker).SourceModId == StudioLayer.Id, $"the new scene's scenario runs from the layer ('{Box.Locker.ID}')");
Shot("new_scene");
PressText("studio/map_export");
break;
case 25:
AnswerPrompt(ExportId);
break;
case 26:
var exported = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), ExportId);
Check(File.Exists(Path.Combine(exported, "manifest.json")) && File.Exists(Path.Combine(exported, "scenes", NewScene + ".json")), "layer exported as a mod with a manifest");
if (Directory.Exists(exported))
Directory.Delete(exported, true);
MenuPick(101);
break;
case 27:
if (SceneManager.GetCurrentSceneName() != "home_kitchen")
{
_step--;
break;
}
Check(true, "back at the checkpoint's scene");
Press(Root + "Header/Row/CloseButton");
break;
default:
Check(!_panel.Visible, "close hides the dock");
Cleanup();
Finish();
break;
}
}
private static bool IsCurrentLine(StudioSession session, int number)
{
foreach (var line in session.Lines)
{
if (line.IsCurrent && line.Number == number)
return true;
}
return false;
}
private static bool IsRunningCandidate(StudioSession session, string name)
{
foreach (var candidate in session.Candidates)
{
if (candidate.Status == StudioCandidateStatus.Running && candidate.Name == name)
return true;
}
return false;
}
private static bool HasEditLog(StudioSession session)
{
foreach (var entry in session.Log)
{
if (entry.Category == StudioLogCategory.Edit)
return true;
}
return false;
}
private static bool HasNode(StudioSession session, StudioNodeState state, string name)
{
foreach (var node in session.Nodes)
{
if (node.State == state && node.Name == name)
return true;
}
return false;
}
private bool QuestionIsUp()
{
var tree = GetTree();
if (!Boot.IsReady || tree.CurrentScene == null || UiHost.Instance == null || Live == null)
return false;
var box = Box;
return box != null && box.IsRunning && box.Locker is ScenarioEvent && box.IsWaitingAnswer;
}
private Dialog.Action.IActionCommand CurrentAction()
{
var queue = Box?.Runtime.Dialog?.Queue;
var index = Box?.Runtime.Index ?? -1;
return queue != null && index >= 0 && index < queue.Count ? queue[index] : null;
}
private void Press(string path)
{
var button = _panel.GetNodeOrNull<Button>(path);
Check(button != null, $"button {path} found");
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
private void PressText(string key)
{
var button = FindButton(_panel, TranslationServer.Translate(key));
Check(button != null, $"button '{key}' found");
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
private void PressBrokenTarget()
{
foreach (var node in Content().GetChildren())
{
if (node is StudioBranchView { IsBroken: true } branch)
{
var button = FindButton(branch, TranslationServer.Translate("studio/choose_target"));
Check(button != null, "broken answer offers 'choose where'");
button?.EmitSignal(BaseButton.SignalName.Pressed);
return;
}
}
Fail("no broken answer to redirect");
}
private void PickScene(string scene)
{
PopupMenu menu = null;
foreach (var child in _panel.GetChildren())
{
if (child is PopupMenu popup && popup.Visible)
menu = popup;
}
Check(menu != null, "target menu opened");
if (menu == null)
return;
var caption = string.Format(TranslationServer.Translate("studio/target_scene"), scene);
for (var i = 0; i < menu.ItemCount; i++)
{
if (menu.GetItemText(i) != caption)
continue;
menu.EmitSignal(PopupMenu.SignalName.IdPressed, (long)menu.GetItemId(i));
menu.Hide();
return;
}
Fail($"scene {scene} not offered as a target");
}
private void PressPayload(string payload)
{
foreach (var button in Descendants<StudioActionButton>(Content()))
{
if (button.Payload != payload)
continue;
button.EmitSignal(BaseButton.SignalName.Pressed);
return;
}
Fail($"no button for '{payload}'");
}
/// <summary>Types <paramref name="text"/> into the open studio prompt and confirms it.</summary>
private void AnswerPrompt(string text)
{
foreach (var child in _panel.GetChildren())
{
if (child is not ConfirmationDialog dialog || !dialog.Visible)
continue;
foreach (var field in Descendants<LineEdit>(dialog))
field.Text = text;
dialog.EmitSignal(AcceptDialog.SignalName.Confirmed);
return;
}
Fail("no studio prompt open");
}
private void ToggleBreakpoint(int line)
{
foreach (var node in Descendants<StudioCodeLineView>(Content()))
{
if (node.Line.SourceLine != line)
continue;
var click = new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true };
node.Gutter.EmitSignal(Control.SignalName.GuiInput, click);
return;
}
Fail($"no script line {line} in the author view");
}
private Rect2 FrameRect()
{
var frame = _panel.GetNode<Control>("Frame");
return new Rect2(frame.Position, frame.Size);
}
/// <summary>Presses the left button on a dock handle, moves the mouse by <paramref name="delta"/> and releases.</summary>
private void Drag(string path, Vector2 delta)
{
var handle = _panel.GetNodeOrNull<Control>(path);
Check(handle != null, $"handle {path} found");
if (handle != null)
DragFrom(handle, delta);
}
private static void DragFrom(Control handle, Vector2 delta)
{
handle.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
handle.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseMotion { Position = delta, ButtonMask = MouseButtonMask.Left });
handle.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Position = delta });
}
/// <summary>Drags the ▶ marker of the current line onto the nearest line above it the dialog can run from.</summary>
private void DragMarkerBack()
{
StudioCodeLineView current = null;
StudioCodeLineView target = null;
foreach (var view in Descendants<StudioCodeLineView>(Content()))
{
if (view.Line.IsCurrent)
current = view;
else if (current == null && view.Line.CanRunNext)
target = view;
}
Check(current != null && target != null, "a current line and a runnable line above it");
if (current == null || target == null)
return;
_movedLine = target.Line.SourceLine;
DragFrom(current.Marker, target.GetGlobalRect().GetCenter() - current.Marker.GlobalPosition);
}
private void Restart(StudioRestartMode mode) => MenuPick((long)mode);
private void MenuPick(long id)
{
var popup = _panel.GetNode<MenuButton>(Root + "Transport/Row/RestartButton").GetPopup();
popup.EmitSignal(PopupMenu.SignalName.IdPressed, id);
}
private int Broken()
{
var broken = 0;
foreach (var node in Content().GetChildren())
{
if (node is StudioBranchView { IsBroken: true })
broken++;
}
return broken;
}
private VBoxContainer Content() => _panel.GetNode<VBoxContainer>(Root + "Body/Scroll/Content");
private int CountOf<T>() where T : Node => Descendants<T>(Content()).Count;
private static System.Collections.Generic.List<T> Descendants<T>(Node node) where T : Node
{
var result = new System.Collections.Generic.List<T>();
foreach (var child in node.GetChildren())
{
if (child is T typed)
result.Add(typed);
result.AddRange(Descendants<T>(child));
}
return result;
}
private static Button FindButton(Node node, string text)
{
if (node is Button button && button.Text == text)
return button;
foreach (var child in node.GetChildren())
{
var found = FindButton(child, text);
if (found != null)
return found;
}
return null;
}
private void Cleanup()
{
var root = Live?.Layer.RootPath;
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
return;
ModOverlays.Unmount(StudioLayer.Id);
StudioLayer.RefreshContent();
Directory.Delete(root, true);
var parent = Path.GetDirectoryName(root);
if (parent != null && Directory.GetFileSystemEntries(parent).Length == 0)
Directory.Delete(parent);
Log($"test layer removed: {root}");
}
private void Shot(string id)
{
if (string.IsNullOrEmpty(_shotsDir) || DisplayServer.GetName() == "headless")
return;
var dir = ProjectSettings.GlobalizePath(_shotsDir);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, id + ".png");
GetViewport().GetTexture().GetImage().SavePng(path);
Log($"screenshot {path}");
}
private void Check(bool ok, string name)
{
if (ok)
{
_passed++;
Log($"[PASS] {name}");
}
else
{
Fail(name);
}
}
private void Fail(string message)
{
_failed++;
Log($"[FAIL] {message}");
}
private void Finish()
{
if (_finished)
return;
_finished = true;
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");
GetTree().Quit(_failed == 0 ? 0 : 1);
}
private static void Log(string message) => GameLogger.Log("TEST", message);
}
}
@@ -0,0 +1,41 @@
namespace Cthangover.Mods.ScenarioStudio.Tests.Live
{
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// Entry point of the studio live test, active only with
/// <c>--itest=studio_live</c>; <c>--shots=&lt;dir&gt;</c> also saves a
/// screenshot per checked state (needs a real display, e.g. xvfb). The
/// test expects <c>--studio-write=layer</c> and its own
/// <c>--studio-layer=</c> so it never writes into a mod. The driver is added
/// deferred because resources-ready fires before the shell scene exists.
/// </summary>
public class StudioLiveTestInitializer : IModInitializer
{
private static bool driverQueued;
public void OnModLoaded(string modId) { }
public void OnModResourcesReady()
{
if (driverQueued)
return;
var args = OS.GetCmdlineUserArgs();
if (!args.Any(a => a == "--itest=studio_live"))
return;
var shots = args
.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
.Select(a => a.Substring("--shots=".Length))
.FirstOrDefault();
driverQueued = true;
GameLogger.Log("TEST", $"StudioLiveTestInitializer: activating studio live test, shots='{shots ?? ""}'");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new StudioLiveDriverNode(shots));
}
}
}