Improve collection graph controls, version rules and help

This commit is contained in:
cthangover committed 2026-10-26 20:22:58 +03:00
1 parent b6c61ea3df
commit 7ae31f180c
24 files changed
+2201 -311

No files matched your search

+466 -52
View File
@@ -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 &amp; 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=&lt;dir&gt;</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=&lt;dir&gt;</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=&lt;dir&gt;</c> saves the window as <paramref name="name"/>.png (windowed runs only).</summary>
private void Shot(string name)
/// <summary>With <c>--shots=&lt;dir&gt;</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);