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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Schema elements maintain parent references via `AssociateWith()` methods. After
- `Schema/Models/SchemaClass.cs` - Class definitions containing `SchemaMember` collections
- `SchemaEditor/SchemaEditor.cs` - Main editor application using `ktsu.ImGui.App`
- `SchemaEditor/EditorHost.cs` - Builds the `ImGuiAppConfig`; `CreateConfig` is what the tests drive too
- `SchemaEditor/EditorTheme.cs` - The ktsu.ThemeProvider theme, and the one definition of how a validation issue is coloured
- `SchemaEditor/Program.cs` - The entry point, and the only file excluded from coverage measurement
- `SchemaEditor.Test/EditorHarness.cs` - Runs a real editor headlessly, frames advanced by the test
- `SchemaEditor.Test/WidgetHarness.cs` - A headless frame containing only the widget under test
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageVersion Include="ktsu.ForceDirectedLayout" Version="3.16.6" />
<PackageVersion Include="ktsu.FuzzySearch" Version="1.2.39" />
<PackageVersion Include="ktsu.ImGui.Styler" Version="3.16.6" />
<PackageVersion Include="ktsu.ThemeProvider" Version="3.0.14" />
<PackageVersion Include="ktsu.IntervalAction" Version="1.3.41" />
<PackageVersion Include="ktsu.Keybinding.Core" Version="1.0.41" />
<PackageVersion Include="ktsu.UndoRedo.Core" Version="1.0.19" />
Expand Down
16 changes: 14 additions & 2 deletions SchemaEditor.Test/EditorHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,25 @@ private EditorHarness(SchemaEditor editor, ImGuiAppHarness app)
/// Starts an editor with empty settings and advances the frames it needs to be drawing.
/// </summary>
/// <returns>The running harness. Dispose it to release the ImGui context.</returns>
internal static EditorHarness Start()
internal static EditorHarness Start() => Start(new HarnessOptions());

