namespace Cthangover.Tools.Schemas.Tests
{
using Cthangover.Mods.Schemas;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Core.Utils;
using Cthangover.Mods.Equipment;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Stats;
using Cthangover.Tools.Services;
using Godot;
///
/// Drives the Data Editor window and the command palette like an author: a new item gets
/// gear fields that two different mods described, the game sees it as gear right after
/// Save, and the palette writes and checks a stats line. Everything the test writes is
/// removed again, the files it changed are restored byte for byte.
///
public partial class SchemaToolsTestDriver : Node
{
private const float TickSeconds = 0.15f;
private const float TimeoutSeconds = 60f;
private const string Mod = "rpg_plus_game";
private const string NewFile = "items/rpg_plus_game.json";
private const string NewId = "rpg_plus_game/new_item";
private const string ScenarioKey = "rpg_plus_game:scenarios/rpg_plus/events/seer.scenario";
private readonly List failures = new();
private readonly Dictionary backups = new();
private int passed;
private int step;
private double elapsed;
private bool newFileExisted;
private bool itemsDirExisted;
private DataEditorWindow editor;
private CommandPaletteWindow palette;
private ScenarioEditorWindow scenarios;
public SchemaToolsTestDriver()
{
Name = "SchemaToolsTestDriver";
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private void Tick()
{
if (step < 0)
return;
elapsed += TickSeconds;
if (elapsed > TimeoutSeconds)
{
Fail($"timeout at step {step}");
Cleanup();
Finish();
return;
}
try
{
RunStep();
}
catch (System.Exception ex)
{
Fail($"step {step}: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
Cleanup();
Finish();
}
}
private void RunStep()
{
switch (step)
{
case 0:
if (!Boot.IsReady)
return;
foreach (var file in ModFileService.ListFiles(Mod, "locale", ".properties"))
backups[file] = ModFileService.ReadText(Mod, file);
newFileExisted = ModFileService.ReadText(Mod, NewFile) != null;
itemsDirExisted = Directory.Exists(Path.GetDirectoryName(ModFileService.GetWritablePath(Mod, NewFile)) ?? string.Empty);
Check(!newFileExisted, "the game mod has no items file of its own yet");
editor = ToolWindow.Open();
step++;
return;
case 1:
if (!editor.IsInsideTree())
return;
var kinds = SchemaCatalog.Current.Kinds;
Check(new[] { "stat", "archetype", "equip_slot", "item", "character", "action", "shop" }.All(kinds.ContainsKey), "the editor lists the kinds of five mods' schemas");
editor.OpenKind("item");
editor.OpenMod(Mod);
Check(editor.KindId == "item" && editor.ModId == Mod, "items of the game mod are open");
editor.AddNew();
Check(editor.SelectedId == NewId, $"New adds {NewId} (got {editor.SelectedId})");
Type(editor, "Text_Equip", "sword");
var list = editor.FindChild("List_Traits", true, false);
Check(list != null, "the equipment mod's Traits field is a list editor in the item form");
(list?.FindChild("Add", true, false) as Button)?.EmitSignal(BaseButton.SignalName.Pressed);
step++;
return;
case 2:
var card = editor.FindChild("List_Traits", true, false)?.FindChild("Card0", true, false);
Check(card != null, "Add made a trait card");
var picker = card?.FindChild("Picker_Stat", true, false) as OptionButton;
var index = Enumerable.Range(0, picker?.ItemCount ?? 0).FirstOrDefault(i => picker.GetItemText(i).EndsWith(" atk") || picker.GetItemText(i) == "atk");
Check(picker != null && index > 0, "the trait's stat picker offers the pack's stats");
picker?.Select(index);
picker?.EmitSignal(OptionButton.SignalName.ItemSelected, index);
Type(card, "Text_Add", "3");
var data = editor.Document.Find(NewId)?.Data;
Check(data?["Equip"]?.ToString() == "sword", "the form wrote the equip type");
Check(data?["Traits"]?[0]?["Stat"]?.ToString() == "atk" && data?["Traits"]?[0]?["Add"]?.ToString() == "3", $"the form wrote the trait: {data?["Traits"]?.ToJsonString()}");
Check(editor.Save(), "Save writes the mod");
Check(EquipmentService.IsEquipment(NewId), "the game knows the new item as gear right after saving");
var traits = ExtraData.Traits(EquipmentService.Item(NewId)?.ExtraProperties, NewId);
Check(traits.Count == 1 && traits[0].Stat == "atk" && traits[0].Add == 3, "with its trait");
var written = ModFileService.ReadText(Mod, NewFile) ?? string.Empty;
Check(written.Contains("\"Kind\": \"item\"") && !written.Contains("\r"), "the new file declares its kind and has LF lines");
palette = ToolWindow.Open();
scenarios = ToolWindow.Open();
step++;
return;
case 3:
if (!palette.IsInsideTree() || !scenarios.IsInsideTree())
return;
scenarios.OpenScenario(ScenarioKey);
palette.Choose("action:stat.add");
palette.SetValue("stat", "str");
palette.SetValue("value", "2");
Check(palette.Line == "action stat.add stat=str value=2", $"the palette writes the line ({palette.Line})");
Check((palette.FindChild("Issues", true, false) as Label)?.Text.StartsWith("✔") == true, "and finds nothing wrong with it");
palette.SetValue("stat", "strenght");
Check((palette.FindChild("Issues", true, false) as Label)?.Text.Contains("✖") == true, "a misspelt stat is reported as an error");
palette.SetValue("stat", "str");
step++;
return;
case 4:
var tabs = scenarios.FindChildren("*", "", true, false).OfType().FirstOrDefault();
if (tabs == null || !tabs.HasActiveFile)
{
if (elapsed > 30)
Fail("the scenario editor did not open the seer's scenario");
else
return;
}
Check(palette.InsertIntoEditor(), "Insert puts the line into the open scenario");
Check(tabs?.CurrentText.Contains("action stat.add stat=str value=2") == true, "the editor's text has the line");
Check(ModFileService.ReadText(Mod, "scenarios/rpg_plus/events/seer.scenario")?.Contains("stat.add") == false, "nothing is written to disk before the author saves");
Cleanup();
Finish();
return;
}
}
private static void Type(Node root, string name, string text)
{
if (root?.FindChild(name, true, false) is not LineEdit edit)
return;
edit.Text = text;
edit.EmitSignal(LineEdit.SignalName.TextChanged, text);
}
private void Cleanup()
{
if (step < 0)
return;
foreach (var window in new Node[] { editor, palette, scenarios }.Where(w => w != null && IsInstanceValid(w)))
window.QueueFree();
var path = ModFileService.GetWritablePath(Mod, NewFile);
if (!newFileExisted && path != null && File.Exists(path))
File.Delete(path);
// The save created the folder too; only that empty folder of this run goes away.
var folder = path == null ? null : Path.GetDirectoryName(path);
if (!itemsDirExisted && folder != null && Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any())
Directory.Delete(folder);
foreach (var backup in backups)
ModFileService.WriteText(Mod, backup.Key, backup.Value);
ModManager.Instance.GetResolver()?.ClearCache();
ModManager.Instance.GetResolver()?.ClearCache();
GameEntityIndex.Refresh();
LocaleFileService.ReloadGame();
}
private void Check(bool condition, string what)
{
if (condition)
{
passed++;
GameLogger.Log("TEST", $"[PASS] {what}");
}
else
{
Fail(what);
}
}
private void Fail(string what)
{
failures.Add(what);
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
}
private void Finish()
{
step = -1;
GameLogger.Log("TEST", $"[TEST_RESULT] name=schema_tools_edit passed={passed} failed={failures.Count}");
GetTree().Quit(failures.Count == 0 ? 0 : 1);
}
}
}