diff --git a/.github/workflows/app-gallery.yml b/.github/workflows/app-gallery.yml new file mode 100644 index 0000000..d4c2025 --- /dev/null +++ b/.github/workflows/app-gallery.yml @@ -0,0 +1,161 @@ +name: App Gallery + +# Regenerates docs/gallery after CI finishes on main, and commits the images back to main when they +# differ. Nothing is committed when the output is byte-identical, which it is run to run: the +# gallery is a set of headless UI tests (tests/ProjectDirector.UITests/Gallery) rendering on the +# CPU rasterizer over three git repositories seeded offline with pinned names, dates and contents, +# with the settings file and the secret store replaced by in-memory ones and the log panel, whose +# lines carry wall-clock times, cropped out. The only thing that moves a picture is a change to the +# code that draws it. +# +# 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, so nothing a fork wrote is ever checked out or built with +# this job's permissions. +# +# 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, and CI's own cancel-in-progress group means no other main run is part +# way to a release of its own when this one pushes. A failed pipeline skips this job. +# +# There is no paths filter, because the triggering commit is not the only one this run covers: CI +# cancels a superseded run, so a change to a panel can reach main under the CI run of the push after +# it. Regenerating after every successful main run costs a couple of minutes and commits nothing +# when nothing changed. +# +# Why the commit back cannot loop or cut a release: +# - It is pushed with the workflow's own GITHUB_TOKEN, and GitHub starts no workflow run for a +# push made with that token. Neither CI nor, through it, this workflow sees it. +# - It carries `[bot][skip ci]`, the prefix KtsuBuild's own metadata commits use. Should the +# token ever change to one that does trigger runs, GitHub still skips push workflows for it, +# and KtsuBuild leaves it out of the version calculation 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. No extra token or ruleset change is needed. +# +# 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 app 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/ProjectDirector' && github.ref == 'refs/heads/main' + # A newer run supersedes an older one: both regenerate from the current main, so an older run + # that is still going has nothing left worth committing. Set on the job rather than the + # workflow, because a called workflow's github.workflow is the caller's name. + concurrency: + group: app-gallery + cancel-in-progress: true + permissions: + contents: write # To push the regenerated images to main + + steps: + # LFS content is not fetched: nothing the gallery builds from is in LFS, and every image it + # writes replaces the pointer that was there. The LFS filters are installed below instead, + # which is what makes git compare and commit the images 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/ProjectDirector.UITests/ProjectDirector.UITests.csproj -c Release + + # The old images and index are removed first so that a picture whose entry was renamed or + # removed goes with it, rather than lingering beside its replacement. A stage that fails is a + # failed test, which fails the job before anything is committed. + - name: Regenerate the gallery + env: + PROJECTDIRECTOR_GALLERY_OUT: docs/gallery + run: | + rm -f docs/gallery/*.png docs/gallery/README.md + tests/ProjectDirector.UITests/bin/Release/net10.0/ktsu.ProjectDirector.UITests \ + --filter "FullyQualifiedName~ktsu.ProjectDirector.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 images + env: + # A rebase below checks files out, and nothing here reads an image'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 app 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, so refuse to push one. + 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 app gallery" \ + --message "Rendered by .github/workflows/app-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." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f3bd1f..87f294a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,12 @@ 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 passed on a push to main. It has to run after + # the pipeline, not beside it: see the header of app-gallery.yml. + app-gallery: + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: ./.github/workflows/app-gallery.yml + permissions: + contents: write diff --git a/CLAUDE.md b/CLAUDE.md index c9bad2c..f950d76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,8 @@ dotnet publish --configuration Release --output ./staging Tests live in `ProjectDirector.Test` (MSTest, via `MSTest.Sdk` + `ktsu.Sdk`). The app exposes its internals to the test project through `InternalsVisibleTo` in `ProjectDirector/AssemblyInfo.cs`. `GitCliTests` drives `GitCli` against throwaway repositories under the temp directory; the ImGui layer is not unit-tested. +`tests/ProjectDirector.UITests` drives the real application headlessly through `ktsu.ImGui.App.Testing` to render the app gallery (`Gallery/`), and exists for that purpose. It reaches the app through `ProjectDirector.BuildConfig()` and the internal options constructor. It replaces the settings file and the secret store with in-memory ones, and points the app at three repositories it seeds offline under `/tmp/projectdirector-gallery`. That path is fixed because it appears in the pictures. Every picture is cropped above the log panel, because the log prints wall-clock timestamps. `PROJECTDIRECTOR_GALLERY_OUT` names the output directory; `.github/workflows/app-gallery.yml` regenerates `docs/gallery/` after CI passes on `main`. A new view or dialog worth showing gets an entry in `Gallery/GalleryCatalog.cs`. + That last point is why the repository actions are shaped the way they are: the part of each with a rule in it is pulled out into a plain method so it can be driven without a live ImGui context or a display. `ProjectDirector.DecidePull` decides whether pulling interrupts the user first (`PullDecisionTests`), and `GitCli.ListPendingChanges` plus `ProjectDirector.DescribePendingChanges` decide what a commit will sweep up and how that is shown (`CommitTests`). `DiffTake` builds the file a take arrow in the diff view produces and writes it back with the destination's own line ending and BOM (`DiffTakeTests`). Anything genuinely worth testing that is still tangled up with drawing is usually worth extracting the same way. ```powershell diff --git a/Directory.Packages.props b/Directory.Packages.props index c1a96ac..b102848 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,9 @@ + + @@ -26,5 +28,6 @@ + \ No newline at end of file diff --git a/ProjectDirector.sln b/ProjectDirector.sln index 308d12d..3e1b417 100644 --- a/ProjectDirector.sln +++ b/ProjectDirector.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector", "ProjectD EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.Test", "ProjectDirector.Test\ProjectDirector.Test.csproj", "{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.UITests", "tests\ProjectDirector.UITests\ProjectDirector.UITests.csproj", "{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.Build.0 = Debug|Any CPU {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.ActiveCfg = Release|Any CPU {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.Build.0 = Release|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectDirector/AssemblyInfo.cs b/ProjectDirector/AssemblyInfo.cs index 4d64088..fdf24c2 100644 --- a/ProjectDirector/AssemblyInfo.cs +++ b/ProjectDirector/AssemblyInfo.cs @@ -1,3 +1,4 @@ // Copyright (c) 2023-2026 ktsu-dev contributors [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.Test")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.UITests")] diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 1ea98b6..138a267 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -15,6 +15,7 @@ namespace ktsu.ProjectDirector; using ktsu.Extensions; using ktsu.ImGui.App; using ktsu.ImGui.Popups; +using ktsu.ImGui.Probes; using ktsu.ImGui.Widgets; using ktsu.ImGui.Styler; using Octokit; @@ -63,23 +64,39 @@ internal sealed class ProjectDirector // private ChatClient ChatClient { get; init; } - private static void Main(string[] _) + private static void Main(string[] _) => ImGuiApp.Start(new ProjectDirector().BuildConfig()); + + /// + /// Builds the configuration starts the application with. + /// + /// The configuration, wired to this instance. + /// + /// Separate from so a headless test can start exactly the configuration the + /// application runs, rather than a copy of it. + /// + internal ImGuiAppConfig BuildConfig() => new() { - ProjectDirector projectDirector = new(); - ImGuiApp.Start(new() - { - Title = "Project Director", - OnAppMenu = projectDirector.ShowMenu, - OnMoveOrResize = projectDirector.WindowResized, - OnRender = projectDirector.Tick, - }); - } + Title = "Project Director", + OnAppMenu = ShowMenu, + OnMoveOrResize = WindowResized, + OnRender = Tick, + }; private const int LogLinesMax = 100; public ProjectDirector() + : this(ProjectDirectorOptions.LoadOrCreate()) + { + } + + /// + /// Initializes a new instance of the class over options the caller + /// supplies, instead of the ones saved on this machine. + /// + /// The options to start from. + internal ProjectDirector(ProjectDirectorOptions options) { - Options = ProjectDirectorOptions.LoadOrCreate(); + Options = options; _ = MakeLoadedOptionsSafe(Options, QueueLog); @@ -829,6 +846,7 @@ private void ShowBottomPanel(float dt) } ImGui.EndChild(); + ImGuiProbes.MarkItem("Log"); } private void ShowLeftPanel(float dt) @@ -886,9 +904,13 @@ private void ShowCollapsiblePanel(string name, Action contentDelegate) private void ShowMenu() { - if (ImGui.BeginMenu("File")) + bool fileMenuOpen = ImGui.BeginMenu("File"); + ImGuiProbes.MarkItem("menu/File"); + if (fileMenuOpen) { - if (ImGui.MenuItem("Set Dev Directory")) + bool setDevDirectory = ImGui.MenuItem("Set Dev Directory"); + ImGuiProbes.MarkItem("menu/Set Dev Directory"); + if (setDevDirectory) { PopupSetDevDirectory.Open("Set Dev Directory?", "Set Dev Directory?", Options.DevDirectory, result => { @@ -915,7 +937,9 @@ private void ShowMenu() ImGui.Separator(); - if (ImGui.MenuItem("Add New GitHub Owner")) + bool addOwner = ImGui.MenuItem("Add New GitHub Owner"); + ImGuiProbes.MarkItem("menu/Add New GitHub Owner"); + if (addOwner) { PopupAddNewGitHubOwner.Open("New Owner Name?", "New Owner Name?", "ktsu-io", result => { @@ -988,7 +1012,9 @@ private void ShowRepos(GitHubOwnerName owner) ImGuiWidgets.ColorIndicator(Palette.Basic.Green, isCloned); ImGui.SameLine(); bool isSelected = Options.BaseRepo == repoName; - if (ImGui.Selectable(gitHubRepo.RepoName, ref isSelected)) + bool clicked = ImGui.Selectable(gitHubRepo.RepoName, ref isSelected); + ImGuiProbes.MarkItem("repo", gitHubRepo.RepoName); + if (clicked) { SwitchPage(repoName); } @@ -1710,6 +1736,7 @@ private void ShowSimilarRepos(GitRepository repo) if (ImGui.TableNextColumn()) { _ = ImGui.Selectable(otherRepoName, selected: false, ImGuiSelectableFlags.SpanAllColumns); + ImGuiProbes.MarkItem("similar", otherRepoName); if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { SwitchPage(Options.BaseRepo, otherRepoName); @@ -1765,6 +1792,7 @@ private void ShowComparedRepo(GitRepository repo) if (ImGui.TableNextColumn()) { _ = ImGui.Selectable(filePath, selected: false, ImGuiSelectableFlags.SpanAllColumns); + ImGuiProbes.MarkItem("changed", filePath); if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { SwitchPage(Options.BaseRepo, Options.CompareRepo, filePath); @@ -2294,11 +2322,14 @@ private void ShowRepoBrowser() if (ImGui.TableNextColumn()) { _ = ImGui.Selectable(path); + ImGuiProbes.MarkItem("browse", path); if (ImGui.BeginPopupContextItem(path, ImGuiPopupFlags.MouseButtonRight)) { ImGui.Selectable(path); ImGui.Separator(); - if (ImGui.Selectable($"Propagate")) + bool propagate = ImGui.Selectable($"Propagate"); + ImGuiProbes.MarkItem("browse-menu/Propagate"); + if (propagate) { shouldOpenPopup |= true; Options.PropagatePath = path; @@ -2378,6 +2409,18 @@ private void ShowRepoBrowser() _ = PopupPropagateFile.ShowIfOpen(); } + /// + /// Lists a directory for the repository browsers, in name order. + /// + /// The directory to list. + /// The full paths of its files and directories, ordered by name ignoring case. + /// + /// The file system's own order is whatever that file system keeps: alphabetical on NTFS, by hash + /// on ext4. Sorting makes the browsers read the same on every machine. + /// + internal static IEnumerable ListBrowserEntries(string directory) => + Directory.EnumerateFileSystemEntries(directory).Order(StringComparer.OrdinalIgnoreCase); + private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, FullyQualifiedGitHubRepoName compareRepo, RelativeDirectoryPath newPath) { Options.BrowsePath = newPath; @@ -2391,7 +2434,7 @@ private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Ful try { - BrowserContentsBase = Directory.EnumerateFileSystemEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); + BrowserContentsBase = ListBrowserEntries(Path.Join(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { @@ -2400,7 +2443,7 @@ private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Ful try { - BrowserContentsCompare = Directory.EnumerateFileSystemEntries(Path.Combine(repoB.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoB.LocalPath)).ToCollection(); + BrowserContentsCompare = ListBrowserEntries(Path.Join(repoB.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoB.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { @@ -2427,7 +2470,7 @@ private void SwitchRepoBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Relati try { - BrowserContentsBase = Directory.EnumerateFileSystemEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); + BrowserContentsBase = ListBrowserEntries(Path.Join(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { diff --git a/ProjectDirector/ProjectDirector.csproj b/ProjectDirector/ProjectDirector.csproj index ed7d5ea..93314e6 100644 --- a/ProjectDirector/ProjectDirector.csproj +++ b/ProjectDirector/ProjectDirector.csproj @@ -18,6 +18,7 @@ + diff --git a/README.md b/README.md index 98d72c2..4b1b873 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ canonical version out to the rest. - **Persistent State**: Development directory, credentials, repository cache, and UI layout are saved between sessions. +The [app gallery](docs/gallery/README.md) shows the main views and dialogs as the application draws them today. + ## Requirements - .NET 9.0 diff --git a/tests/ProjectDirector.UITests/AssemblyInfo.cs b/tests/ProjectDirector.UITests/AssemblyInfo.cs new file mode 100644 index 0000000..4d9f24e --- /dev/null +++ b/tests/ProjectDirector.UITests/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +// ImGui keeps its context in global state, so only one harness can exist per process, and the +// application's settings and secret store seams are process-wide too. Sequential execution is a +// correctness requirement here, not a performance preference. +[assembly: DoNotParallelize] diff --git a/tests/ProjectDirector.UITests/Gallery/AppGallery.cs b/tests/ProjectDirector.UITests/Gallery/AppGallery.cs new file mode 100644 index 0000000..88a7ae8 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/AppGallery.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using ktsu.ImGui.App.Testing; + +/// +/// Photographs the application 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 PROJECTDIRECTOR_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 application over freshly seeded settings, so a picture never depends on +/// the one taken before it. +/// +/// +[TestClass] +public sealed class AppGallery +{ + /// The environment variable naming the directory the gallery is written to. + internal const string OutputVariable = "PROJECTDIRECTOR_GALLERY_OUT"; + + /// The display the gallery is drawn at. + internal static readonly (int Width, int Height) Display = (1440, 900); + + private static readonly Lazy TemporaryOutput = new(() => + Path.Combine(Path.GetTempPath(), $"projectdirector-gallery-out-{Guid.NewGuid():N}")); + + /// Gets or sets the context the test runner supplies. + 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; + + /// Creates the repositories every picture is taken over. + /// The class's test context, unused. + [ClassInitialize] + public static void SeedRepositories(TestContext context) => GalleryRepositories.Seed(); + + /// + /// Removes the seeded repositories, and the temporary directory when pictures went there rather + /// than to a named one. + /// + [ClassCleanup] + public static void DeleteTemporaryOutput() + { + GalleryRepositories.Delete(); + 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); + + using GalleryApp app = GalleryApp.Start(Display); + Assert.IsTrue(GalleryFonts.Load(), "ImGuiApp's own font could not be found, so the pictures would not look like the application."); + app.Harness.Mouse.MoveTo(-100f, -100f); + app.Harness.Step(2); + + entry.Stage(app); + + // A stage ends wherever its last click landed, so the pointer is parked again: a hover + // highlight left behind would be in the picture. + app.Harness.Mouse.MoveTo(-100f, -100f); + app.Harness.Step(2); + + Bitmap32 frame = app.Harness.Target; + Rectangle region = entry.Crop?.Invoke(app) ?? 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})."); + } + + [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. + /// The frame. + /// The part of it to keep. + /// A new bitmap holding that part. + 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/ProjectDirector.UITests/Gallery/GalleryApp.cs b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs new file mode 100644 index 0000000..c8276b6 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.IO.Abstractions.TestingHelpers; + +using ktsu.AppDataStorage; +using ktsu.CredentialCache.Storage; +using ktsu.ImGui.App.Testing; + +using CredentialCache = ktsu.CredentialCache.CredentialCache; + +/// +/// The real application, started headlessly over the seeded repositories with its settings file +/// and secret store replaced by in-memory ones. +/// +/// +/// The application saves its settings as it starts and reads its GitHub token from the OS secret +/// store. Both are redirected for the lifetime of this object, so a gallery run never reads or +/// writes the settings or credentials of whoever runs it. +/// +internal sealed class GalleryApp : IDisposable +{ + private readonly CredentialCache credentials; + + private GalleryApp(ImGuiAppHarness harness, ProjectDirectorOptions options, CredentialCache credentials) + { + Harness = harness; + Options = options; + this.credentials = credentials; + } + + /// Gets the harness driving the application. + internal ImGuiAppHarness Harness { get; } + + /// Gets the settings the application is running on. + internal ProjectDirectorOptions Options { get; } + + /// Starts the application at the given display size. + /// The size of the window the application draws into. + /// The running application. + internal static GalleryApp Start((int Width, int Height) display) + { + AppData.ConfigureForTesting(() => new MockFileSystem()); + CredentialCache credentials = new(new InMemoryCredentialStore()); + TokenStorage.UseCache(credentials); + + try + { + ProjectDirectorOptions options = GalleryRepositories.CreateOptions(); + ProjectDirector director = new(options); + ImGuiAppHarness harness = ImGuiAppHarness.Start(director.BuildConfig(), new HarnessOptions + { + Width = display.Width, + Height = display.Height, + }); + + return new(harness, options, credentials); + } + catch + { + Restore(credentials); + throw; + } + } + + /// Selects a repository in the left panel and waits for its comparison to finish. + /// The repository's own name. + internal void SelectRepository(string repo) + { + Harness.Click($"repo/{repo}"); + GitRepository selected = Options.Repos[GalleryRepositories.FullName(repo)]; + + // The comparison runs git on a background task, which publishes whenever it finishes rather + // than after some number of frames, so the wait is on the task's own answer. + Assert.IsTrue( + SpinWait.SpinUntil(() => !selected.SimilarReposPending, TimeSpan.FromSeconds(60)), + $"Comparing {repo} against its siblings did not finish."); + Harness.Step(2); + } + + /// Right-clicks a probe-marked item, which is what opens a context menu. + /// The item's probe name. + internal void RightClick(string name) + { + Rectangle item = Harness.Probe.Rect(name) ?? throw new InvalidOperationException($"Nothing called {name} has been drawn."); + Harness.Mouse.Click(item.MinX + (item.Width / 2f), item.MinY + (item.Height / 2f), 1); + Harness.Step(2); + } + + /// + /// Gets everything above the log panel. The log prefixes each line with the wall-clock time it + /// was written, so it can never be photographed the same way twice. + /// + /// The part of the window worth keeping. + internal Rectangle AboveTheLog() + { + Rectangle log = Harness.Probe.Rect("Log") ?? throw new InvalidOperationException("The log panel has not been drawn."); + return new Rectangle(0, 0, Harness.Options.Width, log.MinY - 1); + } + + /// + public void Dispose() + { + Harness.Dispose(); + Restore(credentials); + } + + private static void Restore(CredentialCache credentials) + { + TokenStorage.UseCache(null); + credentials.Dispose(); + AppData.ResetFileSystem(); + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs new file mode 100644 index 0000000..28bdc71 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using ktsu.ImGui.App.Testing; + +/// Every picture in the app gallery, in the order the index shows them. +/// +/// Each stage starts from the seeded repositories listed and nothing selected, and drives the +/// application by the probe names its own draw code records. 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 +{ + /// Gets the entries. + internal static IReadOnlyList Entries { get; } = + [ + new( + "Selecting a repository", + "The repositories under each GitHub owner are listed on the left, marked green where a clone exists in the dev directory. Selecting one shows where it lives, its git actions, its files, and its siblings ranked by how many files they share with it.", + app => app.SelectRepository(GalleryRepositories.Alpha)) + { + Crop = AboveTheLog, + }, + new( + "Comparing two repositories", + "Picking a similar repository lists every shared file that differs between the two, with how many lines differ, beside a browser over both trees.", + app => + { + app.SelectRepository(GalleryRepositories.Alpha); + app.Harness.Click($"similar/{GalleryRepositories.FullName(GalleryRepositories.Beta)}"); + app.Harness.Step(3); + }) + { + Crop = AboveTheLog, + }, + new( + "Comparing a file", + "Picking a changed file shows the two copies side by side, and the arrows take a block from one side to the other.", + app => + { + app.SelectRepository(GalleryRepositories.Alpha); + app.Harness.Click($"similar/{GalleryRepositories.FullName(GalleryRepositories.Beta)}"); + app.Harness.Step(3); + app.Harness.Click($"changed/{GalleryRepositories.WidgetSource}"); + app.Harness.Step(3); + }) + { + Crop = AboveTheLog, + }, + new( + "Propagating a file", + "Right-clicking a file in the browser offers to copy it into any of the sibling repositories that also have it.", + app => + { + app.SelectRepository(GalleryRepositories.Alpha); + app.RightClick("browse/README.md"); + app.Harness.Click("browse-menu/Propagate"); + app.Harness.Step(3); + }) + { + Crop = AboveTheLog, + }, + new( + "Setting the dev directory", + "The directory scanned for clones is set from the File menu.", + app => OpenMenuItem(app, "menu/Set Dev Directory")) + { + Crop = AboveTheLog, + }, + new( + "Adding a GitHub owner", + "Repositories are discovered by GitHub owner, a user or an organization, added from the File menu.", + app => OpenMenuItem(app, "menu/Add New GitHub Owner")) + { + Crop = AboveTheLog, + }, + ]; + + private static Rectangle? AboveTheLog(GalleryApp app) => app.AboveTheLog(); + + /// Opens a popup from the File menu. The popup is only ever shown, never confirmed. + private static void OpenMenuItem(GalleryApp app, string item) + { + app.Harness.Click("menu/File"); + app.Harness.Step(2); + app.Harness.Click(item); + app.Harness.Step(3); + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs b/tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs new file mode 100644 index 0000000..8f9fc41 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.Text; + +using ktsu.ImGui.App.Testing; + +/// One picture in the app 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 application into the state worth photographing, starting from the seeded repositories +/// listed and nothing selected. It works the way a user would, through menus, panels and the +/// pointer, so the picture is of a path through the application 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/ProjectDirector.UITests/Gallery/GalleryFonts.cs b/tests/ProjectDirector.UITests/Gallery/GalleryFonts.cs new file mode 100644 index 0000000..58f84d5 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryFonts.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +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 application draws in. +/// +/// The harness never builds ImGuiApp's own fonts, so a headless test draws in Dear ImGui's +/// built-in bitmap font. That is right for a test, which must not move when a font does, and wrong +/// for pictures of the application. 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; + } + }); + + /// Gets a value indicating whether the application's font could be found. + internal static bool Available => NerdFont.Value is not 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/ProjectDirector.UITests/Gallery/GalleryIndex.cs b/tests/ProjectDirector.UITests/Gallery/GalleryIndex.cs new file mode 100644 index 0000000..bf0b2ba --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryIndex.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +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. + /// The entries to caption. + /// The index's text, with LF line endings. + internal static string Render(IReadOnlyList entries) + { + StringBuilder text = new(); + text.Append("# App gallery\n\n"); + text.Append("\n\n"); + text.Append("Project Director as it draws today, staged and photographed headlessly by the UI test harness "); + text.Append("over three small repositories seeded for the purpose. "); + text.Append("The pictures are regenerated on every successful build of `main`, so they show the current application. "); + text.Append("The log panel along the bottom of the window is left out of every picture, because it stamps each line with the time it was written.\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/ProjectDirector.UITests/Gallery/GalleryRepositories.cs b/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs new file mode 100644 index 0000000..12cd945 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs @@ -0,0 +1,250 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.Diagnostics; + +using ktsu.Semantics.Paths; + +/// +/// Seeds the three sibling repositories the gallery photographs, and the settings that point the +/// application at them. +/// +/// +/// +/// The repositories are real git repositories, made offline with fixed names, dates and contents, +/// because the comparison the application draws runs git against them. Nothing here touches +/// a remote: the settings carry each repository's GitHub address only as text to show, and every +/// fetch interval is zero, which is how the application spells "never fetch on a timer". +/// +/// +/// They live at one fixed path rather than a fresh temporary one, because the application prints +/// the dev directory and the selected repository's local path, and a path that changed per run +/// would change every picture with it. +/// +/// +internal static class GalleryRepositories +{ + /// The GitHub owner the repositories are filed under. + internal const string Owner = "ktsu-dev"; + + /// The repository the gallery selects. + internal const string Alpha = "Alpha"; + + /// The sibling sharing the most files with . + internal const string Beta = "Beta"; + + /// A sibling sharing fewer files with . + internal const string Gamma = "Gamma"; + + /// The file the diff pictures compare, which differs in several places. + internal const string WidgetSource = "src/Widget.cs"; + + /// + /// The repositories in the order the settings list them. Order matters: the similar repos table + /// breaks ties by the order the settings hold the siblings in. + /// + private static readonly string[] Names = [Alpha, Beta, Gamma]; + + /// Gets the directory every repository is created under, which the application shows as its dev directory. + internal static string Root => OperatingSystem.IsWindows() + ? Path.Combine(Path.GetTempPath(), "projectdirector-gallery") + : "/tmp/projectdirector-gallery"; + + /// Gets the name the application keys a repository by. + /// The repository's own name. + /// The owner-qualified name. + internal static FullyQualifiedGitHubRepoName FullName(string repo) => + FullyQualifiedGitHubRepoName.Create($"{Owner}.{repo}"); + + /// Creates the repositories, replacing any left by an earlier run. + internal static void Seed() + { + Delete(); + + Write(Alpha, "README.md", """ + # Alpha + + A small library in the ktsu-dev family. + + ## Usage + + Install the package and call `Widget.Build()`. + + ## License + + MIT + """); + Write(Alpha, ".editorconfig", EditorConfig); + Write(Alpha, "LICENSE.md", License); + Write(Alpha, WidgetSource, """ + namespace Alpha; + + public static class Widget + { + public static string Build() => "alpha"; + + public static int Size => 3; + } + """); + + Write(Beta, "README.md", """ + # Beta + + A small library in the ktsu-dev family. + + ## Usage + + Install the package and call `Widget.Build()`. + + ## License + + MIT + """); + Write(Beta, ".editorconfig", EditorConfig); + Write(Beta, "LICENSE.md", License); + Write(Beta, WidgetSource, """ + namespace Beta; + + public static class Widget + { + public static string Build() => "beta"; + + public static int Size => 4; + + public static bool IsReady => true; + } + """); + + Write(Gamma, "README.md", """ + # Gamma + + A command line tool in the ktsu-dev family. + + ## Usage + + Run `gamma --help` for the list of commands. + + ## License + + MIT + """); + Write(Gamma, ".editorconfig", EditorConfig); + Write(Gamma, "LICENSE.md", License); + Write(Gamma, "docs/commands.md", """ + # Commands + + - `gamma build` + - `gamma check` + """); + + foreach (string path in Names.Select(PathOf)) + { + Git(path, "init", "--quiet", "--initial-branch=main"); + Git(path, "add", "--all"); + Git(path, "commit", "--quiet", "--no-gpg-sign", "--message", "Initial commit"); + } + } + + /// Removes the repositories. + internal static void Delete() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + + /// + /// Builds the settings the application starts from: one owner, its three repositories, all + /// recorded as cloned, and nothing selected. + /// + /// Fresh settings, which the application is free to modify. + internal static ProjectDirectorOptions CreateOptions() + { + GitHubOwnerName owner = GitHubOwnerName.Create(Owner); + ProjectDirectorOptions options = new() + { + DevDirectory = AbsoluteDirectoryPath.Create(Root), + }; + + _ = options.GitHubOwners.Add(owner); + foreach (string name in Names) + { + GitHubRepository repo = new() + { + OwnerName = owner, + RepoName = GitHubRepoName.Create(name), + LocalPath = FullyQualifiedLocalRepoPath.Create(PathOf(name)), + RemotePath = GitRemotePath.Create($"https://github.com/{Owner}/{name}"), + MinFetchIntervalSeconds = 0, + }; + + options.Repos[FullName(name)] = repo; + options.ClonedRepos[repo.LocalPath] = FullName(name); + } + + return options; + } + + private const string EditorConfig = """ + root = true + + [*] + indent_style = tab + end_of_line = lf + """; + + private const string License = """ + MIT License + + Copyright (c) ktsu-dev contributors + """; + + private static string PathOf(string repo) => Path.Combine(Root, Owner, repo); + + private static void Write(string repo, string relativePath, string content) + { + string path = Path.Combine(PathOf(repo), relativePath); + _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content.ReplaceLineEndings("\n") + "\n"); + } + + /// + /// Runs git with every input that could vary between machines pinned: no system or user + /// configuration, and a fixed author, committer and date. + /// + private static void Git(string workingDirectory, params string[] arguments) + { + ProcessStartInfo start = new("git") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + start.Environment["GIT_CONFIG_NOSYSTEM"] = "1"; + start.Environment["GIT_CONFIG_GLOBAL"] = OperatingSystem.IsWindows() ? "NUL" : "/dev/null"; + start.Environment["GIT_AUTHOR_NAME"] = "ktsu-dev gallery"; + start.Environment["GIT_AUTHOR_EMAIL"] = "gallery@ktsu.dev"; + start.Environment["GIT_AUTHOR_DATE"] = "2026-01-01T00:00:00Z"; + start.Environment["GIT_COMMITTER_NAME"] = "ktsu-dev gallery"; + start.Environment["GIT_COMMITTER_EMAIL"] = "gallery@ktsu.dev"; + start.Environment["GIT_COMMITTER_DATE"] = "2026-01-01T00:00:00Z"; + + using Process git = Process.Start(start) ?? throw new InvalidOperationException("git could not be started."); + string error = git.StandardError.ReadToEnd(); + _ = git.StandardOutput.ReadToEnd(); + git.WaitForExit(); + if (git.ExitCode != 0) + { + throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed in {workingDirectory}: {error}"); + } + } +} diff --git a/tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj b/tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj new file mode 100644 index 0000000..50b482f --- /dev/null +++ b/tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj @@ -0,0 +1,24 @@ + + + + + + true + net10.0 + + + ktsu.ProjectDirector.UITests + $(RootNamespace) + false + true + + + + + + + + +