diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 1fc0574..2837dba 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -364,16 +364,13 @@ jobs: '/d:sonar.host.url=https://sonarcloud.io' '/d:sonar.projectBaseDir=${{ github.workspace }}' '/d:sonar.cs.vscoveragexml.reportsPaths=coverage/**/coverage.xml' - # The editor's panel and tree files are excluded from COVERAGE only - they are still - # analysed for bugs, smells and security. They are pure immediate-mode draw code: - # a Show() method that reads a value, draws a widget and acts on what the widget - # reports, with nothing to assert that is not a pixel. SchemaEditor.Test now drives the - # rest of the editor headlessly, so the rest is measured; this list is what that - # harness does not yet reach, not the whole application. - # SchemaEditor/Program.cs is the one file here excluded because it cannot be executed - # rather than because nobody has yet: it holds only Main, which opens a window and does - # not return. What the host is configured with lives in EditorHost.cs, which is tested. - '/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs,SchemaEditor/Program.cs,SchemaEditor/ButtonTree.cs,SchemaEditor/ClassGraphView.cs,SchemaEditor/CodeGeneratorPanel.cs,SchemaEditor/SchemaEditor.Panels.cs,SchemaEditor/Tree*.cs' + # Nothing in the editor is excluded from coverage any more except its entry point. + # SchemaEditor.Test drives the panels, the trees and the class graph headlessly, so the + # draw code that used to be unmeasurable now runs in the suite. SchemaEditor/Program.cs + # stays out because it cannot be executed rather than because nobody has yet: it holds + # only Main, which opens a window and does not return. What the host is configured with + # lives in EditorHost.cs, which is tested. + '/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs,SchemaEditor/Program.cs' '/d:sonar.cs.vstest.reportsPaths=coverage/**/*.trx' '/d:sonar.exclusions=**/NativeExports.cs' ) diff --git a/Directory.Packages.props b/Directory.Packages.props index a024231..38cbeb5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/SchemaEditor.Test/ClassGraphTests.cs b/SchemaEditor.Test/ClassGraphTests.cs new file mode 100644 index 0000000..c5a4e86 --- /dev/null +++ b/SchemaEditor.Test/ClassGraphTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.SchemaEditor.Test; + +using ktsu.ImGui.App.Testing; +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; + +using SchemaTypes = ktsu.Schema.Models.Types; + +/// +/// The class graph, which draws the schema's classes and the references between them. +/// +/// +/// Driven through rather than through the editor, because the graph +/// lives behind a tab and the tab bar comes from a widget library that does not record its tabs - +/// so there is no name for a test to click. Drawing the view directly reaches the same code, and +/// is the same thing the editor's tab delegate does. +/// +[TestClass] +public sealed class ClassGraphTests +{ + private WidgetHarness harness = null!; + + [TestInitialize] + public void StartHarness() => harness = WidgetHarness.Start(); + + [TestCleanup] + public void StopHarness() => harness.Dispose(); + + /// + /// Two classes, one referencing the other, so the graph has both a node and an edge to draw. + /// + private static Schema BuildReferencingSchema() + { + Schema schema = new(); + SchemaClass user = schema.AddClass("User".As())!; + SchemaClass account = schema.AddClass("Account".As())!; + account.AddMember("Owner".As())!.SetType(new SchemaTypes.Object() { ClassName = user.Name }); + return schema; + } + + private int DrawnPixels() + { + CapturedFrame frame = harness.App.Capture(); + Rgba32 background = harness.App.Options.ClearColor; + return frame.CountPixels(p => p != background); + } + + [TestMethod] + public void TheGraphDrawsASchemaWithReferences() + { + ClassGraphView graph = new(); + Schema schema = BuildReferencingSchema(); + harness.Draw = () => graph.Show(schema, 1f / 60f); + + // The layout is force directed, so it settles over frames rather than in one. + harness.App.Step(30); + + Assert.IsTrue(DrawnPixels() > 0, "The graph rendered nothing at all."); + } + + /// + /// The graph is drawn every frame whether or not a schema is open, so the empty case is the + /// one that runs most often. + /// + [TestMethod] + public void TheGraphDrawsWithNoSchemaOpen() + { + ClassGraphView graph = new(); + harness.Draw = () => graph.Show(null, 1f / 60f); + + harness.App.Step(10); + + Assert.AreEqual(10 + 1, harness.App.FrameCount, "Frames stopped advancing, so a frame threw."); + } + + /// + /// A schema with nothing in it takes an early return that says so, rather than handing an + /// empty graph to the node editor. + /// + [TestMethod] + public void TheGraphSaysSoWhenThereIsNothingToShow() + { + ClassGraphView graph = new(); + Schema empty = new(); + harness.Draw = () => graph.Show(empty, 1f / 60f); + + harness.App.Step(10); + + Assert.AreEqual(10 + 1, harness.App.FrameCount, "Frames stopped advancing, so a frame threw."); + Assert.IsTrue(DrawnPixels() > 0, "The empty-schema message was not drawn."); + } +} diff --git a/SchemaEditor.Test/EditorHarness.cs b/SchemaEditor.Test/EditorHarness.cs index 62037ad..7be0342 100644 --- a/SchemaEditor.Test/EditorHarness.cs +++ b/SchemaEditor.Test/EditorHarness.cs @@ -84,6 +84,50 @@ internal void StepUntil(Func condition, string description, int maxFrames } } + /// + /// Waits for a marked item to be drawn, then clicks it. + /// + /// + /// The frames between the item first appearing and the click are not padding. A modal sizes + /// itself from its contents on the frame it appears and is centred on the next, so the + /// rectangle recorded for a control on its first frame is not where that control ends up; + /// clicking there hits the background instead. + /// + /// A marked name, or the trailing part of one. + internal void Click(string item) + { + StepUntil(() => App.Probe.Matches(item).Count > 0, $"'{item}' appearing"); + App.Step(3); + App.Click(item); + App.Step(2); + } + + /// + /// Right-clicks a marked item, which is how the tree opens an item's context menu. + /// + /// A marked name, or the trailing part of one. + internal void RightClick(string item) + { + StepUntil(() => App.Probe.Matches(item).Count > 0, $"'{item}' appearing"); + App.Step(3); + + Rectangle rect = App.Probe.Rect(item) ?? throw new AssertFailedException($"'{item}' was not recorded."); + App.Mouse.Click((rect.MinX + rect.MaxX) * 0.5f, (rect.MinY + rect.MaxY) * 0.5f, 1); + App.Step(3); + } + + /// + /// Types a value into a marked text field, replacing whatever it holds. + /// + /// A marked name, or the trailing part of one. + /// The text to leave in the field. + internal void TypeInto(string field, string text) + { + Click(field); + App.Keyboard.Press(Hexa.NET.ImGui.ImGuiKey.A, ctrl: true); + App.Keyboard.Type(text); + } + public void Dispose() { if (disposed) diff --git a/SchemaEditor.Test/MemberPanelTests.cs b/SchemaEditor.Test/MemberPanelTests.cs new file mode 100644 index 0000000..65355a2 --- /dev/null +++ b/SchemaEditor.Test/MemberPanelTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.SchemaEditor.Test; + +using System.Linq; + +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; + +/// +/// The member rows in the class panel: the controls that reorder and remove members. +/// +[TestClass] +public sealed class MemberPanelTests +{ + private EditorHarness harness = null!; + private SchemaClass user = null!; + + [TestInitialize] + public void StartEditor() + { + harness = EditorHarness.Start(); + + Schema schema = new(); + user = schema.AddClass("User".As())!; + user.AddMember("Id".As()); + user.AddMember("Age".As()); + user.AddMember("Email".As()); + + harness.Editor.CurrentSchema = schema; + harness.Editor.EditClass(user); + } + + [TestCleanup] + public void StopEditor() => harness.Dispose(); + + private string[] MemberNames => [.. user.Members.Select(m => m.Name.ToString())]; + + /// + /// Asserts the class holds exactly these members, in this order. + /// + private void AssertMembers(params string[] expected) => + CollectionAssert.AreEqual(expected, MemberNames, $"Members were [{string.Join(", ", MemberNames)}]."); + + [TestMethod] + public void TheMembersAreShownInOrder() => + AssertMembers("Id", "Age", "Email"); + + [TestMethod] + public void MovingAMemberUpSwapsItWithThePreviousOne() + { + harness.Click("memberAge/MoveUp"); + + AssertMembers("Age", "Id", "Email"); + } + + [TestMethod] + public void MovingAMemberDownSwapsItWithTheNextOne() + { + harness.Click("memberId/MoveDown"); + + AssertMembers("Age", "Id", "Email"); + } + + [TestMethod] + public void ReorderingIsUndoable() + { + harness.Click("memberEmail/MoveUp"); + AssertMembers("Id", "Email", "Age"); + + harness.Editor.UndoRedo.Undo(); + + AssertMembers("Id", "Age", "Email"); + } + + /// + /// The first row cannot move up and the last cannot move down, so those controls are disabled - + /// and a disabled ImGui control does not respond to a click. + /// + [TestMethod] + public void TheEndsOfTheListCannotMoveFurther() + { + harness.Click("memberId/MoveUp"); + AssertMembers("Id", "Age", "Email"); + + harness.Click("memberEmail/MoveDown"); + AssertMembers("Id", "Age", "Email"); + } + + [TestMethod] + public void DeletingAMemberRemovesItFromItsClass() + { + harness.Click("memberAge/Delete"); + + AssertMembers("Id", "Email"); + } + + /// + /// Deleting restores in place rather than at the end, or an undo would silently reorder the + /// class it was meant to put back. + /// + [TestMethod] + public void DeletingAMemberIsUndoable() + { + harness.Click("memberAge/Delete"); + AssertMembers("Id", "Email"); + + harness.Editor.UndoRedo.Undo(); + + AssertMembers("Id", "Age", "Email"); + } +} diff --git a/SchemaEditor.Test/TreeContextMenuTests.cs b/SchemaEditor.Test/TreeContextMenuTests.cs new file mode 100644 index 0000000..509fc58 --- /dev/null +++ b/SchemaEditor.Test/TreeContextMenuTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.SchemaEditor.Test; + +using System.Linq; + +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; + +/// +/// Renaming and deleting from a tree row's context menu, and what an undo of each puts back. +/// +[TestClass] +public sealed class TreeContextMenuTests +{ + private EditorHarness harness = null!; + private Schema schema = null!; + + [TestInitialize] + public void StartEditor() + { + harness = EditorHarness.Start(); + + schema = new Schema(); + SchemaClass user = schema.AddClass("User".As())!; + user.AddMember("Id".As()); + user.AddMember("Age".As()); + user.AddMember("Email".As()); + schema.AddClass("Account".As()); + schema.AddClass("Order".As()); + schema.AddEnum("Colour".As()); + schema.AddEnum("Size".As()); + + harness.Editor.CurrentSchema = schema; + } + + [TestCleanup] + public void StopEditor() => harness.Dispose(); + + private string[] ClassNames => [.. schema.Classes.Select(c => c.Name.ToString())]; + + private string[] EnumNames => [.. schema.Enums.Select(e => e.Name.ToString())]; + + private void AssertClasses(params string[] expected) => + CollectionAssert.AreEqual(expected, ClassNames, $"Classes were [{string.Join(", ", ClassNames)}]."); + + private void AssertEnums(params string[] expected) => + CollectionAssert.AreEqual(expected, EnumNames, $"Enums were [{string.Join(", ", EnumNames)}]."); + + private void ChooseFromContextMenu(string row, string entry) + { + harness.RightClick(row); + harness.Click(entry); + } + + [TestMethod] + public void DeletingAClassRemovesIt() + { + ChooseFromContextMenu("BtnAccount", "DeleteAccount"); + + AssertClasses("User", "Order"); + } + + /// + /// Restore appends, so without the position being remembered an undo would bring the class + /// back at the end - quietly reordering the schema it was asked to put back, and with it the + /// order the file and any generated code are written in. + /// + [TestMethod] + public void UndoingAClassDeleteBringsItBackWhereItWas() + { + ChooseFromContextMenu("BtnAccount", "DeleteAccount"); + + harness.Editor.UndoRedo.Undo(); + + AssertClasses("User", "Account", "Order"); + } + + [TestMethod] + public void RenamingAClassChangesItsName() + { + ChooseFromContextMenu("BtnAccount", "RenameAccount"); + harness.TypeInto("input/field", "Ledger"); + harness.Click("input/ok"); + + Assert.IsNotNull(schema.GetClass("Ledger".As())); + Assert.IsNull(schema.GetClass("Account".As())); + } + + [TestMethod] + public void DeletingAnEnumRemovesIt() + { + ChooseFromContextMenu("BtnColour", "DeleteColour"); + + AssertEnums("Size"); + } + + [TestMethod] + public void UndoingAnEnumDeleteBringsItBackWhereItWas() + { + ChooseFromContextMenu("BtnColour", "DeleteColour"); + + harness.Editor.UndoRedo.Undo(); + + AssertEnums("Colour", "Size"); + } + + /// + /// Data sources and code generators are deleted the same way, but their restore does not + /// preserve position: the schema exposes an ordered set for classes and enums and not for + /// these two, so the editor has nothing to move them back with. See the note in the roadmap. + /// + [TestMethod] + public void DeletingADataSourceRemovesIt() + { + schema.AddDataSource("Users".As()); + harness.Editor.CurrentSchema = schema; + + ChooseFromContextMenu("BtnUsers", "DeleteUsers"); + + Assert.IsNull(schema.GetDataSource("Users".As())); + } + + [TestMethod] + public void RenamingADataSourceChangesItsName() + { + schema.AddDataSource("Users".As()); + harness.Editor.CurrentSchema = schema; + + ChooseFromContextMenu("BtnUsers", "RenameUsers"); + harness.TypeInto("input/field", "People"); + harness.Click("input/ok"); + + Assert.IsNotNull(schema.GetDataSource("People".As())); + } + + [TestMethod] + public void DeletingACodeGeneratorRemovesIt() + { + schema.AddCodeGenerator("CSharp".As()); + harness.Editor.CurrentSchema = schema; + + ChooseFromContextMenu("BtnCSharp", "DeleteCSharp"); + + Assert.IsNull(schema.GetCodeGenerator("CSharp".As())); + } + + [TestMethod] + public void RenamingACodeGeneratorChangesItsName() + { + schema.AddCodeGenerator("CSharp".As()); + harness.Editor.CurrentSchema = schema; + + ChooseFromContextMenu("BtnCSharp", "RenameCSharp"); + harness.TypeInto("input/field", "CSharpPocos"); + harness.Click("input/ok"); + + Assert.IsNotNull(schema.GetCodeGenerator("CSharpPocos".As())); + } + + [TestMethod] + public void RenamingAnEnumChangesItsName() + { + ChooseFromContextMenu("BtnColour", "RenameColour"); + harness.TypeInto("input/field", "Hue"); + harness.Click("input/ok"); + + Assert.IsNotNull(schema.GetEnum("Hue".As())); + } + + [TestMethod] + public void RenamingAMemberChangesItsName() + { + SchemaClass user = schema.GetClass("User".As())!; + + ChooseFromContextMenu("User/BtnId", "RenameId"); + harness.TypeInto("input/field", "Identifier"); + harness.Click("input/ok"); + + Assert.IsNotNull(user.GetMember("Identifier".As())); + } + + private void AssertMembersOfUser(params string[] expected) + { + string[] actual = [.. schema.GetClass("User".As())!.Members.Select(m => m.Name.ToString())]; + CollectionAssert.AreEqual(expected, actual, $"Members were [{string.Join(", ", actual)}]."); + } + + [TestMethod] + public void DeletingAMemberFromTheTreeRemovesIt() + { + ChooseFromContextMenu("User/BtnAge", "DeleteAge"); + + AssertMembersOfUser("Id", "Email"); + } + + /// + /// The tree deletes a member through its own command rather than the panel's, so it needs the + /// same position-preserving restore - proved here by deleting from the middle. + /// + [TestMethod] + public void UndoingAMemberDeleteFromTheTreeBringsItBackWhereItWas() + { + ChooseFromContextMenu("User/BtnAge", "DeleteAge"); + AssertMembersOfUser("Id", "Email"); + + harness.Editor.UndoRedo.Undo(); + + AssertMembersOfUser("Id", "Age", "Email"); + } +} diff --git a/SchemaEditor.Test/TreeEditingTests.cs b/SchemaEditor.Test/TreeEditingTests.cs new file mode 100644 index 0000000..cc724f2 --- /dev/null +++ b/SchemaEditor.Test/TreeEditingTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.SchemaEditor.Test; + +using System.Linq; + +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; + +/// +/// Adding elements from the tree: the "+ New" button, the name it asks for, and the undoable +/// command that results. +/// +[TestClass] +public sealed class TreeEditingTests +{ + private EditorHarness harness = null!; + private Schema schema = null!; + + [TestInitialize] + public void StartEditor() + { + harness = EditorHarness.Start(); + schema = new Schema(); + harness.Editor.CurrentSchema = schema; + } + + [TestCleanup] + public void StopEditor() => harness.Dispose(); + + /// + /// Clicks a "+ New" button and answers the name it asks for. + /// + private void AddNamed(string button, string name) + { + harness.Click(button); + harness.TypeInto("input/field", name); + harness.Click("input/ok"); + } + + [TestMethod] + public void AddingAClassNamesItAndSelectsIt() + { + AddNamed("NewClass", "Order"); + + Assert.IsNotNull(schema.GetClass("Order".As())); + Assert.AreEqual("Order", harness.Editor.CurrentClass?.Name.ToString()); + } + + [TestMethod] + public void AddingAClassIsUndoable() + { + AddNamed("NewClass", "Order"); + Assert.IsNotNull(schema.GetClass("Order".As())); + + harness.Editor.UndoRedo.Undo(); + + Assert.IsNull(schema.GetClass("Order".As())); + } + + /// + /// A name already in use is refused with a message rather than silently replacing the class + /// that has it. + /// + [TestMethod] + public void AddingAClassWithANameAlreadyInUseIsRefused() + { + schema.AddClass("Order".As()); + + AddNamed("NewClass", "Order"); + + Assert.AreEqual(1, schema.Classes.Count(c => c.Name.ToString() == "Order")); + } + + [TestMethod] + public void AddingAMemberPutsItOnItsClass() + { + SchemaClass user = schema.AddClass("User".As())!; + harness.Editor.EditClass(user); + + AddNamed("User/NewMember", "Age"); + + Assert.IsNotNull(user.GetMember("Age".As())); + } + + [TestMethod] + public void AddingAnEnumPutsItOnTheSchema() + { + AddNamed("NewEnum", "Colour"); + + Assert.IsNotNull(schema.GetEnum("Colour".As())); + } + + [TestMethod] + public void AddingAnEnumValuePutsItOnItsEnum() + { + SchemaEnum colour = schema.AddEnum("Colour".As())!; + + AddNamed("NewValue", "Red"); + + Assert.IsTrue(colour.Values.Any(v => v.ToString() == "Red")); + } + + [TestMethod] + public void AddingADataSourcePutsItOnTheSchemaAndSelectsIt() + { + AddNamed("NewDataSource", "Users"); + + Assert.IsNotNull(schema.GetDataSource("Users".As())); + Assert.AreEqual("Users", harness.Editor.CurrentDataSource?.Name.ToString()); + } + + [TestMethod] + public void AddingACodeGeneratorPutsItOnTheSchema() + { + AddNamed("NewCodeGenerator", "CSharp"); + + Assert.IsNotNull(schema.GetCodeGenerator("CSharp".As())); + } +} diff --git a/SchemaEditor.Test/TreeNavigationTests.cs b/SchemaEditor.Test/TreeNavigationTests.cs new file mode 100644 index 0000000..5715bad --- /dev/null +++ b/SchemaEditor.Test/TreeNavigationTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.SchemaEditor.Test; + +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; + +/// +/// The schema tree, driven by clicking its rows: that each one selects the thing it names. +/// +/// +/// Rows are addressed by the name the editor marks them with rather than by pixel position, so +/// these tests survive the tree being laid out differently. The marking happens once, in +/// , which is where every row in every tree is drawn. +/// +[TestClass] +public sealed class TreeNavigationTests +{ + private EditorHarness harness = null!; + + [TestInitialize] + public void StartEditor() => harness = EditorHarness.Start(); + + [TestCleanup] + public void StopEditor() => harness.Dispose(); + + /// + /// A schema with one of everything, so every tree has a row to click. + /// + private Schema OpenPopulatedSchema() + { + Schema schema = new(); + + SchemaClass user = schema.AddClass("User".As())!; + user.AddMember("Id".As()); + user.AddMember("Age".As()); + schema.AddClass("Account".As()); + + schema.AddEnum("Colour".As())!.TryAddValue("Red".As()); + schema.AddDataSource("Users".As()); + schema.AddCodeGenerator("CSharp".As()); + + harness.Editor.CurrentSchema = schema; + return schema; + } + + [TestMethod] + public void ClickingAClassRowSelectsThatClass() + { + OpenPopulatedSchema(); + + harness.Click("BtnAccount"); + + Assert.AreEqual("Account", harness.Editor.CurrentClass?.Name.ToString()); + } + + /// + /// A member has no panel of its own; its row is drawn in its class's, so that is what its tree + /// row selects. + /// + [TestMethod] + public void ClickingAMemberRowSelectsItsOwningClass() + { + OpenPopulatedSchema(); + harness.Click("BtnAccount"); + + harness.Click("User/BtnAge"); + + Assert.AreEqual("User", harness.Editor.CurrentClass?.Name.ToString()); + } + + [TestMethod] + public void ClickingAnEnumRowSelectsThatEnum() + { + OpenPopulatedSchema(); + + harness.Click("BtnColour"); + + Assert.AreEqual("Colour", harness.Editor.CurrentEnum?.Name.ToString()); + Assert.IsNull(harness.Editor.CurrentClass, "Selecting an enum should clear the class selection."); + } + + [TestMethod] + public void ClickingADataSourceRowSelectsThatDataSource() + { + OpenPopulatedSchema(); + + harness.Click("BtnUsers"); + + Assert.AreEqual("Users", harness.Editor.CurrentDataSource?.Name.ToString()); + } + + [TestMethod] + public void ClickingACodeGeneratorRowSelectsThatCodeGenerator() + { + OpenPopulatedSchema(); + + harness.Click("BtnCSharp"); + + Assert.AreEqual("CSharp", harness.Editor.CurrentCodeGenerator?.Name.ToString()); + } + + /// + /// Only one thing is selected at a time, or two panels would each claim to be showing the + /// current selection. + /// + [TestMethod] + public void SelectingOneKindClearsTheOthers() + { + OpenPopulatedSchema(); + + harness.Click("BtnUser"); + harness.Click("BtnUsers"); + harness.Click("BtnCSharp"); + + Assert.IsNull(harness.Editor.CurrentClass); + Assert.IsNull(harness.Editor.CurrentDataSource); + Assert.IsNull(harness.Editor.CurrentEnum); + Assert.IsNotNull(harness.Editor.CurrentCodeGenerator); + } +} diff --git a/SchemaEditor/ButtonTree.cs b/SchemaEditor/ButtonTree.cs index e574ef8..eaf50a9 100644 --- a/SchemaEditor/ButtonTree.cs +++ b/SchemaEditor/ButtonTree.cs @@ -7,6 +7,7 @@ namespace ktsu.SchemaEditor; using Hexa.NET.ImGui; using ktsu.Extensions; +using ktsu.ImGui.Probes; using ktsu.ImGui.Styler; using ktsu.ImGui.Widgets; using ktsu.Schema.Models; @@ -117,6 +118,13 @@ private static void ShowTreeItem(string id, Config config, ImGuiWidgets.Tree tre : Palette.Semantic.Warning)) { ImGui.Button($"{buttonText}##Btn{itemId}", new(SchemaEditor.FieldWidth, 0)); + + // Every tree row in the editor is drawn here, so marking it here is what lets a + // test address any of them - a class, a member, an enum value, a data source - + // by name rather than by pixel position. Marking costs nothing when no probe is + // installed, which is every run that is not a test. + ImGuiProbes.MarkItem($"Btn{itemId}"); + if (ImGui.IsItemClicked()) { if (ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) diff --git a/SchemaEditor/SchemaEditor.Panels.cs b/SchemaEditor/SchemaEditor.Panels.cs index 2f5fc76..9e35e46 100644 --- a/SchemaEditor/SchemaEditor.Panels.cs +++ b/SchemaEditor/SchemaEditor.Panels.cs @@ -9,6 +9,7 @@ namespace ktsu.SchemaEditor; using Hexa.NET.ImGui; +using ktsu.ImGui.Probes; using ktsu.ImGui.Styler; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; @@ -275,10 +276,17 @@ private void ShowMemberRow(SchemaClass schemaClass, SchemaMember member, int ind { ImGui.PushID($"member{member.Name}"); + // A probe scope alongside the ImGui id stack: PushID keeps two rows' widgets apart for + // ImGui, and this keeps their recorded names apart for a test, which would otherwise see + // one ambiguous "Delete" however many members the class has. + ImGuiProbes.PushScope($"member{member.Name}"); + ShowMemberReorderButtons(schemaClass, member, index, memberCount); ImGui.SameLine(); - if (ImGui.Button("X", new Vector2(frameHeight, 0))) + bool deleteClicked = ImGui.Button("X", new Vector2(frameHeight, 0)); + ImGuiProbes.MarkItem("Delete"); + if (deleteClicked) { DeleteMember(schemaClass, member); } @@ -315,6 +323,7 @@ private void ShowMemberRow(SchemaClass schemaClass, SchemaMember member, int ind ImGui.Unindent(); } + ImGuiProbes.PopScope(); ImGui.PopID(); } @@ -329,7 +338,9 @@ private void ShowMemberRow(SchemaClass schemaClass, SchemaMember member, int ind private void ShowMemberReorderButtons(SchemaClass schemaClass, SchemaMember member, int index, int memberCount) { ImGui.BeginDisabled(index == 0); - if (ImGui.ArrowButton("##MoveUp", ImGuiDir.Up)) + bool moveUp = ImGui.ArrowButton("##MoveUp", ImGuiDir.Up); + ImGuiProbes.MarkItem("MoveUp"); + if (moveUp) { MoveMember(schemaClass, member, index - 1); } @@ -338,7 +349,9 @@ private void ShowMemberReorderButtons(SchemaClass schemaClass, SchemaMember memb ImGui.SameLine(); ImGui.BeginDisabled(index == memberCount - 1); - if (ImGui.ArrowButton("##MoveDown", ImGuiDir.Down)) + bool moveDown = ImGui.ArrowButton("##MoveDown", ImGuiDir.Down); + ImGuiProbes.MarkItem("MoveDown"); + if (moveDown) { MoveMember(schemaClass, member, index + 1); } @@ -393,12 +406,29 @@ private void ShowMemberIssueMarker(SchemaMember member) } } - private void DeleteMember(SchemaClass schemaClass, SchemaMember member) => + /// + /// Removes a member, remembering where it was so an undo puts it back there. + /// + /// + /// RestoreMember appends, and member order is part of the schema's meaning rather than + /// a display concern - it is the declaration order generated code uses, and it round-trips + /// through the file. So restoring alone turns an undo into an edit of its own: delete a member + /// from the middle of a class, undo, and the class comes back reordered. + /// + private void DeleteMember(SchemaClass schemaClass, SchemaMember member) + { + int index = schemaClass.IndexOfMember(member); + Execute(new DelegateCommand( $"Delete Member '{member.Name}'", () => member.TryRemove(), - () => schemaClass.RestoreMember(member), + () => + { + schemaClass.RestoreMember(member); + schemaClass.TryMoveMember(member, index); + }, ChangeType.Delete)); + } [System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "We want to separate out ImGui calls from enumerations")] public void ShowMemberConfig(Schema schema, SchemaMember schemaMember) diff --git a/SchemaEditor/SchemaEditor.csproj b/SchemaEditor/SchemaEditor.csproj index ac4d5e4..6757848 100644 --- a/SchemaEditor/SchemaEditor.csproj +++ b/SchemaEditor/SchemaEditor.csproj @@ -15,6 +15,7 @@ + diff --git a/SchemaEditor/TreeClass.cs b/SchemaEditor/TreeClass.cs index 710134c..c2d2c9c 100644 --- a/SchemaEditor/TreeClass.cs +++ b/SchemaEditor/TreeClass.cs @@ -6,6 +6,7 @@ namespace ktsu.SchemaEditor; using Hexa.NET.ImGui; +using ktsu.ImGui.Probes; using ktsu.ImGui.Styler; using ktsu.ImGui.Widgets; using ktsu.Schema.Models; @@ -45,18 +46,28 @@ internal void Show() { SchemaClass captured = x; - if (ImGui.Selectable($"Rename {captured.Name}")) + bool renameChosen = ImGui.Selectable($"Rename {captured.Name}"); + ImGuiProbes.MarkItem($"Rename{captured.Name}"); + if (renameChosen) { schemaEditor.PromptRename("class", captured.Name, newName => schema.TryRenameClass(captured, newName.As())); } - if (ImGui.Selectable($"Delete {captured.Name}")) + bool deleteChosen = ImGui.Selectable($"Delete {captured.Name}"); + ImGuiProbes.MarkItem($"Delete{captured.Name}"); + if (deleteChosen) { + // Restored where it was rather than appended; see DeleteMember in the panel. + int index = schema.ClassSet.IndexOf(captured); schemaEditor.Execute(new DelegateCommand( $"Delete Class '{captured.Name}'", () => captured.TryRemove(), - () => schema.RestoreClass(captured), + () => + { + schema.RestoreClass(captured); + schema.ClassSet.Move(captured, index); + }, ChangeType.Delete)); } }, @@ -69,6 +80,7 @@ private void ShowMemberTree(ImGuiWidgets.Tree parent, SchemaClass schemaClass) SchemaChildSet children = schemaClass.Members; ImGui.PushID(schemaClass.Name); + ImGuiProbes.PushScope(schemaClass.Name); ButtonTree.ShowTree(schemaClass.Name, $"{schemaClass.Name} ({children.Count})", children, new() { GetText = (x) => x.Name, @@ -89,22 +101,33 @@ private void ShowMemberTree(ImGuiWidgets.Tree parent, SchemaClass schemaClass) { SchemaMember captured = x; - if (ImGui.Selectable($"Rename {captured.Name}")) + bool renameChosen = ImGui.Selectable($"Rename {captured.Name}"); + ImGuiProbes.MarkItem($"Rename{captured.Name}"); + if (renameChosen) { schemaEditor.PromptRename("member", captured.Name, newName => schemaClass.TryRenameMember(captured, newName.As())); } - if (ImGui.Selectable($"Delete {captured.Name}")) + bool deleteChosen = ImGui.Selectable($"Delete {captured.Name}"); + ImGuiProbes.MarkItem($"Delete{captured.Name}"); + if (deleteChosen) { + // Restored where it was rather than appended; see DeleteMember in the panel. + int index = schemaClass.IndexOfMember(captured); schemaEditor.Execute(new DelegateCommand( $"Delete Member '{captured.Name}'", () => captured.TryRemove(), - () => schemaClass.RestoreMember(captured), + () => + { + schemaClass.RestoreMember(captured); + schemaClass.TryMoveMember(captured, index); + }, ChangeType.Delete)); } }, }, parent); + ImGuiProbes.PopScope(); ImGui.PopID(); } @@ -112,7 +135,9 @@ private void ShowNewClass(Schema schema) { using (Button.Alignment.Left()) { - if (ImGui.Button("+ New Class")) + bool clicked = ImGui.Button("+ New Class"); + ImGuiProbes.MarkItem("NewClass"); + if (clicked) { Popups.OpenInputString("Input", "New Class Name", string.Empty, (newName) => { @@ -150,7 +175,9 @@ private void ShowNewMember(SchemaClass schemaClass) { using (Button.Alignment.Left()) { - if (ImGui.Button("+ New Member")) + bool clicked = ImGui.Button("+ New Member"); + ImGuiProbes.MarkItem("NewMember"); + if (clicked) { Popups.OpenInputString("Input", "New Member Name", string.Empty, (newName) => { diff --git a/SchemaEditor/TreeCodeGenerator.cs b/SchemaEditor/TreeCodeGenerator.cs index 589f9b3..1e674d1 100644 --- a/SchemaEditor/TreeCodeGenerator.cs +++ b/SchemaEditor/TreeCodeGenerator.cs @@ -4,6 +4,7 @@ namespace ktsu.SchemaEditor; using Hexa.NET.ImGui; +using ktsu.ImGui.Probes; using ktsu.ImGui.Styler; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; @@ -41,13 +42,17 @@ internal void Show() { SchemaCodeGenerator captured = x; - if (ImGui.Selectable($"Rename {captured.Name}")) + bool renameChosen = ImGui.Selectable($"Rename {captured.Name}"); + ImGuiProbes.MarkItem($"Rename{captured.Name}"); + if (renameChosen) { schemaEditor.PromptRename("code generator", captured.Name, newName => schema.TryRenameCodeGenerator(captured, newName.As())); } - if (ImGui.Selectable($"Delete {captured.Name}")) + bool deleteChosen = ImGui.Selectable($"Delete {captured.Name}"); + ImGuiProbes.MarkItem($"Delete{captured.Name}"); + if (deleteChosen) { schemaEditor.Execute(new DelegateCommand( $"Delete Code Generator '{captured.Name}'", @@ -64,7 +69,9 @@ private void ShowNewCodeGenerator(Schema schema) { using (Button.Alignment.Left()) { - if (ImGui.Button("+ New Code Generator")) + bool clicked = ImGui.Button("+ New Code Generator"); + ImGuiProbes.MarkItem("NewCodeGenerator"); + if (clicked) { Popups.OpenInputString("Input", "New Code Generator Name", string.Empty, (newName) => { diff --git a/SchemaEditor/TreeDataSource.cs b/SchemaEditor/TreeDataSource.cs index 4427a07..8bec1f8 100644 --- a/SchemaEditor/TreeDataSource.cs +++ b/SchemaEditor/TreeDataSource.cs @@ -4,6 +4,7 @@ namespace ktsu.SchemaEditor; using Hexa.NET.ImGui; +using ktsu.ImGui.Probes; using ktsu.ImGui.Styler; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; @@ -41,13 +42,17 @@ internal void Show() { DataSource captured = x; - if (ImGui.Selectable($"Rename {captured.Name}")) + bool renameChosen = ImGui.Selectable($"Rename {captured.Name}"); + ImGuiProbes.MarkItem($"Rename{captured.Name}"); + if (renameChosen) { schemaEditor.PromptRename("data source", captured.Name, newName => schema.TryRenameDataSource(captured, newName.As())); } - if (ImGui.Selectable($"Delete {captured.Name}")) + bool deleteChosen = ImGui.Selectable($"Delete {captured.Name}"); + ImGuiProbes.MarkItem($"Delete{captured.Name}"); + if (deleteChosen) { schemaEditor.Execute(new DelegateCommand( $"Delete Data Source '{captured.Name}'", @@ -64,7 +69,9 @@ private void ShowNewDataSource(Schema schema) { using (Button.Alignment.Left()) { - if (ImGui.Button("+ New Data Source")) + bool clicked = ImGui.Button("+ New Data Source"); + ImGuiProbes.MarkItem("NewDataSource"); + if (clicked) { Popups.OpenInputString("Input", "New Data Source Name", string.Empty, (newName) => { diff --git a/SchemaEditor/TreeEnum.cs b/SchemaEditor/TreeEnum.cs index 09f6781..d486360 100644 --- a/SchemaEditor/TreeEnum.cs +++ b/SchemaEditor/TreeEnum.cs @@ -4,6 +4,7 @@ namespace ktsu.SchemaEditor; using Hexa.NET.ImGui; +using ktsu.ImGui.Probes; using ktsu.ImGui.Styler; using ktsu.ImGui.Widgets; using ktsu.Schema.Models; @@ -43,18 +44,28 @@ internal void Show() { SchemaEnum captured = x; - if (ImGui.Selectable($"Rename {captured.Name}")) + bool renameChosen = ImGui.Selectable($"Rename {captured.Name}"); + ImGuiProbes.MarkItem($"Rename{captured.Name}"); + if (renameChosen) { schemaEditor.PromptRename("enum", captured.Name, newName => schema.TryRenameEnum(captured, newName.As())); } - if (ImGui.Selectable($"Delete {captured.Name}")) + bool deleteChosen = ImGui.Selectable($"Delete {captured.Name}"); + ImGuiProbes.MarkItem($"Delete{captured.Name}"); + if (deleteChosen) { + // Restored where it was rather than appended; see DeleteMember in the panel. + int index = schema.EnumSet.IndexOf(captured); schemaEditor.Execute(new DelegateCommand( $"Delete Enum '{captured.Name}'", () => captured.TryRemove(), - () => schema.RestoreEnum(captured), + () => + { + schema.RestoreEnum(captured); + schema.EnumSet.Move(captured, index); + }, ChangeType.Delete)); } }, @@ -102,7 +113,9 @@ private void ShowNewEnum(Schema schema) { using (Button.Alignment.Left()) { - if (ImGui.Button("+ New Enum")) + bool clicked = ImGui.Button("+ New Enum"); + ImGuiProbes.MarkItem("NewEnum"); + if (clicked) { Popups.OpenInputString("Input", "New Enum Name", string.Empty, (newName) => { @@ -138,7 +151,9 @@ private void ShowNewEnumValue(SchemaEnum schemaEnum) { using (Button.Alignment.Left()) { - if (ImGui.Button($"+ New Value##addEnumValue{schemaEnum.Name}")) + bool clicked = ImGui.Button($"+ New Value##addEnumValue{schemaEnum.Name}"); + ImGuiProbes.MarkItem("NewValue"); + if (clicked) { Popups.OpenInputString("Input", "New Enum Value", string.Empty, (newValue) => { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5e349cb..9101c66 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -105,10 +105,14 @@ Outstanding: editor packaging via winget, and cutting the v2.0 milestone. Not one of the original phases; added when the editor grew large enough to need one. `SchemaEditor.Test` ([#128](https://github.com/ktsu-dev/Schema/issues/128)) drives the editor -headlessly and covers the recent-files list, the commit-once text field, the unsaved-changes guard -and the save-then-continue sequence, and validation debouncing and click-to-navigate. The -SonarCloud coverage exclusion has narrowed from the whole application to the panel and tree files, -which are still pure draw code. +headlessly. It covers the recent-files list, the commit-once text field, the unsaved-changes guard +and the save-then-continue sequence, validation debouncing and click-to-navigate, and — by +addressing widgets through the names the editor marks them with — the schema tree, its context +menus, the member rows in the class panel, and the class graph. + +The SonarCloud coverage exclusion is now just `SchemaEditor/Program.cs`, which holds only `Main`. +Nothing else in the editor is unmeasurable; what is left is a matter of how much each panel is +worth testing, not of whether it can be. ## What to do next @@ -116,9 +120,8 @@ which are still pure draw code. | ----- | --- | --- | --- | | 1 | Decide [#110](https://github.com/ktsu-dev/Schema/issues/110): implement or delete `Schema.Contracts` | S | A decision, not a build. It is public API on a published package that nothing implements, and `docs/examples/dependency-injection.md` documents it as though it works | | 2 | [#126](https://github.com/ktsu-dev/Schema/issues/126): generated data editors | L | The first thing the data source binding was for | -| 3 | Extend `SchemaEditor.Test` to the panel and tree files | M | The harness exists; those files are what it does not reach yet, and they are the ones still excluded from coverage | -| 4 | [#127](https://github.com/ktsu-dev/Schema/issues/127): generated migrations | L | Needs a schema diff first; the largest remaining design problem | -| 5 | Editor packaging and the v2.0 milestone | M | Ship it | +| 3 | [#127](https://github.com/ktsu-dev/Schema/issues/127): generated migrations | L | Needs a schema diff first; the largest remaining design problem | +| 4 | Editor packaging and the v2.0 milestone | M | Ship it | ## Decisions @@ -133,6 +136,13 @@ Resolved with the project owner (2026-06): Made while implementing, and open to revision: +8. **A deleted element is restored where it was.** Undoing a delete puts the element back at the + index it was removed from rather than at the end, because order is part of the schema's meaning: + it is the declaration order generated code uses, and it round-trips through the file. This is + done for classes, enums and members. Data sources and code generators still restore at the end, + because `Schema` exposes an ordered set (`ClassSet`, `EnumSet`) for the first two and not for + the other two, so the editor has nothing to reposition them with. + 5. **Renames cascade.** Renaming a class or enum repoints every reference to it, rather than being blocked while references exist or allowed to dangle. It is the only option that neither loses work nor knowingly breaks the schema. diff --git a/docs/development/README.md b/docs/development/README.md index 864822d..8f4f766 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -86,10 +86,17 @@ Two fixtures wrap it: - **`WidgetHarness`** draws one widget and nothing else, for widget-level behaviour such as `EditField`. +Widgets are addressed by name. The editor marks its own items through `ktsu.ImGui.Probes` — a +dependency-free package whose whole purpose is that an application, a widget library and a dialog +library can each mark items without depending on one another. Marking costs nothing when no probe +is installed, which is every run that is not a test. The marks are deliberately few and central: +one in `ButtonTree` covers every row of every tree, and a probe scope per member row keeps two +rows' controls apart the same way `PushID` does for ImGui itself. A test then clicks +`App.Click("BtnUser")` or `App.Click("memberAge/Delete")` and never states a coordinate. + Frames are advanced by the test, never by wall-clock time — `Step(n)` for a fixed number and `StepUntil(condition, budget)` for a wait — so a loaded runner is slower rather than flakier. -Widgets are addressed by name through `App.Probe` and `App.Click(name)` where the widget library -records them, and by measured rectangle otherwise. Where a regression is only visible on screen, +Where a regression is only visible on screen, `App.Capture()` gives the rendered pixels: `TwoRowsSharingALabelDoNotShareABuffer` compares one row's pixels before and during an edit of its sibling, which is the only place the shared-buffer bug it guards against is observable at all.