Improve collection graph controls, version rules and help
This commit is contained in:
1 parent
b6c61ea3df
commit
7ae31f180c
24 files changed
+2201
-311
No files matched your search
@@ -6,19 +6,25 @@ using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.UI.Menu;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Interface.Menu;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Interface.View;
|
||||
using Godot;
|
||||
using Mods.ModManager;
|
||||
|
||||
namespace Mods.ModManagerHelpTest
|
||||
{
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods;
|
||||
/// <summary>
|
||||
/// Walks the Mods & Collections hub like a player who hovers over everything: the mods grid, a mod's
|
||||
/// page, the games gallery, the scheme editor (with a mechanic and then a mod selected) and the help
|
||||
/// panel of each level. On every level each visible button, toggle, list and input must carry a tooltip,
|
||||
/// and no tooltip may sit on a control that ignores the mouse (Godot never shows it there). One step
|
||||
/// per tick, so deferred layout settles. <c>--shots=<dir></c> saves every level.
|
||||
/// Prints <c>[TEST_RESULT] name=mod_help</c>.
|
||||
/// Exercises contextual help as a player uses it: pin a hint beside a mod search,
|
||||
/// keep the hub interactive while help is open, inspect a mod page and the scheme
|
||||
/// legend, invoke F1 from keyboard focus and leave with Escape without navigating
|
||||
/// away. Also rejects tooltips that merely repeat their button captions and
|
||||
/// exercises Graphite's scoped version edits, duplicate occurrences, zoom and
|
||||
/// overview, shared dialog chrome and title-bar close hit areas. Collection
|
||||
/// edits remain drafts and the user's layout is restored.
|
||||
/// <c>--shots=<dir></c> saves the inspected states. Prints mod_help's result.
|
||||
/// </summary>
|
||||
public partial class ModHelpTestDriverNode : Node
|
||||
{
|
||||
@@ -33,6 +39,11 @@ namespace Mods.ModManagerHelpTest
|
||||
private double stepAt;
|
||||
private bool done;
|
||||
private ModHubScreen hub;
|
||||
private ModCollectionsScreen collectionEditor;
|
||||
private CollectionSchemeCanvas scheme;
|
||||
private Vector2 firstOccurrencePosition;
|
||||
private string layoutPath;
|
||||
private byte[] layoutBackup;
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
@@ -84,29 +95,53 @@ namespace Mods.ModManagerHelpTest
|
||||
break;
|
||||
|
||||
case 1:
|
||||
{
|
||||
PressTab("mod_manager/hub_tab_mods");
|
||||
CheckLevel("mods");
|
||||
PressHelp();
|
||||
ModHelpPanel help = Help();
|
||||
Check("the header enables help mode", help != null && help.Visible);
|
||||
Check("help has no dim backdrop", help != null && help.GetNodeOrNull("Dim") == null);
|
||||
Check("help does not capture the mouse", help != null && help.MouseFilter == Control.MouseFilterEnum.Ignore);
|
||||
Control search = Annotated("mod_search");
|
||||
CheckHelp("mods_search", search);
|
||||
break;
|
||||
}
|
||||
|
||||
case 2:
|
||||
CheckLevel("mods");
|
||||
{
|
||||
ModHelpPanel help = Help();
|
||||
Check("a shown hint is pinned", help != null && help.IsPinned);
|
||||
var card = hub.FindChildren("*", "", true, false).OfType<ModCard>().FirstOrDefault(c => c.IsVisibleInTree());
|
||||
Check("the grid shows mod cards", card != null);
|
||||
card?.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 3:
|
||||
{
|
||||
Check("the hub stays interactive while help is open", hub.FindChildren("*", "", true, false).OfType<ModDetailView>().Any(d => d.IsVisibleInTree()));
|
||||
CheckLevel("mod_page");
|
||||
CheckHelp("mod_version", Annotated("mod_version"));
|
||||
PressTab("mod_manager/hub_tab_collections");
|
||||
break;
|
||||
}
|
||||
|
||||
case 4:
|
||||
{
|
||||
Check("collections open without closing help", Gallery() != null && Gallery().IsVisibleInTree() && Help().Visible);
|
||||
CheckLevel("gallery");
|
||||
EscapeHelp();
|
||||
hub.OpenCollectionEditor(GameId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 5:
|
||||
{
|
||||
CheckLevel("scheme");
|
||||
Control legend = Annotated("load_order");
|
||||
Check("the scheme always shows its load-order legend", legend != null && legend.IsVisibleInTree());
|
||||
CheckHelp("scheme_load_order", legend);
|
||||
var group = hub.FindChildren("*", "", true, false).OfType<CollectionGroupCard>().FirstOrDefault(g => g.IsVisibleInTree());
|
||||
Check("the scheme shows mechanics", group != null);
|
||||
Click(group);
|
||||
@@ -123,31 +158,184 @@ namespace Mods.ModManagerHelpTest
|
||||
}
|
||||
|
||||
case 7:
|
||||
{
|
||||
CheckLevel("scheme_mod");
|
||||
PressHelp();
|
||||
Control links = Annotated("collection_links");
|
||||
Check("the links toggle has keyboard focus", links != null);
|
||||
links?.GrabFocus();
|
||||
hub._UnhandledInput(new InputEventKey { Keycode = Key.F1, Pressed = true });
|
||||
ModHelpPanel help = Help();
|
||||
Check("F1 explains the focused control", help != null && help.CurrentTarget == links && help.IsPinned);
|
||||
Shot("scheme_f1");
|
||||
break;
|
||||
}
|
||||
|
||||
case 8:
|
||||
CheckHelp("help_scheme");
|
||||
PressTab("mod_manager/hub_tab_collections");
|
||||
// Leave the F1 state visible for the deferred screenshot.
|
||||
EscapeHelp();
|
||||
Check("Escape leaves help without closing the editor", Annotated("load_order") != null);
|
||||
hub.OpenCollectionEditor("cthangover");
|
||||
break;
|
||||
|
||||
case 9:
|
||||
PressHelp();
|
||||
collectionEditor = FindControl<ModCollectionsScreen>(hub);
|
||||
scheme = FindControl<CollectionSchemeCanvas>(collectionEditor);
|
||||
Check("graphite has the existing native canvas", scheme != null);
|
||||
Check("graphite has a scene-defined version form", collectionEditor?.FindChild("VersionEditor", true, false) != null);
|
||||
Check("graphite has a visible overview", FindControl<CollectionMiniMap>(collectionEditor)?.Size.X > 0);
|
||||
CheckSourceSyntax();
|
||||
if (GameEnvironment.IsRuntime)
|
||||
{
|
||||
layoutPath = ProjectSettings.GlobalizePath("user://collection_layout.json");
|
||||
layoutBackup = File.Exists(layoutPath) ? File.ReadAllBytes(layoutPath) : null;
|
||||
}
|
||||
Shot("graphite_01_collection");
|
||||
break;
|
||||
|
||||
case 10:
|
||||
CheckHelp("help_collections");
|
||||
PressTab("mod_manager/hub_tab_mods");
|
||||
SetGraphiteFixture();
|
||||
break;
|
||||
|
||||
case 11:
|
||||
PressHelp();
|
||||
var second = FindOccurrence("m:cthangover:second");
|
||||
firstOccurrencePosition = FindOccurrence("m:cthangover:first").Position;
|
||||
Click(second);
|
||||
Check("shared mod selection retains its clicked group", scheme.SelectedGroupKey == "m:cthangover:second");
|
||||
Check("version form reads the selected occurrence's pin", VersionField<LineEdit>("Value").Text == "1");
|
||||
Shot("graphite_02_shared_context");
|
||||
break;
|
||||
|
||||
case 12:
|
||||
CheckHelp("help_mods");
|
||||
hub.QueueFree();
|
||||
SelectVersionPolicy("=");
|
||||
VersionField<LineEdit>("Value").Text = "999.0.0";
|
||||
PressEditorButton("SaveRule");
|
||||
Shot("graphite_03_version_conflict");
|
||||
break;
|
||||
|
||||
case 13:
|
||||
Check("incompatible version blocks apply", collectionEditor.FindChild("Apply", true, false) is Button apply && apply.Disabled);
|
||||
PressEditorButton("Json");
|
||||
Shot("graphite_06_json_dialog");
|
||||
break;
|
||||
|
||||
case 14:
|
||||
var jsonDialog = FindControl<ConfirmationDialog>(collectionEditor);
|
||||
CheckDialogChrome("JSON", jsonDialog);
|
||||
var jsonText = FindControl<TextEdit>(jsonDialog);
|
||||
Check("collection JSON uses a highlighted code editor", jsonText is CodeEdit && jsonText.SyntaxHighlighter != null);
|
||||
var edited = JsonSerializer.Deserialize<ModCollection>(jsonText.Text);
|
||||
Check("version edit preserves the other group", edited.Mechanics[0].Mods[0] == "ui_base>=0");
|
||||
Check("version edit survives the real JSON editor", edited.Mechanics[1].Mods[0] == "ui_base=999.0.0");
|
||||
jsonDialog.EmitSignal(ConfirmationDialog.SignalName.Canceled);
|
||||
break;
|
||||
|
||||
case 15:
|
||||
SelectVersionPolicy(">=");
|
||||
VersionField<LineEdit>("Value").Text = "1";
|
||||
PressEditorButton("SaveRule");
|
||||
PressEditorButton("TabProblems");
|
||||
Check("restoring a compatible pin clears problems", HasLabel(collectionEditor, TranslationServer.Translate("mod_manager/collection_problems_none")));
|
||||
PressEditorButton("TabInspector");
|
||||
var zoom = scheme.Zoom;
|
||||
PressEditorButton("ZoomOut");
|
||||
Check("zoom updates the view scale", scheme.Zoom < zoom);
|
||||
Check("zoom preserves logical chip positions", FindOccurrence("m:cthangover:first").Position == firstOccurrencePosition);
|
||||
break;
|
||||
|
||||
case 16:
|
||||
var movedChip = FindOccurrence("m:cthangover:second");
|
||||
var beforeMove = movedChip.Position;
|
||||
MoveOccurrence(movedChip, new Vector2(12f, 4f));
|
||||
var stored = CollectionLayoutStore.Load("cthangover");
|
||||
var position = stored.Mods[movedChip.GroupKey + "|" + movedChip.ModId];
|
||||
Check("drag persists the selected occurrence", Mathf.IsEqualApprox(position.X, beforeMove.X + 12f));
|
||||
Check("drag leaves the first occurrence unchanged", FindOccurrence("m:cthangover:first").Position == firstOccurrencePosition);
|
||||
scheme.CenterOn(new Vector2(2000f, 1500f));
|
||||
var beforePan = scheme.VisibleBounds.Position;
|
||||
var map = FindControl<CollectionMiniMap>(collectionEditor);
|
||||
map.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton
|
||||
{
|
||||
ButtonIndex = MouseButton.Left, Pressed = true, Position = map.Size / 2f,
|
||||
});
|
||||
Check("overview click moves the scroll viewport", scheme.VisibleBounds.Position != beforePan);
|
||||
break;
|
||||
|
||||
case 17:
|
||||
PressEditorButton("Fit");
|
||||
Shot("graphite_04_layout");
|
||||
break;
|
||||
|
||||
case 18:
|
||||
SetIncludedFixture();
|
||||
break;
|
||||
|
||||
case 19:
|
||||
Click(FindOccurrence("i:cthangover:dev_tools>=1", "editor_tools"));
|
||||
SelectVersionPolicy("=");
|
||||
VersionField<LineEdit>("Value").Text = "not a version?";
|
||||
PressEditorButton("SaveRule");
|
||||
Check("invalid input is explained", VersionField<Label>("Feedback").Text == TranslationServer.Translate("mod_manager/collection_version_invalid"));
|
||||
PressEditorButton("Json");
|
||||
break;
|
||||
|
||||
case 20:
|
||||
var invalidDialog = FindControl<ConfirmationDialog>(collectionEditor);
|
||||
var unchanged = JsonSerializer.Deserialize<ModCollection>(FindControl<TextEdit>(invalidDialog).Text);
|
||||
Check("invalid input does not fork or repoint an included block", unchanged.Includes[0].Ref == "dev_tools>=1");
|
||||
invalidDialog.EmitSignal(ConfirmationDialog.SignalName.Canceled);
|
||||
break;
|
||||
|
||||
case 21:
|
||||
SelectVersionPolicy("=");
|
||||
VersionField<LineEdit>("Value").Text = ModRegistry.Instance.AllDiscoveredMods["editor_tools"].Version;
|
||||
PressEditorButton("SaveRule");
|
||||
Check("valid edit retains selection in the per-game block copy", scheme.SelectedGroupKey == "i:cthangover:dev_tools.cthangover");
|
||||
PressEditorButton("Json");
|
||||
break;
|
||||
|
||||
case 22:
|
||||
var copiedDialog = FindControl<ConfirmationDialog>(collectionEditor);
|
||||
var copied = JsonSerializer.Deserialize<ModCollection>(FindControl<TextEdit>(copiedDialog).Text);
|
||||
Check("valid edit repoints only this game's include", copied.Includes[0].Ref == "dev_tools.cthangover");
|
||||
copiedDialog.EmitSignal(ConfirmationDialog.SignalName.Canceled);
|
||||
break;
|
||||
|
||||
case 23:
|
||||
Shot("graphite_05_included_version");
|
||||
break;
|
||||
|
||||
case 24:
|
||||
SetGraphiteFixture();
|
||||
break;
|
||||
|
||||
case 25:
|
||||
var renameCard = FindControl<CollectionGroupCard>(collectionEditor);
|
||||
Click(renameCard);
|
||||
break;
|
||||
|
||||
case 26:
|
||||
var rename = Buttons(collectionEditor).OfType<Button>().First(b => b.IsVisibleInTree() && b.Text == TranslationServer.Translate("mod_manager/collection_rename"));
|
||||
rename.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
Shot("graphite_07_rename_dialog");
|
||||
break;
|
||||
|
||||
case 27:
|
||||
var renameDialog = FindControl<AcceptDialog>(collectionEditor);
|
||||
CheckDialogChrome("rename", renameDialog);
|
||||
ClickDialogClose(renameDialog);
|
||||
break;
|
||||
|
||||
case 28:
|
||||
Check("title-bar close dismisses rename", FindControl<AcceptDialog>(collectionEditor) == null);
|
||||
PressEditorButton("Json");
|
||||
break;
|
||||
|
||||
case 29:
|
||||
ClickDialogClose(FindControl<ConfirmationDialog>(collectionEditor));
|
||||
break;
|
||||
|
||||
case 30:
|
||||
Check("title-bar close dismisses JSON", FindControl<ConfirmationDialog>(collectionEditor) == null);
|
||||
Finish();
|
||||
break;
|
||||
}
|
||||
@@ -155,7 +343,188 @@ namespace Mods.ModManagerHelpTest
|
||||
step++;
|
||||
}
|
||||
|
||||
/// <summary>A left click on the frame of a scheme mechanic or mod chip (their first panel takes the mouse).</summary>
|
||||
private void CheckSourceSyntax()
|
||||
{
|
||||
var editor = new TextEdit { Visible = false };
|
||||
collectionEditor.AddChild(editor);
|
||||
editor.SyntaxHighlighter = SourceSyntax.CreateJsonHighlighter();
|
||||
editor.Text = "{\"name\": \"value # ; 123\", \"enabled\": true, \"count\": -2.5, \"none\": null}";
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf("value", StringComparison.Ordinal), UiTokens.AccentBright, "JSON string");
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf('#'), UiTokens.AccentBright, "JSON comment marker inside a string");
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf("true", StringComparison.Ordinal), UiTokens.TypeTarget, "JSON boolean");
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf("2.5", StringComparison.Ordinal), UiTokens.Info, "JSON number");
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf("null", StringComparison.Ordinal), UiTokens.TypeTarget, "JSON null");
|
||||
editor.Text = "{\"value\": \"a\\\"#b\", \"enabled\": false}";
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf('#'), UiTokens.AccentBright, "escaped JSON quote");
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf("false", StringComparison.Ordinal), UiTokens.TypeTarget, "JSON literal after an escaped string");
|
||||
editor.Text = "{\"unfinished\": \"text";
|
||||
CheckSyntaxColor(editor, 0, editor.Text.IndexOf("text", StringComparison.Ordinal), UiTokens.AccentBright, "unfinished JSON after editing");
|
||||
|
||||
editor.SyntaxHighlighter = SourceSyntax.CreateTscnHighlighter();
|
||||
editor.Text = "[gd_scene load_steps=2 format=3]\n[node name=\"Control\" type=\"Control\"]\nposition = Vector2(12, -4)\n; comment 7";
|
||||
CheckSyntaxColor(editor, 0, 1, UiTokens.TypeTarget, "TSCN section");
|
||||
CheckSyntaxColor(editor, 1, 12, UiTokens.AccentBright, "TSCN string");
|
||||
CheckSyntaxColor(editor, 2, 11, UiTokens.TypeTarget, "TSCN constructor");
|
||||
CheckSyntaxColor(editor, 2, 19, UiTokens.Info, "TSCN number");
|
||||
CheckSyntaxColor(editor, 3, 2, UiTokens.Faint, "TSCN comment");
|
||||
|
||||
editor.SyntaxHighlighter = SourceSyntax.CreateScenarioHighlighter(new[] { "mod_command" });
|
||||
editor.Text = "mod_command target=\"res://#path\" wait=2\n# comment\n:next\noption \"Continue\" -> :next";
|
||||
CheckSyntaxColor(editor, 0, 0, UiTokens.Info, "registered scenario command");
|
||||
CheckSyntaxColor(editor, 0, 23, UiTokens.AccentBright, "scenario path inside a string");
|
||||
CheckSyntaxColor(editor, 0, 33, UiTokens.Accent, "scenario named argument");
|
||||
CheckSyntaxColor(editor, 1, 2, UiTokens.Faint, "scenario comment");
|
||||
CheckSyntaxColor(editor, 2, 1, UiTokens.TypeTarget, "scenario label");
|
||||
CheckSyntaxColor(editor, 3, 0, UiTokens.Info, "scenario option");
|
||||
Check("highlighting preserves source text", editor.Text == "mod_command target=\"res://#path\" wait=2\n# comment\n:next\noption \"Continue\" -> :next");
|
||||
editor.Free();
|
||||
}
|
||||
|
||||
private void CheckSyntaxColor(TextEdit editor, int line, int column, Color expected, string token)
|
||||
{
|
||||
var spans = editor.SyntaxHighlighter.GetLineSyntaxHighlighting(line);
|
||||
var nearest = -1;
|
||||
var actual = Colors.Transparent;
|
||||
foreach (var key in spans.Keys)
|
||||
{
|
||||
var start = key.AsInt32();
|
||||
if (start > column || start < nearest)
|
||||
continue;
|
||||
nearest = start;
|
||||
actual = spans[key].AsGodotDictionary()["color"].AsColor();
|
||||
}
|
||||
Check(token + " is highlighted", actual.IsEqualApprox(expected));
|
||||
}
|
||||
|
||||
private void CheckDialogChrome(string name, AcceptDialog dialog)
|
||||
{
|
||||
var icon = dialog.GetThemeIcon("close", "Window");
|
||||
var top = dialog.GetThemeConstant("close_v_offset", "Window");
|
||||
var right = dialog.GetThemeConstant("close_h_offset", "Window");
|
||||
Check(name + ": close icon fits entirely inside the title bar",
|
||||
top >= icon.GetHeight() && top <= dialog.GetThemeConstant("title_height", "Window")
|
||||
&& right >= icon.GetWidth());
|
||||
var frame = (StyleBoxFlat)dialog.GetThemeStylebox("embedded_border", "Window");
|
||||
Check(name + ": rounded outer corners clear the opaque content viewport",
|
||||
frame.ExpandMarginTop >= dialog.GetThemeConstant("title_height", "Window")
|
||||
&& frame.ExpandMarginLeft >= frame.CornerRadiusBottomLeft
|
||||
&& frame.ExpandMarginRight >= frame.CornerRadiusBottomRight
|
||||
&& frame.ExpandMarginBottom >= Math.Max(frame.CornerRadiusBottomLeft, frame.CornerRadiusBottomRight));
|
||||
}
|
||||
|
||||
private void ClickDialogClose(AcceptDialog dialog)
|
||||
{
|
||||
// Headless has no rendered chrome to hit-test. Deliver the same
|
||||
// window-manager request there; the GPU run clicks the real icon.
|
||||
if (DisplayServer.GetName() == "headless")
|
||||
{
|
||||
dialog.Notification((int)NotificationWMCloseRequest);
|
||||
return;
|
||||
}
|
||||
var icon = dialog.GetThemeIcon("close", "Window");
|
||||
var point = (Vector2)dialog.Position + new Vector2(
|
||||
dialog.Size.X - dialog.GetThemeConstant("close_h_offset", "Window"),
|
||||
-dialog.GetThemeConstant("close_v_offset", "Window")) + icon.GetSize() / 2f;
|
||||
GetViewport().PushInput(new InputEventMouseButton { Position = point, ButtonIndex = MouseButton.Left, Pressed = true });
|
||||
GetViewport().PushInput(new InputEventMouseButton { Position = point, ButtonIndex = MouseButton.Left, Pressed = false });
|
||||
}
|
||||
|
||||
private void SetGraphiteFixture()
|
||||
{
|
||||
var fixture = new ModCollection
|
||||
{
|
||||
Id = "cthangover", Name = "Graphite selection check",
|
||||
Mechanics = new List<ModCollectionMechanic>
|
||||
{
|
||||
new() { Id = "first", Name = "First role", Role = "engine", Mods = new List<string> { "ui_base>=0" } },
|
||||
new() { Id = "second", Name = "Second role", Role = "extra", Mods = new List<string> { "ui_base>=1" } },
|
||||
},
|
||||
};
|
||||
PressEditorButton("Json");
|
||||
var dialog = FindControl<ConfirmationDialog>(collectionEditor);
|
||||
FindControl<TextEdit>(dialog).Text = JsonSerializer.Serialize(fixture);
|
||||
dialog.EmitSignal(ConfirmationDialog.SignalName.Confirmed);
|
||||
}
|
||||
|
||||
private void SetIncludedFixture()
|
||||
{
|
||||
var fixture = new ModCollection
|
||||
{
|
||||
Id = "cthangover", Name = "Graphite included version check",
|
||||
Includes = new List<ModCollectionInclude> { new() { Ref = "dev_tools>=1", Role = "tools" } },
|
||||
};
|
||||
PressEditorButton("Json");
|
||||
var dialog = FindControl<ConfirmationDialog>(collectionEditor);
|
||||
FindControl<TextEdit>(dialog).Text = JsonSerializer.Serialize(fixture);
|
||||
dialog.EmitSignal(ConfirmationDialog.SignalName.Confirmed);
|
||||
}
|
||||
|
||||
private CollectionModChip FindOccurrence(string group, string modId = "ui_base")
|
||||
{
|
||||
foreach (var child in scheme.FindChildren("*", "", true, false))
|
||||
{
|
||||
if (child is CollectionModChip chip && chip.GroupKey == group && chip.ModId == modId)
|
||||
return chip;
|
||||
}
|
||||
throw new InvalidOperationException("Mod occurrence is absent: " + group);
|
||||
}
|
||||
|
||||
private T VersionField<T>(string name) where T : Node
|
||||
=> collectionEditor.FindChild("VersionEditor", true, false)?.FindChild(name, true, false) as T;
|
||||
|
||||
private static T FindControl<T>(Node root) where T : Node
|
||||
{
|
||||
if (root == null)
|
||||
return null;
|
||||
foreach (var child in root.FindChildren("*", "", true, false))
|
||||
{
|
||||
if (child is T match)
|
||||
return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasLabel(Node root, string text)
|
||||
{
|
||||
foreach (var child in root.FindChildren("*", "", true, false))
|
||||
{
|
||||
if (child is Label label && label.Text.Contains(text, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void PressEditorButton(string name)
|
||||
{
|
||||
var button = collectionEditor.FindChild(name, true, false) as Button;
|
||||
if (button == null)
|
||||
throw new InvalidOperationException("Editor button is absent: " + name);
|
||||
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
}
|
||||
|
||||
private void SelectVersionPolicy(string op)
|
||||
{
|
||||
var policy = VersionField<OptionButton>("Policy");
|
||||
for (var i = 0; i < policy.ItemCount; i++)
|
||||
{
|
||||
if (policy.GetItemMetadata(i).AsString() != op)
|
||||
continue;
|
||||
policy.Selected = i;
|
||||
policy.EmitSignal(OptionButton.SignalName.ItemSelected, (long)i);
|
||||
return;
|
||||
}
|
||||
throw new InvalidOperationException("Version policy is absent: " + op);
|
||||
}
|
||||
|
||||
private static void MoveOccurrence(CollectionModChip chip, Vector2 delta)
|
||||
{
|
||||
var panel = FindControl<PanelContainer>(chip);
|
||||
panel.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
|
||||
panel.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseMotion { Relative = delta, ButtonMask = MouseButtonMask.Left });
|
||||
panel.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false });
|
||||
}
|
||||
|
||||
/// <summary>A left click on the frame of a scheme mechanic or mod chip.</summary>
|
||||
private static void Click(Control owner)
|
||||
{
|
||||
var panel = owner?.GetChildren().OfType<PanelContainer>().FirstOrDefault();
|
||||
@@ -175,57 +544,83 @@ namespace Mods.ModManagerHelpTest
|
||||
private void PressHelp()
|
||||
{
|
||||
var help = Buttons(hub).FirstOrDefault(b => b.Name == "HelpButton" && b.IsVisibleInTree());
|
||||
Check("the header offers help", help != null);
|
||||
Check("the header offers contextual help", help != null);
|
||||
help?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
}
|
||||
|
||||
private void CheckHelp(string level)
|
||||
private void EscapeHelp()
|
||||
{
|
||||
var panel = hub.FindChildren("*", "", true, false).OfType<ModHelpPanel>().FirstOrDefault(p => p.IsVisibleInTree());
|
||||
Check($"{level}: the help panel opens", panel != null);
|
||||
var body = panel?.FindChildren("*", "RichTextLabel", true, false).OfType<RichTextLabel>().FirstOrDefault();
|
||||
Check($"{level}: the help explains something", body != null && body.GetParsedText().Length > 200);
|
||||
Shot(level);
|
||||
CheckLevel(level);
|
||||
panel?.Close();
|
||||
hub._UnhandledInput(new InputEventAction { Action = "ui_cancel", Pressed = true });
|
||||
Check("Escape closes help", Help() != null && !Help().Visible);
|
||||
}
|
||||
|
||||
/// <summary>Every visible control the player can press, pick or type into explains itself on hover.</summary>
|
||||
private ModHelpPanel Help()
|
||||
=> hub.FindChildren("*", "", true, false).OfType<ModHelpPanel>().FirstOrDefault();
|
||||
|
||||
private CollectionsGallery Gallery()
|
||||
=> hub.FindChildren("*", "", true, false).OfType<CollectionsGallery>().FirstOrDefault();
|
||||
|
||||
private Control Annotated(string key)
|
||||
=> hub.FindChildren("*", "", true, false).OfType<Control>().FirstOrDefault(c
|
||||
=> c.IsVisibleInTree()
|
||||
&& c.HasMeta(ModHelpPanel.HintKeyMetadata)
|
||||
&& c.GetMeta(ModHelpPanel.HintKeyMetadata).AsString() == key);
|
||||
|
||||
private void CheckHelp(string level, Control target)
|
||||
{
|
||||
Check($"{level}: an annotated target exists", target != null);
|
||||
ModHelpPanel help = Help();
|
||||
if (help == null || target == null)
|
||||
return;
|
||||
|
||||
help.Open();
|
||||
help.ShowFor(target);
|
||||
Check($"{level}: the target is explained", help.CurrentTarget == target && help.IsPinned);
|
||||
|
||||
var card = help.GetNode<PanelContainer>("Card");
|
||||
var body = help.GetNode<RichTextLabel>("Card/Margin/Column/Body");
|
||||
var title = help.GetNode<Label>("Card/Margin/Column/Title");
|
||||
Check($"{level}: localized title and body are shown",
|
||||
card.Visible
|
||||
&& title.Text.Length > 0
|
||||
&& body.GetParsedText().Length > 20
|
||||
&& !title.Text.Contains("mod_manager/hint_")
|
||||
&& !body.Text.Contains("mod_manager/hint_"));
|
||||
|
||||
Rect2 cardRect = card.GetGlobalRect();
|
||||
Rect2 viewport = help.GetViewportRect();
|
||||
Check($"{level}: the card stays inside the viewport",
|
||||
cardRect.Position.X >= viewport.Position.X - 1f
|
||||
&& cardRect.Position.Y >= viewport.Position.Y - 1f
|
||||
&& cardRect.End.X <= viewport.End.X + 1f
|
||||
&& cardRect.End.Y <= viewport.End.Y + 1f);
|
||||
Check($"{level}: the card does not cover its target", !cardRect.Intersects(target.GetGlobalRect()));
|
||||
Shot(level);
|
||||
}
|
||||
|
||||
/// <summary>Tooltips must add information, not repeat visible captions or sit on mouse-ignoring controls.</summary>
|
||||
private void CheckLevel(string level)
|
||||
{
|
||||
Shot(level);
|
||||
var silent = new List<string>();
|
||||
var echoes = new List<string>();
|
||||
var deaf = new List<string>();
|
||||
foreach (var control in hub.FindChildren("*", "", true, false).OfType<Control>())
|
||||
{
|
||||
if (!control.IsVisibleInTree())
|
||||
continue;
|
||||
var tip = control.TooltipText;
|
||||
if (Interactive(control) && string.IsNullOrWhiteSpace(tip))
|
||||
silent.Add(Describe(control));
|
||||
if (control is Button button
|
||||
&& !string.IsNullOrWhiteSpace(button.Text)
|
||||
&& string.Equals(button.Text.Trim(), tip?.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
echoes.Add(Describe(control));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(tip) && control.MouseFilter == Control.MouseFilterEnum.Ignore)
|
||||
deaf.Add(Describe(control));
|
||||
}
|
||||
|
||||
Check($"{level}: every control has a tooltip" + (silent.Count > 0 ? ": missing on " + string.Join(", ", silent) : string.Empty), silent.Count == 0);
|
||||
Check($"{level}: no tooltip on a control that ignores the mouse" + (deaf.Count > 0 ? ": " + string.Join(", ", deaf) : string.Empty), deaf.Count == 0);
|
||||
}
|
||||
|
||||
private static bool Interactive(Control control)
|
||||
=> (control is BaseButton || control is LineEdit || control is TextEdit)
|
||||
&& control.MouseFilter != Control.MouseFilterEnum.Ignore
|
||||
&& !InsidePopup(control);
|
||||
|
||||
/// <summary>Items inside a popup menu follow the menu itself, not their own tooltips.</summary>
|
||||
private static bool InsidePopup(Control control)
|
||||
{
|
||||
for (Node parent = control.GetParent(); parent != null; parent = parent.GetParent())
|
||||
{
|
||||
if (parent is PopupMenu)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
Check($"{level}: no tooltip repeats its button" + (echoes.Count > 0 ? ": " + string.Join(", ", echoes) : string.Empty), echoes.Count == 0);
|
||||
Check($"{level}: no tooltip ignores the mouse" + (deaf.Count > 0 ? ": " + string.Join(", ", deaf) : string.Empty), deaf.Count == 0);
|
||||
}
|
||||
|
||||
private static string Describe(Control control)
|
||||
@@ -245,8 +640,8 @@ namespace Mods.ModManagerHelpTest
|
||||
private static string TextOf(BaseButton button)
|
||||
=> button is Button clickable ? clickable.Text : string.Empty;
|
||||
|
||||
/// <summary>With <c>--shots=<dir></c> saves the window as <paramref name="name"/>.png (windowed runs only).</summary>
|
||||
private void Shot(string name)
|
||||
/// <summary>With <c>--shots=<dir></c> saves the settled window as <paramref name="name"/>.png.</summary>
|
||||
private async void Shot(string name)
|
||||
{
|
||||
string dir = null;
|
||||
foreach (var arg in OS.GetCmdlineUserArgs())
|
||||
@@ -256,6 +651,13 @@ namespace Mods.ModManagerHelpTest
|
||||
}
|
||||
if (string.IsNullOrEmpty(dir) || DisplayServer.GetName() == "headless")
|
||||
return;
|
||||
|
||||
// ViewportCapture reads the last rendered frame. Wait until the new
|
||||
// target, highlight and card have actually been presented.
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
if (done && name != "scheme_f1")
|
||||
return;
|
||||
dir = ProjectSettings.GlobalizePath(dir);
|
||||
Directory.CreateDirectory(dir);
|
||||
ViewportCapture.SavePng(GetViewport(), Path.Combine(dir, name + ".png"));
|
||||
@@ -275,11 +677,23 @@ namespace Mods.ModManagerHelpTest
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
private async void Finish()
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
done = true;
|
||||
hub?.QueueFree();
|
||||
if (layoutPath != null)
|
||||
{
|
||||
if (layoutBackup == null)
|
||||
File.Delete(layoutPath);
|
||||
else
|
||||
File.WriteAllBytes(layoutPath, layoutBackup);
|
||||
}
|
||||
// Let queued scene teardown reach the renderer before terminating
|
||||
// the native window; otherwise GPU checks report live viewport RIDs.
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=mod_help passed={passed} failed={failures.Count}");
|
||||
foreach (var failure in failures)
|
||||
GameLogger.Log("TEST", $"ModHelpTest: failure: {failure}", LogLevel.Error);
|
||||
|
||||
Reference in new issue
Block a user