Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<PackageVersion Include="ktsu.ImGui.App" Version="3.16.6" />
<PackageVersion Include="ktsu.ImGui.App.Testing" Version="3.16.6" />
<PackageVersion Include="ktsu.ImGui.Popups" Version="3.16.6" />
<PackageVersion Include="ktsu.ImGui.Probes" Version="3.16.6" />
<PackageVersion Include="ktsu.ImGui.Widgets" Version="3.16.6" />
<PackageVersion Include="ktsu.ImGuiNodeEditor" Version="3.16.6" />
<PackageVersion Include="ktsu.NodeGraph" Version="1.0.0" />
Expand Down
95 changes: 95 additions & 0 deletions SchemaEditor.Test/ClassGraphTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The class graph, which draws the schema's classes and the references between them.
/// </summary>
/// <remarks>
/// Driven through <see cref="WidgetHarness"/> 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.
/// </remarks>
[TestClass]
public sealed class ClassGraphTests
{
private WidgetHarness harness = null!;

[TestInitialize]
public void StartHarness() => harness = WidgetHarness.Start();

[TestCleanup]
public void StopHarness() => harness.Dispose();

/// <summary>
/// Two classes, one referencing the other, so the graph has both a node and an edge to draw.
/// </summary>
private static Schema BuildReferencingSchema()
{
Schema schema = new();
SchemaClass user = schema.AddClass("User".As<ClassName>())!;
SchemaClass account = schema.AddClass("Account".As<ClassName>())!;
account.AddMember("Owner".As<MemberName>())!.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.");

Check warning on line 61 in SchemaEditor.Test/ClassGraphTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaBq9yyBJa7LnQLrcKnj&open=AaBq9yyBJa7LnQLrcKnj&pullRequest=140
}

/// <summary>
/// The graph is drawn every frame whether or not a schema is open, so the empty case is the
/// one that runs most often.
/// </summary>
[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.");
}

/// <summary>
/// A schema with nothing in it takes an early return that says so, rather than handing an
/// empty graph to the node editor.
/// </summary>
[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.");

Check warning on line 93 in SchemaEditor.Test/ClassGraphTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaBq9yyBJa7LnQLrcKnk&open=AaBq9yyBJa7LnQLrcKnk&pullRequest=140
}
}
44 changes: 44 additions & 0 deletions SchemaEditor.Test/EditorHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,50 @@ internal void StepUntil(Func<bool> condition, string description, int maxFrames
}
}

/// <summary>
/// Waits for a marked item to be drawn, then clicks it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="item">A marked name, or the trailing part of one.</param>
internal void Click(string item)
{
StepUntil(() => App.Probe.Matches(item).Count > 0, $"'{item}' appearing");
App.Step(3);
App.Click(item);
App.Step(2);
}

/// <summary>
/// Right-clicks a marked item, which is how the tree opens an item's context menu.
/// </summary>
/// <param name="item">A marked name, or the trailing part of one.</param>
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);
}

/// <summary>
/// Types a value into a marked text field, replacing whatever it holds.
/// </summary>
/// <param name="field">A marked name, or the trailing part of one.</param>
/// <param name="text">The text to leave in the field.</param>
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)
Expand Down
113 changes: 113 additions & 0 deletions SchemaEditor.Test/MemberPanelTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The member rows in the class panel: the controls that reorder and remove members.
/// </summary>
[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<ClassName>())!;
user.AddMember("Id".As<MemberName>());
user.AddMember("Age".As<MemberName>());
user.AddMember("Email".As<MemberName>());

harness.Editor.CurrentSchema = schema;
harness.Editor.EditClass(user);
}

[TestCleanup]
public void StopEditor() => harness.Dispose();

private string[] MemberNames => [.. user.Members.Select(m => m.Name.ToString())];

/// <summary>
/// Asserts the class holds exactly these members, in this order.
/// </summary>
private void AssertMembers(params string[] expected) =>
CollectionAssert.AreEqual(expected, MemberNames, $"Members were [{string.Join(", ", MemberNames)}].");

Check warning on line 44 in SchemaEditor.Test/MemberPanelTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaBq9yx5Ja7LnQLrcKni&open=AaBq9yx5Ja7LnQLrcKni&pullRequest=140

[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");
}

/// <summary>
/// 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.
/// </summary>
[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");
}

/// <summary>
/// Deleting restores in place rather than at the end, or an undo would silently reorder the
/// class it was meant to put back.
/// </summary>
[TestMethod]
public void DeletingAMemberIsUndoable()
{
harness.Click("memberAge/Delete");
AssertMembers("Id", "Email");

harness.Editor.UndoRedo.Undo();

AssertMembers("Id", "Age", "Email");
}
}
Loading