using System; using System.Collections.Generic; using System.IO; using System.Linq; 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; /// /// 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. /// --shots=<dir> saves the inspected states. Prints mod_help's result. /// public partial class ModHelpTestDriverNode : Node { private const string HubId = "mods_hub"; private const string GameId = "novel_template"; private const float TimeoutSeconds = 90f; private readonly List failures = new(); private int passed; private int step; private double elapsed; 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) { if (done) return; elapsed += delta; if (elapsed > TimeoutSeconds) { failures.Add($"timeout at step {step}"); Finish(); return; } if (elapsed - stepAt < 0.5) return; stepAt = elapsed; try { RunStep(); } catch (Exception ex) { failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}"); Finish(); } } private void RunStep() { switch (step) { case 0: if (!Boot.IsReady) return; if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcherPlay) { launcherPlay.EmitSignal(BaseButton.SignalName.Pressed); return; } if (GetTree().CurrentScene?.FindChild("play", true, false) is not Button) return; var entry = MenuRegistry.GetButtonById(HubId); hub = MenuBuilder.OpenScene((Control)GetTree().CurrentScene, entry?.ScenePath, HubId) as ModHubScreen; Check("the hub opens", hub != null); if (hub == null) Finish(); 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: { ModHelpPanel help = Help(); Check("a shown hint is pinned", help != null && help.IsPinned); var card = hub.FindChildren("*", "", true, false).OfType().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().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().FirstOrDefault(g => g.IsVisibleInTree()); Check("the scheme shows mechanics", group != null); Click(group); break; } case 6: { CheckLevel("scheme_mechanic"); var chip = hub.FindChildren("*", "", true, false).OfType().FirstOrDefault(c => c.IsVisibleInTree()); Check("the mechanic shows its mods", chip != null); Click(chip); break; } case 7: { CheckLevel("scheme_mod"); 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: // 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: collectionEditor = FindControl(hub); scheme = FindControl(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(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: SetGraphiteFixture(); break; case 11: 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("Value").Text == "1"); Shot("graphite_02_shared_context"); break; case 12: SelectVersionPolicy("="); VersionField("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(collectionEditor); CheckDialogChrome("JSON", jsonDialog); var jsonText = FindControl(jsonDialog); Check("collection JSON uses a highlighted code editor", jsonText is CodeEdit && jsonText.SyntaxHighlighter != null); var edited = JsonSerializer.Deserialize(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("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(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("Value").Text = "not a version?"; PressEditorButton("SaveRule"); Check("invalid input is explained", VersionField