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
16 changes: 10 additions & 6 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,16 @@ jobs:
'/d:sonar.host.url=https://sonarcloud.io'
'/d:sonar.projectBaseDir=${{ github.workspace }}'
'/d:sonar.cs.vscoveragexml.reportsPaths=coverage/**/coverage.xml'
# SchemaEditor is excluded from COVERAGE only - it is still analysed for bugs, smells
# and security. It is an ImGui application: its code is immediate-mode draw calls that
# need a live UI context to execute, so line coverage there cannot be earned without a
# UI test harness, tracked separately. Measuring it would only ever report the absence
# of a harness. The library, which is what consumers depend on, stays fully measured.
'/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs,SchemaEditor/**/*.cs'
# 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'
'/d:sonar.cs.vstest.reportsPaths=coverage/**/*.trx'
'/d:sonar.exclusions=**/NativeExports.cs'
)
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Schema is a C# library for defining and managing data structure schemas. It consists of four projects:
Schema is a C# library for defining and managing data structure schemas. It consists of five projects:

- **Schema** - Core library providing schema definition types (classes, enums, members, types)
- **Schema.Test** - MSTest unit tests for the core library
- **SchemaEditor** - ImGui-based visual editor application for creating and editing `.schema.json` files
- **SchemaEditor.Test** - Headless UI tests for the editor, driven through `ktsu.ImGui.App.Testing`
- **SchemaTool** - Command line entry point for validating schemas and running their code generators

