init mod
This commit is contained in:
commit
370cc5bbbd
29 files changed
+3093
No files matched your search
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Inventory Test",
|
||||
"description": "Integration test mod: drives the inventory GUI end-to-end (open panel, seed items via public API, verify grid, click a cell, search filter, close) and reports [TEST_RESULT]. Activated by --itest=inventory user arg.",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"visual_novel_core>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.InventoryTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Драйвер интеграционного GUI-теста инвентаря. Проходит по реальному UI
|
||||
/// теми же контрактами, что и игрок: открывает панель через публичный
|
||||
/// <see cref="UiRegistry.Toggle"/> (тот же путь, что кнопка тулбара),
|
||||
/// сеет детерминированные предметы через публичный <c>IInventory.Add</c>,
|
||||
/// кликает ячейку эмуляцией Godot-сигнала <c>gui_input</c> (обработчик
|
||||
/// ячейки подписан на него — мод не может переопределять виртуальные
|
||||
/// методы, но сигналы из мод-сборок работают), фильтрует через
|
||||
/// <c>LineEdit</c>-сигнал <c>text_changed</c> и закрывает панель.
|
||||
///
|
||||
/// Машина состояний: один шаг за тик таймера (0.4с), чтобы отложенные
|
||||
/// пересборки сетки успевали примениться. Каждый шаг — отдельная
|
||||
/// проверка: исход фиксируется, провал не прерывает остальные. Итог —
|
||||
/// контракт раннера <c>[TEST_RESULT] name=inventory_test passed=N
|
||||
/// failed=M</c> и код выхода процесса. Общий watchdog
|
||||
/// (<c>--timeout=</c>, по умолчанию 60с) гасит зависание с failed=1 —
|
||||
/// тем же контрактом, что и у DialogAutoDriverNode.
|
||||
/// </summary>
|
||||
public partial class InventoryTestDriverNode : Node
|
||||
{
|
||||
private const string ResultName = "inventory_test";
|
||||
private const string PanelEntryId = "inventory";
|
||||
private const float TickSeconds = 0.4f;
|
||||
private const float DefaultTimeoutSeconds = 60f;
|
||||
|
||||
private readonly float? timeoutSeconds;
|
||||
private readonly List<string> failures = new();
|
||||
private int passed;
|
||||
private int failed;
|
||||
private int step;
|
||||
private double elapsed;
|
||||
|
||||
private Control panel;
|
||||
private Control gridContent;
|
||||
private LineEdit search;
|
||||
|
||||
/// <summary>Создаёт драйвер с watchdog-таймаутом (null — дефолт 60с) и ставит тикающий таймер-ребёнок.</summary>
|
||||
public InventoryTestDriverNode(float? timeoutSeconds)
|
||||
{
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
var timer = new Timer
|
||||
{
|
||||
Name = "TickTimer",
|
||||
WaitTime = TickSeconds,
|
||||
Autostart = true,
|
||||
OneShot = false
|
||||
};
|
||||
AddChild(timer);
|
||||
timer.Timeout += Tick;
|
||||
}
|
||||
|
||||
/// <summary>Один шаг машины состояний: ожидание готовности, затем проверки по очереди.</summary>
|
||||
private void Tick()
|
||||
{
|
||||
if (step < 0)
|
||||
return; // тест уже завершён
|
||||
|
||||
elapsed += TickSeconds;
|
||||
var limit = timeoutSeconds ?? DefaultTimeoutSeconds;
|
||||
if (elapsed >= limit)
|
||||
{
|
||||
Finish($"timeout ({limit:0}s) reached at step {step}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RunStep();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
failures.Add($"step {step}: unhandled {ex.GetType().Name}: {ex.Message}");
|
||||
failed++;
|
||||
Log($"[FAIL] step {step}: {ex.Message}");
|
||||
Finish(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
// Шаг 0: ждём готовности — бут завершён и сцена вошла в дерево.
|
||||
if (step == 0)
|
||||
{
|
||||
var ready = Boot.IsReady
|
||||
&& GameData.Instance?.Runtime != null
|
||||
&& (Godot.Engine.GetMainLoop() as SceneTree)?.CurrentScene != null;
|
||||
if (!ready)
|
||||
return; // ждём дальше, watchdog страхует
|
||||
Log("environment ready (boot complete, scene entered)");
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 1: UI-запись инвентаря зарегистрирована модом visual_novel_core.
|
||||
if (step == 1)
|
||||
{
|
||||
Check("entry registered", () =>
|
||||
{
|
||||
var entry = AppServices.Current.Ui.GetById(PanelEntryId);
|
||||
if (entry == null)
|
||||
throw new CheckException($"ui entry '{PanelEntryId}' not found");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 2: сеем детерминированные предметы через публичный API
|
||||
// инвентаря (в этой сессии; сохранения тест не пишет).
|
||||
if (step == 2)
|
||||
{
|
||||
Check("inventory seed via IInventory.Add", () =>
|
||||
{
|
||||
var inv = GameData.Instance.Runtime.Inventory;
|
||||
if (inv == null)
|
||||
throw new CheckException("Runtime.Inventory is null (items mod factory missing?)");
|
||||
inv.Add("food/ration", 3);
|
||||
inv.Add("food/wolf_meat", 2);
|
||||
if (inv.CheckCount("food/ration") < 3)
|
||||
throw new CheckException("food/ration count < 3 after Add");
|
||||
if (inv.CheckCount("food/wolf_meat") < 2)
|
||||
throw new CheckException("food/wolf_meat count < 2 after Add");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 3: открываем панель тем же вызовом, что и кнопка тулбара.
|
||||
if (step == 3)
|
||||
{
|
||||
Check("open via UiRegistry.Toggle", () =>
|
||||
{
|
||||
UiRegistry.Toggle(PanelEntryId);
|
||||
if (!UiHost.Instance.TryGetInstance(PanelEntryId, out panel) || panel == null)
|
||||
throw new CheckException("panel instance not created by Toggle");
|
||||
if (!panel.Visible)
|
||||
throw new CheckException("panel not visible after Toggle");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 4: сетка отрисовала засеянные предметы.
|
||||
if (step == 4)
|
||||
{
|
||||
Check("grid renders seeded items", () =>
|
||||
{
|
||||
gridContent = panel.FindChild("GridContent", true, false) as Control;
|
||||
if (gridContent == null)
|
||||
throw new CheckException("GridContent node not found");
|
||||
// Один кадр на пересборку после Add: если ячейки ещё не
|
||||
// успели, тик-ждать нельзя (шаг уже выполняется), поэтому
|
||||
// сравниваем с запасом на асинхронность через событие Change,
|
||||
// которое панель обрабатывает синхронно.
|
||||
if (gridContent.GetChildCount() < 2)
|
||||
throw new CheckException($"grid has {gridContent.GetChildCount()} cell(s), expected >= 2");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 5: клик по первой ячейке — эмуляция gui_input, как будто
|
||||
// щёлкнул игрок; панель выбирает предмет и наполняет паспорт.
|
||||
if (step == 5)
|
||||
{
|
||||
Check("cell click fills passport", () =>
|
||||
{
|
||||
var cell = gridContent.GetChildCount() > 0 ? gridContent.GetChild(0) as Control : null;
|
||||
if (cell == null)
|
||||
throw new CheckException("no cell to click");
|
||||
var click = new InputEventMouseButton
|
||||
{
|
||||
ButtonIndex = MouseButton.Left,
|
||||
Pressed = true
|
||||
};
|
||||
cell.EmitSignal("gui_input", click);
|
||||
|
||||
var passport = panel.FindChild("Passport", true, false);
|
||||
if (passport == null)
|
||||
throw new CheckException("Passport node not found");
|
||||
var hasText = false;
|
||||
foreach (var child in passport.FindChildren("*", "Label", true, false))
|
||||
{
|
||||
if (child is Label l && !string.IsNullOrWhiteSpace(l.Text))
|
||||
{
|
||||
hasText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasText)
|
||||
throw new CheckException("passport has no non-empty label after selection");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 6: поиск-фильтр через сигнал LineEdit. Старые ячейки сетка
|
||||
// удаляет через QueueFree — они исчезают лишь в конце кадра, поэтому
|
||||
// в этом же тике проверяем синхронный индикатор ShownLabel
|
||||
// ("показано 0"), а количество нод — на следующем тике.
|
||||
if (step == 6)
|
||||
{
|
||||
Check("no-match filter zeroes the shown counter", () =>
|
||||
{
|
||||
search = panel.FindChild("Search", true, false) as LineEdit;
|
||||
if (search == null)
|
||||
throw new CheckException("Search LineEdit not found");
|
||||
|
||||
search.Text = "zzz_no_such_item";
|
||||
search.EmitSignal("text_changed", search.Text);
|
||||
|
||||
var shown = panel.FindChild("ShownLabel", true, false) as Label;
|
||||
var text = shown?.Text ?? "";
|
||||
if (!text.Contains("0"))
|
||||
throw new CheckException($"ShownLabel '{text}' does not show zero after no-match filter");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 7: кадр прошёл — ячейки после no-match фильтра реально
|
||||
// удалены из дерева; затем возвращаем фильтр в пустое значение.
|
||||
if (step == 7)
|
||||
{
|
||||
Check("grid empties after filter, restores after reset", () =>
|
||||
{
|
||||
var after = gridContent.GetChildCount();
|
||||
if (after != 0)
|
||||
throw new CheckException($"grid has {after} cell(s) after no-match filter, expected 0");
|
||||
|
||||
search.Text = "";
|
||||
search.EmitSignal("text_changed", search.Text);
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 8: сетка восстановлена; повторный Toggle закрывает панель.
|
||||
if (step == 8)
|
||||
{
|
||||
Check("grid restored and second Toggle closes", () =>
|
||||
{
|
||||
var restored = gridContent.GetChildCount();
|
||||
if (restored < 2)
|
||||
throw new CheckException($"grid has {restored} cell(s) after filter reset, expected >= 2");
|
||||
|
||||
UiRegistry.Toggle(PanelEntryId);
|
||||
if (panel.Visible)
|
||||
throw new CheckException("panel still visible after second Toggle");
|
||||
});
|
||||
Finish(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void Next()
|
||||
{
|
||||
step++;
|
||||
}
|
||||
|
||||
private void Check(string title, System.Action body)
|
||||
{
|
||||
var sw = Godot.Time.GetTicksUsec();
|
||||
try
|
||||
{
|
||||
body();
|
||||
passed++;
|
||||
Log($"[PASS] {title} ({(Godot.Time.GetTicksUsec() - sw) / 1000.0:0.0} ms)");
|
||||
}
|
||||
catch (CheckException ex)
|
||||
{
|
||||
failed++;
|
||||
failures.Add($"{title}: {ex.Message}");
|
||||
Log($"[FAIL] {title}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Печатает итог и завершает процесс; после вызова шаги не выполняются.</summary>
|
||||
private void Finish(string reason)
|
||||
{
|
||||
if (step < 0)
|
||||
return;
|
||||
step = -1;
|
||||
|
||||
if (reason != null)
|
||||
{
|
||||
failed++;
|
||||
Log($"[FAIL] {reason}");
|
||||
}
|
||||
foreach (var f in failures)
|
||||
Log($" failed: {f}");
|
||||
Log($"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
|
||||
GetTree().Quit(failed == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
GameLogger.Log("TEST", $"InventoryTest: {message}");
|
||||
}
|
||||
|
||||
/// <summary>Отличает ожидаемую проверку от неожиданного исключения шага.</summary>
|
||||
private sealed class CheckException : System.Exception
|
||||
{
|
||||
public CheckException(string message) : base(message) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.InventoryTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Точка входа интеграционного теста инвентаря (прецедент live2d_test:
|
||||
/// мод сам везёт драйвер и сам печатает [TEST_RESULT]). Активация —
|
||||
/// только по явному пользовательскому флагу <c>--itest=inventory</c>
|
||||
/// (user-args после --), чтобы мод никогда не вмешивался в обычную игру и
|
||||
/// в другие смоук-прогоны. Само переключение сцен выполняет ядро
|
||||
/// (<c>--scene=</c>), драйверу остаётся только дождаться готового шелла.
|
||||
/// Драйвер ставится в корень дерева отложенно: <c>OnModResourcesReady</c>
|
||||
/// срабатывает в фазе Scenes, до окончания бута и до входа в сцену.
|
||||
/// </summary>
|
||||
public class InventoryTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool driverQueued;
|
||||
|
||||
/// <summary>Per-mod инициализация не требуется — драйвер ставится на resources-ready.</summary>
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает user-args; при активационном флаге добавляет драйвер
|
||||
/// теста в корень дерева. Повторный <c>ModManager.Reload()</c> не
|
||||
/// должен плодить драйверы — защищено статическим флагом.
|
||||
/// </summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (driverQueued)
|
||||
return;
|
||||
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
// Ровно свой флаг: широкая проверка StartsWith("--itest=") активировала
|
||||
// бы этот тест и при запуске чужого itest-мода, а его драйвер квитит
|
||||
// процесс в конце — второй тест не успевал бы отработать.
|
||||
if (!args.Any(a => a == "--itest=inventory"))
|
||||
return;
|
||||
|
||||
var timeout = ParseTimeout(args);
|
||||
driverQueued = true;
|
||||
GameLogger.Log("TEST", $"InventoryTestInitializer: activating inventory GUI test, timeout={timeout?.ToString() ?? "none"}s");
|
||||
|
||||
// Deferred: сцена ещё не готова; драйвер сам ждёт Boot.IsReady и
|
||||
// появления CurrentScene, поэтому порядок с загрузкой сцены не важен.
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new InventoryTestDriverNode(timeout));
|
||||
}
|
||||
|
||||
private static float? ParseTimeout(string[] args)
|
||||
{
|
||||
var raw = args
|
||||
.Where(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--timeout=".Length))
|
||||
.FirstOrDefault();
|
||||
return float.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, out var t) && t > 0
|
||||
? t
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user