From 846b7884f9f1aca3f707f12ac5dc38ee8815f9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:04:06 +0000 Subject: [PATCH 1/2] Add a headless gallery of the Schema editor Ten pictures of the editor working on a small sample schema for a game: the File menu, a class's member grid, a member's metadata fold, the type and unit pickers, an enum, a code generator, the class graph, the diagnostics list and the theme browser. Each is an ordinary test in tests/Schema.Editor.UITests/Gallery that stages the editor through probe clicks and saves the rasterized frame, so every pull request proves each picture can still be staged. They write to a temporary directory unless SCHEMA_GALLERY_OUT names one, and a last test writes a captioned README.md beside them. Two runs produce byte-identical files. editor-gallery.yml runs them into docs/gallery after CI passes on main and commits the result back as github-actions[bot], following ImGuiApp's widget-gallery.yml: called from ci.yml rather than as a workflow_run, after the pipeline so the release commit is already on main, and refusing to commit a picture that is not an LFS pointer. No pictures are committed here; the first run on main writes them. Two seams in the editor, neither used outside a test. The right-hand tab bar comes from a widget library that records nothing, so MarkMainTabs records each tab as main-tab/ while a probe is listening, which is how the gallery reaches the class graph and diagnostics. And the class graph gains RequestFitToView, which the gallery calls so the picture shows the whole layout rather than whatever part of it lands on the canvas. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- .github/workflows/ci.yml | 10 + .github/workflows/editor-gallery.yml | 155 ++++++++++++++++ CLAUDE.md | 19 +- README.md | 1 + Schema.Editor/ClassGraphView.cs | 20 ++ Schema.Editor/SchemaEditor.Diagnostics.cs | 5 +- Schema.Editor/SchemaEditor.cs | 54 +++++- .../Schema.Editor.UITests/ClassGraphTests.cs | 8 +- .../Gallery/EditorGallery.cs | 123 +++++++++++++ .../Gallery/GalleryCatalog.cs | 172 ++++++++++++++++++ .../Gallery/GalleryEntry.cs | 51 ++++++ .../Gallery/GalleryFonts.cs | 63 +++++++ .../Gallery/GalleryIndex.cs | 29 +++ .../Gallery/GallerySchema.cs | 96 ++++++++++ .../Schema.Editor.UITests.csproj | 2 + 15 files changed, 793 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/editor-gallery.yml create mode 100644 tests/Schema.Editor.UITests/Gallery/EditorGallery.cs create mode 100644 tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs create mode 100644 tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs create mode 100644 tests/Schema.Editor.UITests/Gallery/GalleryFonts.cs create mode 100644 tests/Schema.Editor.UITests/Gallery/GalleryIndex.cs create mode 100644 tests/Schema.Editor.UITests/Gallery/GallerySchema.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f3bd1f..bb261e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,13 @@ jobs: with: # `inputs` is empty on push, pull_request and schedule; only a dispatch sets it. version-bump: ${{ inputs.version-bump || 'auto' }} + + # Regenerates docs/gallery once the pipeline has released, and commits it back to main. Only for + # a push to main, so nothing from a pull request, and so nothing from a fork, is ever built with + # this job's write access. See editor-gallery.yml. + editor-gallery: + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: ./.github/workflows/editor-gallery.yml + permissions: + contents: write diff --git a/.github/workflows/editor-gallery.yml b/.github/workflows/editor-gallery.yml new file mode 100644 index 0000000..1893f56 --- /dev/null +++ b/.github/workflows/editor-gallery.yml @@ -0,0 +1,155 @@ +name: Editor Gallery + +# Regenerates docs/gallery after CI passes on main, and commits the pictures back to main when they +# differ. Modelled on ImGuiApp's widget-gallery.yml and ImageGui's feature-gallery.yml; the +# reasoning there holds here too, and is repeated where it decides something. +# +# The pictures come from the Gallery scenarios in tests/Schema.Editor.UITests, run with +# SCHEMA_GALLERY_OUT pointing at docs/gallery. Pull requests already run those scenarios with the +# rest of the UI suite, writing to a temporary directory, so a stage that no longer works fails the +# pull request; this workflow only ever builds main, and only to publish the result. +# +# Nothing is committed when the output is byte-identical, which it is run to run: the scenarios draw +# on the CPU rasterizer at a pinned display size, over a sample schema built in code with a made-up +# path, with the font ImGuiApp ships. The only thing that moves a picture is a change to the code +# that draws it. Always on the same runner image, because the pictures are compared with the last +# ones committed. +# +# ci.yml calls this as a job that needs its pipeline, only for a push to main; it can also be run by +# hand on main. It is deliberately not a workflow_run workflow. workflow_run runs with the +# repository's own token and secrets whatever started the run it follows, including a pull request +# from a fork, so building checked-out code there is the pattern githubactions:S7631 exists to catch. +# Here the only things that can start a run are a push to main, which needs write access, and a +# dispatch by someone with write access. +# +# Why after CI rather than beside it: CI's release job commits VERSION.md and friends back to main +# with a plain `git push` from the commit it was started on. A gallery commit landing on main first +# makes that push non-fast-forward, and the release fails. Needing the pipeline means main already +# carries the release commit. A failed pipeline skips this job. +# +# Why the commit back cannot loop or cut a release: it is pushed with the workflow's own +# GITHUB_TOKEN, which starts no workflow run, and it carries the [bot][skip ci] prefix KtsuBuild's +# metadata commits use, so KtsuBuild leaves it out of the version and the changelog either way. +# +# Why it can push to a protected main: the main ruleset lists github-actions[bot] (the identity +# behind GITHUB_TOKEN) as an always-bypass actor, which is also how KtsuBuild pushes its metadata +# commits. If that ever stops being true, the push step fails and says so. +# +# This workflow never runs for a pull request, so it does not belong in dependabot-merge.yml's list +# of pull-request workflows. + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +env: + DOTNET_VERSION: "10.0" + +jobs: + regenerate: + name: Regenerate editor gallery + runs-on: ubuntu-latest + timeout-minutes: 20 + # Only on main, and never on a fork, where there is nothing of ours to commit to. ci.yml only + # calls this for a push to main, so this is what keeps a manual run off other branches. + if: github.repository == 'ktsu-dev/Schema' && github.ref == 'refs/heads/main' + # A newer run supersedes an older one: both regenerate from the current main. On the job rather + # than the workflow, because a called workflow's github.workflow is the caller's name. + concurrency: + group: editor-gallery + cancel-in-progress: true + permissions: + contents: write # To push the regenerated pictures to main + + steps: + # LFS content is not fetched: nothing the scenarios build from is in LFS, and every picture + # they write replaces the pointer that was there. The LFS filters are installed below instead, + # which is what makes git compare and commit the pictures as LFS objects. + - name: Checkout Repository + uses: actions/checkout@v7 + with: + # The current main, which by now carries CI's release commit, not the commit CI ran on. + # Never the triggering event's ref or SHA. + ref: main + fetch-depth: 1 + lfs: false + persist-credentials: true + + - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }}.x + cache: true + cache-dependency-path: | + **/*.csproj + **/Directory.Packages.props + **/global.json + + - name: Install Git LFS filters + run: git lfs install --local + + - name: Build the UI tests + run: dotnet build tests/Schema.Editor.UITests/Schema.Editor.UITests.csproj -c Release + + # The old pictures are removed first so that one whose entry was renamed or removed goes with + # it. A stage that fails fails the job before anything is committed. + - name: Regenerate the gallery + env: + SCHEMA_GALLERY_OUT: ${{ github.workspace }}/docs/gallery + run: | + rm -f docs/gallery/*.png docs/gallery/README.md + tests/Schema.Editor.UITests/bin/Release/net10.0/ktsu.Schema.Editor.UITests \ + --filter "FullyQualifiedName~ktsu.Schema.Editor.UITests.Gallery" + + # Only docs/gallery is staged. The build also rewrites some tracked files (ktsu.Sdk syncs + # .gitignore and friends from its own copy), and none of that belongs in this commit. + - name: Commit and push the pictures + env: + # A rebase below checks files out, and nothing here reads a picture's content, so there + # is no reason to download LFS objects for it. Pointers stay pointers. + GIT_LFS_SKIP_SMUDGE: "1" + run: | + source_sha=$(git rev-parse HEAD) + git add --all docs/gallery + + if git diff --cached --quiet; then + echo "The editor gallery is up to date." + exit 0 + fi + + git diff --cached --stat + + # A PNG committed as a blob rather than as an LFS pointer would bloat the history for good. + for path in $(git diff --cached --name-only --diff-filter=AM -- '*.png'); do + header=$(git cat-file blob ":$path" | head -c 42 | tr -d '\0' || true) + if [[ "$header" != "version https://git-lfs.github.com/spec/v1" ]]; then + echo "::error file=$path::Staged as a raw blob rather than an LFS pointer." + exit 1 + fi + done + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit --quiet --message "[bot][skip ci] Regenerate the editor gallery" \ + --message "Rendered by .github/workflows/editor-gallery.yml from $source_sha." + + # Drop what the build rewrote and was left unstaged, which a rebase refuses to run over. + git reset --hard --quiet HEAD + + # Main may still have moved on since checkout, a manual dispatch racing a release being + # the likely way. The commits touch different files, so a rebase is clean. + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt); rebasing onto the current main." + sleep $((attempt * 5)) + git fetch --depth=50 origin main + git rebase FETCH_HEAD + done + + echo "::error::Could not push the regenerated gallery to main. If main's ruleset does not let github-actions[bot] bypass it, that is why." + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 34aaec7..6fbc75b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -548,10 +548,21 @@ The names are qualified by the ImGui window and any pushed scope, and a test mat part: `menu/New`, `field/ClassNameUser`, `memberId/Delete`, `diagnostic/Error:Users`. Rows that share a label push a probe scope alongside `ImGui.PushID`, so two members' fields do not collide. -One thing has no name to click: the right-hand tab bar comes from a widget library that neither -records its tabs nor takes a selection from outside. A panel behind it - the class graph, the -diagnostics list - is tested by drawing it directly in a `WidgetHarness`, which is what the tab's -own delegate does. +The right-hand tab bar comes from a widget library that neither records its tabs nor takes a +selection from outside, so the editor measures them itself: `MarkMainTabs` records each one as +`main-tab/`, sized the way Dear ImGui sizes a tab, and only when a probe is listening. That is +what lets the gallery reach the class graph and the diagnostics list by clicking. The tests of those +panels predate it and draw them directly in a `WidgetHarness`, which is what the tab's own delegate +does; that is still the quicker route to a panel's behaviour. + +### The gallery + +`tests/Schema.Editor.UITests/Gallery` stages the editor over a sample schema and photographs it. +The scenarios are ordinary tests, so a pull request proves every picture can still be staged; they +write to a temporary directory unless `SCHEMA_GALLERY_OUT` names one. `editor-gallery.yml` runs them +into `docs/gallery` after CI passes on `main` and commits the result, so neither the pictures nor +their `README.md` are written by hand. A picture has to come out byte-identical run to run: no +clock, no machine path (the sample schema's path is made up), nothing random. ### What a Linux-only suite stops seeing diff --git a/README.md b/README.md index ee62bcf..b6588cd 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ Use `SchemaSerializer` for JSON serialization with `System.Text.Json`. The seria ## Schema Editor The **Schema.Editor** is an ImGui-based desktop application for visually creating and editing `.schema.json` files. +The [editor gallery](docs/gallery/README.md) shows what it looks like, regenerated from `main` on every build. ### Running the Editor diff --git a/Schema.Editor/ClassGraphView.cs b/Schema.Editor/ClassGraphView.cs index a46fef2..0355b53 100644 --- a/Schema.Editor/ClassGraphView.cs +++ b/Schema.Editor/ClassGraphView.cs @@ -34,6 +34,10 @@ internal sealed class ClassGraphView // changes. Rebuilding every frame would reset the force-directed layout and node positions. private string lastSignature = string.Empty; + // Set by RequestFitToView and spent on the next frame drawn, which is the first moment the size + // of the canvas is known. + private bool fitToViewRequested; + public ClassGraphView() => // The force-directed layout is disabled by default; enable it so the graph self-arranges. engine.UpdatePhysicsSettings(engine.PhysicsSettings with { Enabled = true }); @@ -75,6 +79,12 @@ public void Show(Schema? schema, float deltaTime) engine.SetDraggedNodes(renderer.CurrentlyDraggedNodes); engine.UpdatePhysics(deltaTime); + if (fitToViewRequested) + { + renderer.FitToView(engine, editorSize); + fitToViewRequested = false; + } + renderer.Render(engine, editorSize); // Node transforms must be read back after rendering, while the editor context is active. @@ -221,6 +231,16 @@ private static float SeedRadius(int count, Vector2 centre) return roomOnCanvas > 0.0f ? MathF.Min(wanted, roomOnCanvas) : wanted; } + /// + /// Brings the whole graph into view on the next frame drawn: centred, and zoomed out far enough + /// to fit. + /// + /// + /// Nothing in the editor asks for this yet; the gallery does, so that a picture of the graph + /// shows all of it rather than whatever part of a wide layout happens to fall on the canvas. + /// + internal void RequestFitToView() => fitToViewRequested = true; + /// /// Gets where the nodes sit, measured from the canvas's top-left corner. /// diff --git a/Schema.Editor/SchemaEditor.Diagnostics.cs b/Schema.Editor/SchemaEditor.Diagnostics.cs index 2513dd9..136dd75 100644 --- a/Schema.Editor/SchemaEditor.Diagnostics.cs +++ b/Schema.Editor/SchemaEditor.Diagnostics.cs @@ -75,9 +75,8 @@ internal void UpdateValidation(float dt) /// Draws the issue list behind the diagnostics tab. /// /// - /// Internal so a test can draw it directly. The tab bar hosting it comes from a widget library - /// that neither records its tabs for a probe nor takes a selection from outside, so there is no - /// tab for a test to click; drawing the panel is what the tab delegate does either way. + /// Internal so a test can draw it directly, which is what the tab delegate does either way and + /// is quicker than reaching it through the tab, recorded by . /// internal void ShowDiagnosticsPanel() { diff --git a/Schema.Editor/SchemaEditor.cs b/Schema.Editor/SchemaEditor.cs index 282b435..112e3bf 100644 --- a/Schema.Editor/SchemaEditor.cs +++ b/Schema.Editor/SchemaEditor.cs @@ -5,7 +5,10 @@ namespace ktsu.Schema.Editor; using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Linq; +using System.Numerics; using Hexa.NET.ImGui; @@ -48,12 +51,16 @@ public partial class SchemaEditor private CodeGeneratorPanel CodeGeneratorPanel { get; init; } private MemberGridPanel MemberGrid { get; init; } - private ClassGraphView ClassGraph { get; } = new(); + internal ClassGraphView ClassGraph { get; } = new(); private ImGuiWidgets.TabPanel MainTabs { get; } // The tab whose label carries the validation counts. private readonly string diagnosticsTabId; + // The name each main tab is recorded under, by tab id: its first label, so the diagnostics tab + // is found by what it is rather than by the counts its label carries at the time. + private readonly Dictionary mainTabNames = []; + // Tab content delegates are parameterless, so the current frame's delta is stashed here for them. private float currentDeltaTime; @@ -77,9 +84,9 @@ public SchemaEditor() // The tab bar lives inside the right divider zone, which is already a child window, so the tab // content flows from the cursor without overlapping the bar. The left zone keeps the schema tree. MainTabs = new ImGuiWidgets.TabPanel("MainViews", closable: false, reorderable: false); - MainTabs.AddTab("Editor", ShowEditorPanel); - MainTabs.AddTab("Class Graph", () => ClassGraph.Show(CurrentSchema, currentDeltaTime)); - diagnosticsTabId = MainTabs.AddTab(DiagnosticsTab.Name, ShowDiagnosticsPanel); + AddMainTab("Editor", ShowEditorPanel); + AddMainTab("Class Graph", () => ClassGraph.Show(CurrentSchema, currentDeltaTime)); + diagnosticsTabId = AddMainTab(DiagnosticsTab.Name, ShowDiagnosticsPanel); Options = AppData.LoadOrCreate(); Popups = Options.Popups; @@ -260,9 +267,48 @@ internal void OnRender(float dt) private void ShowRightPanel(float dt) { DiagnosticsTab.ShowCounts(MainTabs, diagnosticsTabId, ErrorCount, WarningCount); + MarkMainTabs(); MainTabs.Draw(); } + private string AddMainTab(string name, Action content) + { + string id = MainTabs.AddTab(name, content); + mainTabNames[id] = name; + return id; + } + + /// + /// Records where each of the main tabs is about to be drawn. + /// + /// + /// The tab bar comes from a widget library that neither records its tabs nor takes a selection + /// from outside, so without this nothing behind the Class Graph or Diagnostics tab could be + /// reached the way a user reaches it. Each tab is measured the way Dear ImGui sizes a tab with no + /// close button - its label, the frame padding either side and one pixel - and the tabs sit end to + /// end, the inner item spacing apart. A tab bar only shrinks its tabs when they do not fit, which + /// three short labels across three quarters of the window do not need. Nothing is measured when no + /// probe is listening, which is every run that is not a test. + /// + private void MarkMainTabs() + { + if (!ImGuiProbes.IsRecording) + { + return; + } + + ImGuiStylePtr style = ImGui.GetStyle(); + Vector2 tabMin = ImGui.GetCursorScreenPos(); + float tabHeight = ImGui.GetFrameHeight(); + + foreach (ImGuiWidgets.Tab tab in MainTabs.Tabs.Where(tab => tab.IsVisible)) + { + float tabWidth = ImGui.CalcTextSize(tab.Label).X + (style.FramePadding.X * 2f) + 1f; + ImGuiProbes.MarkRegion($"main-tab/{mainTabNames[tab.Id]}", tabMin, tabMin + new Vector2(tabWidth, tabHeight)); + tabMin.X += tabWidth + style.ItemInnerSpacing.X; + } + } + private void ShowEditorPanel() { ShowSchemaConfig(); diff --git a/tests/Schema.Editor.UITests/ClassGraphTests.cs b/tests/Schema.Editor.UITests/ClassGraphTests.cs index 098dbe5..455c86a 100644 --- a/tests/Schema.Editor.UITests/ClassGraphTests.cs +++ b/tests/Schema.Editor.UITests/ClassGraphTests.cs @@ -18,10 +18,10 @@ namespace ktsu.Schema.Editor.UITests; /// 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. +/// Driven through rather than through the editor. The graph lives +/// behind a tab, which the editor records as main-tab/Class Graph and the gallery clicks, +/// but drawing the view directly reaches the same code with nothing else on screen, and is the same +/// thing the editor's tab delegate does. /// [TestClass] public sealed class ClassGraphTests diff --git a/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs b/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs new file mode 100644 index 0000000..d1b4608 --- /dev/null +++ b/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests.Gallery; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using ktsu.ImGui.App.Testing; + +/// +/// Photographs the editor for docs/gallery: one picture per +/// entry, and an index that captions them. +/// +/// +/// +/// These run as tests, so every pull request proves each picture can still be staged: a stage that +/// clicks a control which has moved or been renamed fails here rather than at the next regeneration +/// on main. Pictures are written to a temporary directory unless SCHEMA_GALLERY_OUT names +/// one, which is how the gallery workflow, and anyone regenerating by hand, sends them to +/// docs/gallery. +/// +/// +/// Each entry starts its own editor, so a picture never depends on the one taken before it. +/// +/// +[TestClass] +public sealed class EditorGallery +{ + /// The environment variable naming the directory the gallery is written to. + internal const string OutputVariable = "SCHEMA_GALLERY_OUT"; + + /// The display the gallery is drawn at: larger than a test's, so the panels have room. + internal static readonly HarnessOptions Display = new() { Width = 1440, Height = 900 }; + + private static readonly Lazy TemporaryOutput = new(() => + Path.Combine(Path.GetTempPath(), $"schema-gallery-{Guid.NewGuid():N}")); + + /// Gets or sets the context the runner reports through. + public TestContext TestContext { get; set; } = null!; + + /// Gets every entry's name, one test case each. + public static IEnumerable EntryNames => GalleryCatalog.Entries.Select(entry => new object[] { entry.Name }); + + /// Gets the directory pictures are written to. + internal static string OutputDirectory => + Environment.GetEnvironmentVariable(OutputVariable) is string output && output.Length > 0 + ? Path.GetFullPath(output) + : TemporaryOutput.Value; + + /// Removes the temporary directory, when pictures went there rather than to a named one. + [ClassCleanup] + public static void DeleteTemporaryOutput() + { + if (TemporaryOutput.IsValueCreated && Directory.Exists(TemporaryOutput.Value)) + { + Directory.Delete(TemporaryOutput.Value, recursive: true); + } + } + + [TestMethod] + [DynamicData(nameof(EntryNames))] + public void Photograph(string name) + { + GalleryEntry entry = GalleryCatalog.Entries.Single(candidate => candidate.Name == name); + + EditField.Reset(); + using EditorHarness harness = EditorHarness.Start(Display); + try + { + Assert.IsTrue(GalleryFonts.Load(), "ImGuiApp's own font could not be found, so the pictures would not look like the editor."); + harness.App.Mouse.MoveTo(-100f, -100f); + harness.App.Step(2); + + entry.Stage(harness); + harness.App.Step(2); + + Bitmap32 frame = harness.App.Target; + Rectangle region = entry.Crop?.Invoke(harness) ?? new Rectangle(0, 0, frame.Width, frame.Height); + Bitmap32 picture = Crop(frame, region); + + Directory.CreateDirectory(OutputDirectory); + string path = Path.Combine(OutputDirectory, entry.Slug + ".png"); + picture.SavePng(path); + TestContext.WriteLine($"Wrote {path} ({picture.Width}x{picture.Height})."); + } + finally + { + EditField.Reset(); + } + } + + [TestMethod] + public void WriteTheIndex() + { + string[] slugs = [.. GalleryCatalog.Entries.Select(entry => entry.Slug)]; + Assert.HasCount(slugs.Length, slugs.Distinct(StringComparer.Ordinal), "Two gallery entries would write the same file."); + + Directory.CreateDirectory(OutputDirectory); + File.WriteAllText(Path.Combine(OutputDirectory, "README.md"), GalleryIndex.Render(GalleryCatalog.Entries)); + } + + /// Copies a rectangle out of a frame, clamped to its edges. + internal static Bitmap32 Crop(Bitmap32 source, Rectangle region) + { + int minX = Math.Clamp(region.MinX, 0, source.Width); + int minY = Math.Clamp(region.MinY, 0, source.Height); + int maxX = Math.Clamp(region.MaxX, minX, source.Width); + int maxY = Math.Clamp(region.MaxY, minY, source.Height); + Assert.IsTrue(maxX > minX && maxY > minY, $"The crop {region} leaves nothing of a {source.Width}x{source.Height} frame."); + + Bitmap32 cropped = new(maxX - minX, maxY - minY); + int rowBytes = cropped.Width * 4; + for (int y = minY; y < maxY; y++) + { + source.Pixels.Slice(((y * source.Width) + minX) * 4, rowBytes) + .CopyTo(cropped.Pixels.Slice((y - minY) * rowBytes, rowBytes)); + } + + return cropped; + } +} diff --git a/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs b/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs new file mode 100644 index 0000000..72402e8 --- /dev/null +++ b/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs @@ -0,0 +1,172 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests.Gallery; + +using System.Collections.Generic; + +using ktsu.ImGui.App.Testing; +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; + +/// Every picture in the editor gallery, in the order the index shows them. +/// +/// Each stage after the first starts with open and drives the editor by +/// the same names the other tests click. A new view earns a picture by adding an entry here; +/// nothing else needs to change, because the runner, the index and the workflow all read this list. +/// +internal static class GalleryCatalog +{ + /// The class graph's physics runs this many frames before it is photographed. + private const int GraphSettleFrames = 300; + + /// Gets the entries. + internal static IReadOnlyList Entries { get; } = + [ + new( + "Starting a schema", + "The editor opens on whatever was open last, or on nothing. The File menu creates, opens and saves `.schema.json` files and keeps the recent ones to hand.", + harness => + { + harness.OpenMenu("File"); + Park(harness); + }) + { + Crop = _ => new Rectangle(0, 0, 720, 450), + }, + new( + "Editing a class", + "The trees on the left hold the schema's classes, enums, data sources and code generators. Choosing a class opens its members as a grid: each one named, typed and described, and reordered or removed from its row.", + harness => + { + Open(harness); + Park(harness); + }), + new( + "Member metadata", + "Behind each member is what its type cannot say: the unit it is measured in, the range it may take, its default, how it is interpolated, and how it travels over a network.", + harness => + { + Open(harness); + harness.Click($"member{GallerySchema.Speed}/ToggleSemantics"); + Park(harness); + }), + new( + "Choosing a type", + "A member's type is picked from the built-in types and everything the schema declares, searchable by name.", + harness => + { + Open(harness); + harness.Click("memberInventory/Type"); + harness.StepUntil(() => harness.IsOnScreen("searchable-list/search"), "the type picker opening"); + Park(harness); + }), + new( + "Choosing a unit", + "Units come from ktsu.Semantics.Quantities rather than a list kept here, each offered by name and symbol.", + harness => + { + Open(harness); + harness.Click($"member{GallerySchema.Speed}/ToggleSemantics"); + harness.Click($"member{GallerySchema.Speed}/Unit"); + harness.TypeInto("searchable-list/search", "PerSecond"); + harness.App.Step(2); + Park(harness); + }), + new( + "Editing an enum", + "An enum is a named set of values any class can use as a member's type.", + harness => + { + Load(harness); + harness.SelectTree(TreeSchema.EnumsTab); + harness.Click("BtnRarity"); + Park(harness); + }), + new( + "Code generators", + "A code generator writes the schema out as source in a target language, into a directory relative to the schema file.", + harness => + { + Load(harness); + harness.SelectTree(TreeSchema.CodeGeneratorsTab); + harness.Click("BtnCSharp"); + Park(harness); + }), + new( + "Class graph", + "Every class and enum as a node, with an edge for each member that refers to another, laid out by a force simulation.", + harness => + { + Open(harness); + harness.Click("main-tab/Class Graph"); + harness.App.Step(GraphSettleFrames); + harness.Editor.ClassGraph.RequestFitToView(); + Park(harness); + }), + new( + "Diagnostics", + "The schema is validated as it is edited. The tab carries the counts, and each issue names the element it is about; clicking one selects that element.", + harness => + { + Schema schema = Open(harness); + schema.GetClass(GallerySchema.Player.As())!.AddMember("Score".As()); + schema.AddDataSource("Players".As()); + Validate(harness); + harness.Click("main-tab/Diagnostics"); + Park(harness); + }), + new( + "Themes", + "The editor takes its colours from ktsu.ThemeProvider, and remembers the theme chosen in the browser.", + harness => + { + Open(harness); + EditorTheme.OpenBrowser(); + harness.StepUntil(() => harness.IsOnScreen("theme-card/Dracula"), "the theme browser opening"); + harness.App.Step(3); + Park(harness); + }), + ]; + + /// Opens the sample schema with its main class selected, as a user would find it. + private static Schema Open(EditorHarness harness) + { + Schema schema = Load(harness); + harness.SelectTree(TreeSchema.ClassesTab); + harness.Click($"Btn{GallerySchema.Player}"); + return schema; + } + + /// + /// Opens the sample schema with nothing selected. + /// + /// + /// For a picture of something other than a class. Opening the class tree first would leave the + /// probe knowing two rows called BtnRarity, the enum and the item's member of that type, + /// and a name that matches two rows is one a click refuses to guess between. + /// + private static Schema Load(EditorHarness harness) + { + Schema schema = GallerySchema.Build(); + harness.Editor.CurrentSchema = schema; + harness.Editor.CurrentSchemaPath = GallerySchema.Path; + Validate(harness); + return schema; + } + + /// Validates now rather than after the editor's debounce, and draws the result. + private static void Validate(EditorHarness harness) + { + harness.Editor.RequestValidation(); + harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds); + harness.App.Step(2); + } + + /// Moves the pointer off the window, so nothing in the picture is hovered. + private static void Park(EditorHarness harness) + { + harness.App.Mouse.MoveTo(-100f, -100f); + harness.App.Step(3); + } +} diff --git a/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs b/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs new file mode 100644 index 0000000..34ef35b --- /dev/null +++ b/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests.Gallery; + +using System; +using System.Text; + +using ktsu.ImGui.App.Testing; + +/// One picture in the editor gallery: how to stage it, and what to say about it. +/// The caption, which also names the picture's file. +/// One or two sentences under the picture in the gallery's index. +/// +/// Drives the editor into the state worth photographing, starting from the sample schema open and +/// settled. It works the way a user would where it can - menus, rows and buttons clicked by the names +/// the editor records for them - so the picture is of a path through the editor rather than of +/// state set behind its back. +/// +internal sealed record GalleryEntry(string Name, string Description, Action Stage) +{ + /// + /// Gets the part of the window to keep, or null for all of it. Asked after + /// has run, so it can measure what the stage put on screen. + /// + public Func? Crop { get; init; } + + /// Gets the file name the picture is written under, without its extension. + public string Slug => MakeSlug(Name); + + /// + public override string ToString() => Name; + + /// Turns a caption into a lower-case, hyphenated file name. + internal static string MakeSlug(string text) + { + StringBuilder slug = new(text.Length); + foreach (char character in text) + { + if (char.IsAsciiLetterOrDigit(character)) + { + slug.Append(char.ToLowerInvariant(character)); + } + else if (slug.Length > 0 && slug[^1] != '-') + { + slug.Append('-'); + } + } + + return slug.ToString().TrimEnd('-'); + } +} diff --git a/tests/Schema.Editor.UITests/Gallery/GalleryFonts.cs b/tests/Schema.Editor.UITests/Gallery/GalleryFonts.cs new file mode 100644 index 0000000..2132823 --- /dev/null +++ b/tests/Schema.Editor.UITests/Gallery/GalleryFonts.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests.Gallery; + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Resources; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App; + +/// Gives the gallery the font a running editor draws in. +/// +/// The harness never builds ImGuiApp's own fonts, so a test draws in Dear ImGui's built-in +/// bitmap font. That is right for the tests, which measure layout and must not move when a font +/// does, and wrong for pictures of the editor. This loads the Nerd Font ImGuiApp ships and +/// makes it the default, which is what the widget gallery in ImGuiApp does for the same reason. +/// +internal static class GalleryFonts +{ + /// The size ImGuiApp draws its interface at, at a scale of one. + private const float Pixels = 14f; + + private static readonly Lazy NerdFont = new(() => + { + try + { + ResourceManager resources = new("ktsu.ImGui.App.Resources.Resources", typeof(ImGuiApp).Assembly); + return resources.GetObject("NerdFont", CultureInfo.InvariantCulture) as byte[]; + } + catch (MissingManifestResourceException) + { + return null; + } + }); + + /// Adds the application's font and makes it the default. Call between frames. + /// True when the font was loaded; false leaves the built-in font in place. + [SuppressMessage("Major Code Smell", "S6640:Make sure that using \"unsafe\" is safe here", Justification = "FontHelper's glyph range helper returns a pointer ImGui owns for the atlas's lifetime; it is passed straight through and never dereferenced here.")] + internal static bool Load() + { + if (NerdFont.Value is not byte[] data) + { + return false; + } + + ImGuiIOPtr io = ImGui.GetIO(); + unsafe + { + ImFontPtr? font = FontHelper.AddCustomFont(io, data, Pixels, FontHelper.GetExtendedUnicodeRanges(io.Fonts)); + if (font is not ImFontPtr loaded) + { + return false; + } + + io.FontDefault = loaded; + } + + return true; + } +} diff --git a/tests/Schema.Editor.UITests/Gallery/GalleryIndex.cs b/tests/Schema.Editor.UITests/Gallery/GalleryIndex.cs new file mode 100644 index 0000000..e09f3f9 --- /dev/null +++ b/tests/Schema.Editor.UITests/Gallery/GalleryIndex.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests.Gallery; + +using System.Collections.Generic; +using System.Text; + +/// Writes the gallery's README.md, which captions each picture. +internal static class GalleryIndex +{ + /// Renders the index for the given entries, in catalog order. + internal static string Render(IReadOnlyList entries) + { + StringBuilder text = new(); + text.Append("# Editor gallery\n\n"); + text.Append("\n\n"); + text.Append("The Schema editor as it draws today, working on a small sample schema for a game, staged and photographed headlessly by the UI test harness. "); + text.Append("The pictures are regenerated on every successful build of `main`, so they show the current editor.\n\n"); + + foreach (GalleryEntry entry in entries) + { + text.Append("## ").Append(entry.Name).Append("\n\n"); + text.Append(entry.Description).Append("\n\n"); + text.Append("![").Append(entry.Name).Append("](").Append(entry.Slug).Append(".png)\n\n"); + } + + return text.ToString(); + } +} diff --git a/tests/Schema.Editor.UITests/Gallery/GallerySchema.cs b/tests/Schema.Editor.UITests/Gallery/GallerySchema.cs new file mode 100644 index 0000000..5c3162d --- /dev/null +++ b/tests/Schema.Editor.UITests/Gallery/GallerySchema.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests.Gallery; + +using System; + +using ktsu.Schema.Generation; +using ktsu.Schema.Models; +using ktsu.Schema.Models.Metadata; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; + +using SchemaTypes = ktsu.Schema.Models.Types; + +/// The sample schema every gallery picture is taken of. +/// +/// Built in code rather than read from a file, so the pictures depend on nothing outside the test +/// assembly. It is small enough to read in a picture and covers what the editor is for: classes +/// referring to one another and to an enum, an array, a vector, members carrying units, ranges and +/// defaults, and a code generator. The path it claims to be saved at is the same on every machine, +/// so the editor's header reads the same in every picture; nothing is ever written there. +/// +internal static class GallerySchema +{ + /// The class the gallery edits most. + internal const string Player = "Player"; + + /// The member whose metadata the gallery shows. + internal const string Speed = "Speed"; + + /// Gets the path the schema claims to be saved at. + internal static AbsoluteFilePath Path { get; } = + (OperatingSystem.IsWindows() ? @"C:\Projects\Game\game.schema.json" : "/projects/game/game.schema.json").As(); + + /// Builds the schema. + internal static Schema Build() + { + Schema schema = new(); + schema.SetSourceFile(Path); + + SchemaEnum rarity = schema.AddEnum("Rarity".As())!; + rarity.Description = "How hard an item is to come by.".As(); + foreach (string value in (string[])["Common", "Uncommon", "Rare", "Epic", "Legendary"]) + { + rarity.TryAddValue(value.As()); + } + + SchemaClass item = schema.AddClass("Item".As())!; + item.Description = "Anything a player can pick up and carry.".As(); + item.AddMember("Name".As())!.SetType(new SchemaTypes.String()); + item.AddMember("Rarity".As())!.SetType(new SchemaTypes.Enum() { EnumName = rarity.Name }); + SchemaMember weight = item.AddMember("Weight".As())!; + weight.SetType(new SchemaTypes.Float()); + weight.Unit = "kg".As(); + weight.Range = new MemberRange { Minimum = 0, Maximum = 50 }; + item.AddMember("Value".As())!.SetType(new SchemaTypes.Int()); + + SchemaClass inventory = schema.AddClass("Inventory".As())!; + inventory.Description = "What a player is carrying.".As(); + inventory.AddMember("Items".As())!.SetType(new SchemaTypes.Array() + { + ElementType = new SchemaTypes.Object() { ClassName = item.Name }, + Container = SchemaTypes.Array.VectorContainer.As(), + }); + SchemaMember capacity = inventory.AddMember("Capacity".As())!; + capacity.SetType(new SchemaTypes.Int()); + capacity.Range = new MemberRange { Minimum = 1, Maximum = 64 }; + capacity.DefaultValue = new NumberDefault { Value = 20 }; + + SchemaClass player = schema.AddClass(Player.As())!; + player.Description = "Someone playing the game, and everything the game remembers about them.".As(); + player.AddMember("Name".As())!.SetType(new SchemaTypes.String()); + SchemaMember health = player.AddMember("Health".As())!; + health.SetType(new SchemaTypes.Int()); + health.Range = new MemberRange { Minimum = 0, Maximum = 100 }; + health.DefaultValue = new NumberDefault { Value = 100 }; + player.AddMember("Position".As())!.SetType(new SchemaTypes.Vector3()); + SchemaMember speed = player.AddMember(Speed.As())!; + speed.SetType(new SchemaTypes.Float()); + speed.Description = "How fast the player walks.".As(); + speed.Unit = "m/s".As(); + speed.Range = new MemberRange { Minimum = 0, Maximum = 12 }; + speed.DefaultValue = new NumberDefault { Value = 5 }; + speed.Interpolation = Interpolation.Linear; + player.AddMember("Inventory".As())!.SetType(new SchemaTypes.Object() { ClassName = inventory.Name }); + + SchemaCodeGenerator csharp = schema.AddCodeGenerator("CSharp".As())!; + csharp.Description = "The game's data types, as C#.".As(); + csharp.Language = CSharpCodeGenerator.LanguageId.As(); + csharp.Namespace = "Game.Data".As(); + csharp.OutputPath = "Generated".As(); + + return schema; + } +} diff --git a/tests/Schema.Editor.UITests/Schema.Editor.UITests.csproj b/tests/Schema.Editor.UITests/Schema.Editor.UITests.csproj index 3d2bab3..9027e2e 100644 --- a/tests/Schema.Editor.UITests/Schema.Editor.UITests.csproj +++ b/tests/Schema.Editor.UITests/Schema.Editor.UITests.csproj @@ -4,6 +4,8 @@ true + + true From eaca24123913e10af0a29cdf702342fa9cc88450 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:08:55 +0000 Subject: [PATCH 2/2] Use Path.Join for the gallery's output paths Path.Combine discards everything before a rooted segment, which the code-quality check flags. None of these segments is rooted, so Join produces the same paths without the hazard. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- tests/Schema.Editor.UITests/Gallery/EditorGallery.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs b/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs index d1b4608..9ac9d2b 100644 --- a/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs +++ b/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs @@ -35,7 +35,7 @@ public sealed class EditorGallery internal static readonly HarnessOptions Display = new() { Width = 1440, Height = 900 }; private static readonly Lazy TemporaryOutput = new(() => - Path.Combine(Path.GetTempPath(), $"schema-gallery-{Guid.NewGuid():N}")); + Path.Join(Path.GetTempPath(), $"schema-gallery-{Guid.NewGuid():N}")); /// Gets or sets the context the runner reports through. public TestContext TestContext { get; set; } = null!; @@ -81,7 +81,7 @@ public void Photograph(string name) Bitmap32 picture = Crop(frame, region); Directory.CreateDirectory(OutputDirectory); - string path = Path.Combine(OutputDirectory, entry.Slug + ".png"); + string path = Path.Join(OutputDirectory, entry.Slug + ".png"); picture.SavePng(path); TestContext.WriteLine($"Wrote {path} ({picture.Width}x{picture.Height})."); } @@ -98,7 +98,7 @@ public void WriteTheIndex() Assert.HasCount(slugs.Length, slugs.Distinct(StringComparer.Ordinal), "Two gallery entries would write the same file."); Directory.CreateDirectory(OutputDirectory); - File.WriteAllText(Path.Combine(OutputDirectory, "README.md"), GalleryIndex.Render(GalleryCatalog.Entries)); + File.WriteAllText(Path.Join(OutputDirectory, "README.md"), GalleryIndex.Render(GalleryCatalog.Entries)); } /// Copies a rectangle out of a frame, clamped to its edges.