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..9ac9d2b --- /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.Join(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.Join(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.Join(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