/// <summary>
/// Starts an editor at a chosen display size.
/// </summary>
/// <remarks>
/// Size matters to more than layout: the editor derives its field and column widths from the
/// display width, so a narrow window is what puts a long label past the width its column
/// gives it.
/// </remarks>
/// <param name="options">Determinism settings, including the display size.</param>
/// <returns>The running harness. Dispose it to release the ImGui context.</returns>
internal static EditorHarness Start(HarnessOptions options)
{
// Must precede the constructor: it is the constructor that loads the settings.
ktsu.AppDataStorage.AppData.ConfigureForTesting(() => new MockFileSystem());

SchemaEditor editor = new();
ImGuiAppHarness app = ImGuiAppHarness.Start(EditorHost.CreateConfig(editor), new HarnessOptions());
ImGuiAppHarness app = ImGuiAppHarness.Start(EditorHost.CreateConfig(editor), options);

// The first frame builds the font atlas and lays the panels out; nothing is measurable
// before it has run.
Expand Down
2 changes: 2 additions & 0 deletions SchemaEditor.Test/SchemaEditor.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<!-- The headless harness: it drives ImGuiApp frames through a software rasterizer, so the
editor's draw code runs with no window, no display and no GPU. -->
<PackageReference Include="ktsu.ImGui.App.Testing" />
<PackageReference Include="ktsu.ImGui.Styler" />
<PackageReference Include="ktsu.ThemeProvider" />
<!-- A mock file system for AppDataStorage, so a test never reads or writes the settings of
whoever is running it. -->
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" />
Expand Down
70 changes: 70 additions & 0 deletions SchemaEditor.Test/ThemeBrowserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.SchemaEditor.Test;

/// <summary>
/// Picking a theme from the browser the Theme menu opens.
/// </summary>
/// <remarks>
/// The themes named here are ones near the top of the browser's grid. The grid scrolls, and a card
/// below the fold is recorded by the probe at a position that is clipped away, so clicking it hits
/// the modal behind rather than the card.
/// </remarks>
[TestClass]
public sealed class ThemeBrowserTests
{
private EditorHarness harness = null!;

[TestInitialize]
public void StartEditor()
{
harness = EditorHarness.Start();
harness.Editor.Options.ThemeName = string.Empty;
harness.Editor.OnStart();
}

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

[TestMethod]
public void ChoosingAThemeAppliesIt()
{
EditorTheme.OpenBrowser();

harness.Click("theme-card/Dracula");

Assert.AreEqual("Dracula", EditorTheme.CurrentName);
}

/// <summary>
/// The choice has to reach the settings, or it is gone at the next launch - which is the whole
/// point of storing it.
/// </summary>
[TestMethod]
public void ChoosingAThemeRemembersIt()
{
EditorTheme.OpenBrowser();

harness.Click("theme-card/Gruvbox Dark");

Assert.AreEqual("Gruvbox Dark", harness.Editor.Options.ThemeName);
}

/// <summary>
/// A theme chosen now is the theme applied on the next start, which is the round trip the
/// setting exists for.
/// </summary>
[TestMethod]
public void ARememberedThemeComesBackOnTheNextStart()
{
EditorTheme.OpenBrowser();
harness.Click("theme-card/Dracula");

EditorTheme.Apply("Nord");
Assert.AreEqual("Nord", EditorTheme.CurrentName);

harness.Editor.OnStart();

Assert.AreEqual("Dracula", EditorTheme.CurrentName);
}
}
80 changes: 80 additions & 0 deletions SchemaEditor.Test/ThemeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.SchemaEditor.Test;

/// <summary>
/// Which ktsu.ThemeProvider theme the editor runs under, and where that choice comes from.
/// </summary>
/// <remarks>
/// The editor used to have no theme at all. It wrapped every frame in
/// <c>Theme.FromColor(Palette.Semantic.Primary)</c> - a scoped colour meant for one widget -
/// which tinted the entire interface with the primary colour and left an ordinary button looking
/// the same as one marked with an error.
/// </remarks>
[TestClass]
public sealed class ThemeTests
{
private EditorHarness harness = null!;

[TestInitialize]
public void StartEditor() => harness = EditorHarness.Start();

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

/// <summary>
/// Settings that name no theme still get one, rather than falling back to unstyled ImGui.
/// </summary>
[TestMethod]
public void SettingsWithNoThemeGetTheDefault()
{
EditorTheme.Apply(string.Empty);

Assert.AreEqual("VSCode Dark", EditorTheme.CurrentName);
}

[TestMethod]
public void ANamedThemeIsApplied()
{
EditorTheme.Apply("Nord");

Assert.AreEqual("Nord", EditorTheme.CurrentName);
}

/// <summary>
/// A theme that has left the registry - renamed upstream, or dropped - must not leave the
/// editor unstyled, because the name is read from settings written by an older build.
/// </summary>
[TestMethod]
public void AThemeThatIsNoLongerRegisteredFallsBack()
{
EditorTheme.Apply("A Theme That Does Not Exist");

Assert.AreEqual("VSCode Dark", EditorTheme.CurrentName);
}

[TestMethod]
public void TheSavedThemeIsAppliedWhenTheEditorStarts()
{
harness.Editor.Options.ThemeName = "Gruvbox Dark";

harness.Editor.OnStart();

Assert.AreEqual("Gruvbox Dark", EditorTheme.CurrentName);
}

/// <summary>
/// Starting with whatever the previous test left applied must still end with a theme, which is
/// the property that stops the blanket-tint approach coming back as "no theme at all".
/// </summary>
[TestMethod]
public void TheEditorAlwaysRunsUnderSomeTheme()
{
harness.Editor.Options.ThemeName = string.Empty;

harness.Editor.OnStart();
harness.App.Step(3);

Assert.IsFalse(string.IsNullOrEmpty(EditorTheme.CurrentName), "The editor drew a frame with no theme applied.");
}
}
80 changes: 80 additions & 0 deletions SchemaEditor.Test/TreeRowWidthTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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;

/// <summary>
/// How wide a tree row is drawn.
/// </summary>
/// <remarks>
/// The rows shared one fixed width, and ImGui clips a button's label to its frame, so the longest
/// label in the tree - "Code Generators (0)" - was drawn without its count. The width is a minimum
/// now: short labels still line up as a column, and a long one grows to fit.
/// </remarks>
[TestClass]
public sealed class TreeRowWidthTests
{
private EditorHarness harness = null!;

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

private int WidthOf(string item)
{
harness.StepUntil(() => harness.App.Probe.Matches(item).Count > 0, $"'{item}' appearing");
Rectangle rect = harness.App.Probe.Rect(item) ?? throw new AssertFailedException($"'{item}' was not recorded.");
return rect.Width;
}

/// <summary>
/// The four tree headings are drawn from the same code with labels of very different lengths,
/// which is where the clipping showed up.
/// </summary>
[TestMethod]
public void ALongerHeadingIsDrawnWiderThanSpareColumnWidthAllows()
{
// Narrow, because the column is 15% of the display width: at a wide display every heading
// fits and there is nothing to prove. This is the shape of window the clipping was
// reported from.
harness = EditorHarness.Start(new HarnessOptions { Width = 700, Height = 600 });
harness.Editor.CurrentSchema = new Schema();

Assert.IsTrue(
WidthOf("RootCode Generators") > WidthOf("RootEnums"),
"'Code Generators (0)' is the longest heading in the tree; drawn at the same width as 'Enums (0)' it loses its count.");

Check warning on line 48 in SchemaEditor.Test/TreeRowWidthTests.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=AaBrJWFs6DzhTsVTWlcC&open=AaBrJWFs6DzhTsVTWlcC&pullRequest=142
}

[TestMethod]
public void ALongerClassNameIsDrawnWider()
{
harness = EditorHarness.Start();
Schema schema = new();
schema.AddClass("A".As<ClassName>());
schema.AddClass("AClassNameLongEnoughToNeedMoreRoomThanTheColumnGives".As<ClassName>());
harness.Editor.CurrentSchema = schema;

Assert.IsTrue(
WidthOf("BtnAClassNameLongEnoughToNeedMoreRoomThanTheColumnGives") > WidthOf("BtnA"),
"A class name longer than the column was clipped instead of widening its row.");

Check warning on line 62 in SchemaEditor.Test/TreeRowWidthTests.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=AaBrJWFs6DzhTsVTWlcB&open=AaBrJWFs6DzhTsVTWlcB&pullRequest=142
}

/// <summary>
/// The width is a minimum, not a per-row measurement: rows whose labels both fit still line up,
/// or the tree would be a ragged edge.
/// </summary>
[TestMethod]
public void ShortLabelsShareOneColumnWidth()
{
harness = EditorHarness.Start();
Schema schema = new();
schema.AddClass("A".As<ClassName>());
schema.AddClass("Bee".As<ClassName>());
harness.Editor.CurrentSchema = schema;

Assert.AreEqual(WidthOf("BtnA"), WidthOf("BtnBee"));
}
}
Loading