## Build Commands
Expand Down Expand Up @@ -80,6 +81,10 @@ Schema elements maintain parent references via `AssociateWith()` methods. After
- `Schema/Models/Types/BaseType.cs` - Abstract base with `[JsonDerivedType]` attributes for polymorphic serialization
- `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/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

## Dependencies

Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageVersion Include="ktsu.Semantics.Strings" Version="3.2.5" />
<PackageVersion Include="ktsu.Semantics.Paths" Version="3.2.5" />
<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.Widgets" Version="3.16.6" />
<PackageVersion Include="ktsu.ImGuiNodeEditor" Version="3.16.6" />
Expand All @@ -28,5 +29,6 @@
<PackageVersion Include="Microsoft.SourceLink.AzureRepos.Git" Version="10.0.400" />
<PackageVersion Include="Polyfill" Version="11.2.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.2.0" />
</ItemGroup>
</Project>
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ A C# library for defining, managing, and editing data structure schemas with a r

**ktsu.Schema** lets you define structured data models programmatically or visually, then serialize them to `.schema.json` files (documented in the [schema file format reference](docs/schema-format.md)). It provides a foundation for code generation, data validation, and tooling that needs to understand your data structures at a metadata level.

The solution contains three projects:
The solution contains five projects:

- **Schema** - Core library with schema definition types, a rich type system, and JSON serialization
- **Schema.Test** - Unit tests for the core library
- **SchemaEditor** - ImGui-based desktop application for visual schema editing
- **SchemaEditor.Test** - Headless UI tests that drive the editor with no window or display
- **SchemaTool** - Command line entry point for validating schemas and running their code generators

## Installation

Expand Down
14 changes: 14 additions & 0 deletions Schema.sln
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SchemaEditor", "SchemaEdito
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SchemaTool", "SchemaTool\SchemaTool.csproj", "{1EBD8E07-A2A3-4091-8748-ECFEB1D301F0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SchemaEditor.Test", "SchemaEditor.Test\SchemaEditor.Test.csproj", "{31DEE39F-3542-4B65-B744-74243D28D878}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -69,6 +71,18 @@ Global
{1EBD8E07-A2A3-4091-8748-ECFEB1D301F0}.Release|x64.Build.0 = Release|Any CPU
{1EBD8E07-A2A3-4091-8748-ECFEB1D301F0}.Release|x86.ActiveCfg = Release|Any CPU
{1EBD8E07-A2A3-4091-8748-ECFEB1D301F0}.Release|x86.Build.0 = Release|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Debug|Any CPU.Build.0 = Debug|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Debug|x64.ActiveCfg = Debug|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Debug|x64.Build.0 = Debug|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Debug|x86.ActiveCfg = Debug|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Debug|x86.Build.0 = Debug|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Release|Any CPU.ActiveCfg = Release|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Release|Any CPU.Build.0 = Release|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Release|x64.ActiveCfg = Release|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Release|x64.Build.0 = Release|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Release|x86.ActiveCfg = Release|Any CPU
{31DEE39F-3542-4B65-B744-74243D28D878}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
5 changes: 5 additions & 0 deletions Schema/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

// Both test assemblies are named, rather than only the one that reads this project's internals.
// ktsu.Sdk's KTSU0002 requires a non-test project to expose its internals to the repository's test
// projects, and there are two of them now; which of the two a given project actually needs is not
// what the rule is checking.
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.Schema.Test")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.SchemaEditor.Test")]
5 changes: 5 additions & 0 deletions SchemaEditor.Test/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

// One ImGui context exists at a time, and ImGuiAppHarness refuses to start a second while one is
// running, so these tests cannot run concurrently with each other the way the library's can.
[assembly: DoNotParallelize]
268 changes: 268 additions & 0 deletions SchemaEditor.Test/DiagnosticsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
// 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 diagnostics panel's logic: when validation runs, and what clicking an issue selects.
/// </summary>
[TestClass]
public sealed class DiagnosticsTests
{
private EditorHarness harness = null!;

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

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

/// <summary>
/// Builds a schema whose every element kind has something wrong with it, so one validation run
/// produces an issue pointing at each.
/// </summary>
private static Schema BuildSchemaWithAnIssuePerElementKind()
{
Schema schema = new();

// A member with no type: a warning naming the member.
SchemaClass user = schema.AddClass("User".As<ClassName>())!;
user.AddMember("Untyped".As<MemberName>());

// An empty class name: an error naming the class.
schema.AddClass(new ClassName());

// An empty enum name: an error naming the enum.
schema.AddEnum(new EnumName());

// A data source with no class and no file: warnings naming the data source.
schema.AddDataSource("Users".As<DataSourceName>());

// A code generator with no output path and no language: warnings naming the generator.
schema.AddCodeGenerator("CSharp".As<CodeGeneratorName>());

return schema;
}

[TestMethod]
public void ValidationDoesNotRunUntilTheSchemaSettles()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
harness.Editor.RequestValidation();

// Just short of the debounce: the edit is still in flight, so nothing has been validated.
harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds - 0.01f);
Assert.AreEqual(0, harness.Editor.Diagnostics.Count);

Check warning on line 60 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

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

harness.Editor.UpdateValidation(0.02f);
Assert.IsTrue(harness.Editor.Diagnostics.Count > 0, "The schema should have been validated once the debounce elapsed.");

Check warning on line 63 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsNotEmpty' instead of 'Assert.IsTrue'

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

/// <summary>
/// A burst of edits - the case the debounce exists for - must validate once at the end rather
/// than once per edit.
/// </summary>
[TestMethod]
public void EachEditRestartsTheDebounce()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();

for (int edit = 0; edit < 5; edit++)
{
harness.Editor.RequestValidation();
harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds - 0.01f);
Assert.AreEqual(0, harness.Editor.Diagnostics.Count, $"Validation ran while edit {edit} was still in flight.");

Check warning on line 79 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

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

harness.Editor.UpdateValidation(0.02f);
Assert.IsTrue(harness.Editor.Diagnostics.Count > 0);

Check warning on line 83 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsNotEmpty' instead of 'Assert.IsTrue'

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

[TestMethod]
public void ValidationDoesNotRunAgainUntilSomethingChanges()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
harness.Editor.RequestValidation();
harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds);

System.Collections.ObjectModel.Collection<SchemaValidationIssue> first = harness.Editor.Diagnostics;

harness.Editor.UpdateValidation(10f);

Assert.AreSame(first, harness.Editor.Diagnostics, "Validation re-ran without the schema having changed.");
}

[TestMethod]
public void ValidatingWithNoSchemaClearsTheIssues()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
harness.Editor.RequestValidation();
harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds);
Assert.IsTrue(harness.Editor.Diagnostics.Count > 0);

Check warning on line 106 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsNotEmpty' instead of 'Assert.IsTrue'

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

harness.Editor.CurrentSchema = null;
harness.Editor.RequestValidation();
harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds);

Assert.AreEqual(0, harness.Editor.Diagnostics.Count);

Check warning on line 112 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

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

private SchemaValidationIssue IssueFor<TElement>() =>
harness.Editor.Diagnostics.FirstOrDefault(i => i.Element is TElement)
?? throw new AssertFailedException($"No validation issue was reported against a {typeof(TElement).Name}. Issues: {string.Join("; ", harness.Editor.Diagnostics.Select(i => $"{i.Path}: {i.Message}"))}");

private void ValidateOnce()
{
harness.Editor.RequestValidation();
harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds);
}

[TestMethod]
public void NavigatingToAClassIssueSelectsTheClass()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();

SchemaValidationIssue issue = IssueFor<SchemaClass>();
harness.Editor.NavigateTo(issue);

Assert.AreSame(issue.Element, harness.Editor.CurrentClass);
}

/// <summary>
/// A member has no panel of its own; its row is drawn in its class's, so that is what must be
/// selected.
/// </summary>
[TestMethod]
public void NavigatingToAMemberIssueSelectsItsOwningClass()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();

SchemaValidationIssue issue = IssueFor<SchemaMember>();
harness.Editor.NavigateTo(issue);

Assert.AreSame(((SchemaMember)issue.Element!).ParentClass, harness.Editor.CurrentClass);
}

[TestMethod]
public void NavigatingToAnEnumIssueSelectsTheEnum()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();

SchemaValidationIssue issue = IssueFor<SchemaEnum>();
harness.Editor.NavigateTo(issue);

Assert.AreSame(issue.Element, harness.Editor.CurrentEnum);
}

[TestMethod]
public void NavigatingToADataSourceIssueSelectsTheDataSource()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();

SchemaValidationIssue issue = IssueFor<DataSource>();
harness.Editor.NavigateTo(issue);

Assert.AreSame(issue.Element, harness.Editor.CurrentDataSource);
}

[TestMethod]
public void NavigatingToACodeGeneratorIssueSelectsTheCodeGenerator()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();

SchemaValidationIssue issue = IssueFor<SchemaCodeGenerator>();
harness.Editor.NavigateTo(issue);

Assert.AreSame(issue.Element, harness.Editor.CurrentCodeGenerator);
}

/// <summary>
/// Selecting one element must clear the rest, or two panels would claim to be showing the
/// current selection at once.
/// </summary>
[TestMethod]
public void NavigatingClearsThePreviousSelection()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();

harness.Editor.NavigateTo(IssueFor<SchemaClass>());
harness.Editor.NavigateTo(IssueFor<DataSource>());

Assert.IsNull(harness.Editor.CurrentClass);
Assert.IsNotNull(harness.Editor.CurrentDataSource);
}

/// <summary>
/// A duplicate-name issue names no single element, so there is nothing to select and nothing
/// should change.
/// </summary>
[TestMethod]
public void NavigatingToAnIssueWithNoElementChangesNothing()
{
harness.Editor.CurrentSchema = BuildSchemaWithAnIssuePerElementKind();
ValidateOnce();
harness.Editor.NavigateTo(IssueFor<SchemaClass>());
SchemaClass? selected = harness.Editor.CurrentClass;

harness.Editor.NavigateTo(new SchemaValidationIssue
{
Severity = SchemaValidationSeverity.Error,
Path = "User",
Message = "Duplicate class name 'User'.",
});

Assert.AreSame(selected, harness.Editor.CurrentClass);
}

[TestMethod]
public void AnElementWithNoIssueIsNotMarked()
{
Schema schema = new();
SchemaClass clean = schema.AddClass("Clean".As<ClassName>())!;
harness.Editor.CurrentSchema = schema;
ValidateOnce();

Assert.IsNull(harness.Editor.GetIssueFor(clean));
}

[TestMethod]
public void AnElementWithNoElementReferenceIsNotMatched() =>
Assert.IsNull(harness.Editor.GetIssueFor(null));

/// <summary>
/// The inline marker has room for one issue, so it must be the most severe one affecting the
/// element rather than whichever validation happened to report first.
/// </summary>
[TestMethod]
public void TheMarkerForAnElementIsItsMostSevereIssue()
{
Schema schema = new();

// No class and no file: the missing class is a warning, and the missing file is another.
// Pointing it at a class that does not exist makes the first an error instead.
DataSource dataSource = schema.AddDataSource("Users".As<DataSourceName>())!;
dataSource.ClassName = "Missing".As<ClassName>();

harness.Editor.CurrentSchema = schema;
ValidateOnce();

SchemaValidationIssue? marked = harness.Editor.GetIssueFor(dataSource);

Assert.IsNotNull(marked);
Assert.AreEqual(SchemaValidationSeverity.Error, marked.Severity);
Assert.IsTrue(
harness.Editor.Diagnostics.Any(i => ReferenceEquals(i.Element, dataSource) && i.Severity == SchemaValidationSeverity.Warning),
"This test only proves anything while the data source also has a warning to be outranked.");

Check warning on line 266 in SchemaEditor.Test/DiagnosticsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

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