diff --git a/claude.md b/claude.md index 0df077359..63c361e8c 100644 --- a/claude.md +++ b/claude.md @@ -24,8 +24,15 @@ dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configurati # Or run the test project directly, which is the fastest loop and takes the same filter src/DiffEngine.Tests/bin/Debug/net10.0/DiffEngine.Tests.exe --treenode-filter "/*/*/ClassName/*" + +# Benchmarks, one project per assembly they reach into. --filter takes class or method globs +dotnet run -c Release --project src/DiffEngine.Benchmarks -- --filter "*TextDiff*" +dotnet run -c Release --project src/DiffEngineViewer.Benchmarks -- --filter "*Frame*" +dotnet run -c Release --project src/DiffEngineViewer.Windows.Benchmarks -- --filter "*" ``` +**Benchmarks:** BenchmarkDotNet, run in process with a short job, both set in each project's `Program.cs`. Its default toolchain generates a project under `bin/` and builds it with one `OutDir` for everything it references; DiffEngine references the three viewer heads for build ordering, all three are named `DiffEngineViewer`, and they overwrite one another there, so the default cannot build. Three projects because of what each has to see inside: the library, the viewer's core (which links the library's sources, so one project referencing both finds every shared type ambiguous), and the WinForms head (which only builds for Windows). Each is signed and named in `InternalsVisibleTo`. A benchmark measures the product's own code path, and where a fix replaces a path rather than adding one, the benchmark is committed before the fix so the earlier number can be had again from history. Three iterations settle a cost that is out by multiples and not one that is out by a tenth: pass `--iterationCount` for that. The viewer project's `Native` classes turn the Linux head's real window, so they are left out of a run wherever there is no shim or no display, which is every run on Windows: they run in the `ubuntu:24.04` container the pixel snapshots reproduce in, under `xvfb-run`, by the command in `NativeFrameBenchmarks`' summary. The clock says little there, since the shim holds a turn to a sixtieth of a second, so they report processor time, the X server's, and what OpenGL counted as drawn. + **Test runner:** TUnit runs on Microsoft.Testing.Platform rather than VSTest, which changes two things about the commands above. Filters are treenode paths given after `--`, as `/Assembly/Namespace/Class/Test` with `*` for any segment; VSTest's `--filter "FullyQualifiedName~ClassName"` matches nothing and exits 5, so a filtered run that reports no failures may have run no tests. And `--nologo` makes any run report "Zero tests ran" and exit 5, whatever else is on the command line, so leave it off. **SDK Requirements:** .NET 10 SDK (see `global.json`, at the repository root rather than under `src`). It also carries `"test": { "runner": "Microsoft.Testing.Platform" }`, which is what puts `dotnet test` on the runner described above. The project uses preview/prerelease SDK features. @@ -106,6 +113,44 @@ that member's declaration is not in it, so an identical snapshot in the test nex candidate at all, while the recorded line is still tried first so two snapshots in one member stay apart. +A line names a call site only until something above it in the file changes, and accepting a +snapshot is exactly that. So whoever finds a queued entry by its line asks whose it is before +believing it. A settle that hits an entry queued from another member leaves it alone unless the +value settles it, and a failing re-run whose key names nothing looks for the entry of its own +call site (`InlinePatch.IsSameCallSite`: member, test, mode and anchor) and takes it to the line it +is at now, rather than queueing a second one beside it. `InlineStaging` asks the same of a staged +trio, and scopes a settle to the running framework (`InlineStaging.Settle`), which +`InlinePatchFile.Write` labels a staged patch with. + +What the patcher writes has to compile where it lands, and three shapes did not. F# measures a +continuation from the column the call's expression starts at, not from the line's indentation, +whenever something precedes the call on its line (`do!`, `let! x =`): the offside rule +(`SourceLanguage.IndentationIsSyntax`, and the shapes are in `FsCompilerRoundTripTests`). An +appended `Snapshot` call goes in front of `ToTask`, `ConfigureAwait` or `GetAwaiter` in either +language, none of which returns something to call it on. And a `Remove` of +`settings.Snapshot("old");` takes the statement, since `settings;` is none, while one whose value +is awaited, assigned, returned or passed takes only the call. + +An accept reads, lexes and rewrites the whole source file, and the rewrite is what costs: a file +written a moment ago is scanned by whatever watches the drive before the next thing can open it, +so five hundred snapshots in one 600 KB file were half a minute of writes around a second of +patching. `InlineApplier.ApplyAll` takes patches together: each file is read once, its patches are +applied in memory in the order given, each to what the one before it left, and it is written once +through the same temporary and swap, with the file's lock and mutex held from the read to the +write. Every patch is told what `Apply` would have told it in turn, and `Apply` is the one-patch +case of the same code. One thing can only differ: a write that fails fails every patch from the +first edit on. Both batches use it, a file at a time (`AcceptBatch.Together` in the viewer, +`OwnedInlineHost.AcceptEvery` in the tray), so the moment up to which a snapshot can still be +withdrawn from a bulk accept is its file's turn rather than its own. A `SourceScan` rents its map +from the pool and is disposed for that reason, and keeps its spans as sorted lists rather than +hash tables: it is built again for every patch, over the whole file. + +A passing inline verification clears its staged trio (`InlineStaging.Clear`), which walked the +project's whole `obj` tree to find the `VerifyInline` directories. The list of those is now kept a +second, per project, and dropped at once when this process stages anything; the caller's own +directory and each known directory's write time are still checked on every clear, which is what +the comment there about not caching "nothing staged" asks for. + ### Core Components **DiffEngine Library (`src/DiffEngine/`):** @@ -114,6 +159,7 @@ apart. - `Definitions` - Static collection of all supported diff tool definitions. Each tool is defined in `Implementation/` folder. - `Definition` - Record type describing a diff tool: executable paths, command arguments, supported extensions, OS support, MDI behavior, auto-refresh capability. - `DiffTool` - Enum of all supported diff tools (BeyondCompare, P4Merge, VS Code, etc.) +- `TextDiff` (`TextDiff/`) - The line diff behind a failure message and behind every text pair the viewer shows, which links these files. Myers in linear space over line ids, with three things in front of the textbook. Lines only one side has are marked changed and taken out first (`LineDiff.DiffShared`), since they cannot be unchanged and Myers costs by edits: a re-indented snapshot was all edits, four seconds for 40,000 lines. A diff has a budget of searching (`MyersDiff.Budget`, about a sixth of a second), counted in work rather than lines so that nothing quick is given up on for being long; past it a search settles for a split, which is still a correct diff and may not be the smallest. And a search that settles having passed nothing looks for where the start of each side is in the other (`TryDisplaced`), because a block moved further than the search went lines up on a diagonal it never reached. Up to 10,000 lines between the two sides a diff is always minimal. - `ResolvedTool` - A diff tool that was found on the system with its resolved executable path. - `BuildServerDetector` - Detects CI/build server environments to disable diff tool launching. @@ -132,38 +178,101 @@ apart. structure, which is what makes the text snapshots meaningful and keeps three renderers honest. - `ViewerProgram.Run(args, OpenWindow)` owns the loop for all heads. A head is a `Main` that chooses a renderer; nothing else about the app is per platform. +- The loop presents sixty times a second and builds a screen only when the state is another one. + `ScreenCache` keeps the last `SessionState` and its `Screen`: a state is immutable and only ever + replaced, so the same reference is the same screen, and building one a frame was the whole + queue's labels and tooltips, megabytes a second, from a window nobody was touching. Handing a + head the same `Screen` is also how it learns nothing changed, with no comparison: the WinForms + head and `ScreenPayload` both stop at the reference. So anything a screen depends on has to be + in the state. A spinner or a picture landing is a head's own business and redraws on its own. - Windows renders with **WinForms** and loads no native library. It is pumped through `Application.DoEvents` rather than `Application.Run`, so the shared loop stays shared. Only the grid is owner drawn: the footer, the context menu, the pane scrollbar and the tooltips are real controls, so they get the OS's keyboard handling, theming and screen reader support. The menu is - still projected from the same `Screen.Menu` the other heads draw. + still projected from the same `Screen.Menu` the other heads draw. A row is handed to GDI+ cut to + the cells its pane has, and one more (`RowText.Shown`, read from the front of the row and cut + before it is segmented): GDI+ lays out every character it is given before it clips any, so 72 + rows of 2,000 character lines were 15 ms a paint, and a megabyte line 24. A picture zoomed to + half its own size or less is copied out of one scaled copy, made on the pool and kept in + `ImageCache`'s composite slot with pan out of the key, where it was scaled from full resolution + on every paint, 50 ms for a 4000 by 3000 pair. Between half and full size nothing is kept, since + the copy would cost up to the decoded picture again. - macOS renders with **AppKit and Core Text** (`native/swift/`), Linux with **raylib and Dear ImGui** (`native/`). Both implement the same C ABI, so the managed interop layer is identical. - macOS took the same treatment as Windows: a real menu bar, an `NSMenu` context menu, `NSView` tooltips and an `NSScroller`, with `NSApp.appearance` set to `darkAqua` so they match the drawn grid. The cost is that none of them exists in `deview_capture`, which makes no window — hence `PixelTests.ContextMenu` being skipped there, and the scroller taking its strip out of the - renderer only when a window exists. + renderer only when a window exists. AppKit's own loops run inside the pump, so the managed loop + waits them out. The scroller is a `PaneScroller`, which follows a drag of its knob as ordinary + events for that reason, where AppKit's tracking loop left the panes still until the knob was let + go. A live resize is the same kind of loop and still draws the rows sliced for the old size: + doing better takes a frame callback the C ABI does not have. Keys and clicks are queued in + `Runtime` and handed over one a poll, as the WinForms head does and for its reason. The footer + wraps its buttons, and puts the status on a line of its own when there is no room beside them. + The font has `calt` and `liga` off, so `<>` or `!=` in a snapshot is drawn as the characters it + holds. A spinner invalidates only its own rectangle, but since macOS 11 a view with an automatic + backing store may be handed its whole bounds anyway, so the renderer answers a turn two ways: + `dirty` and `shows` leave out what the context's clip cannot reach, where it is narrowed, and + `lines` and `earlier` keep the `CTLine`s the last two draws made, keyed by the text's bytes and + by which colour object, so a draw that changes little lays out little. An enlarged picture below + its own size is drawn from a copy at that size (`reduced`), made on the work queue in the fitted + copy's slot, except in a capture, past its own size, or when both panes name one picture. None + of this head can be compiled or run from Windows: CI's `macos-14` job is the first build, and + its OSX baselines come from that job's `received-*` artifacts. That job only captures, so it + exercises none of the clip test and none of `reduced`. - Linux draws its own menu, so it keeps that baseline. Its tooltip and pane scrollbar are ImGui's, the scrollbar being `ScrollbarEx` driven in rows rather than pixels so its travel is exactly - `ViewerSession`'s clamp. + `ViewerSession`'s clamp. Its footer wraps as the macOS one does (`LayOutFooter`), with two + Linux-only scenes for it in `PixelTests`. Characters JetBrains Mono lacks are drawn from the + machine's fonts, found through fontconfig, which is loaded at run time rather than linked and + only once a character on screen needs it. A capture never uses them: it draws with the embedded + font alone, so no baseline depends on what is installed (`PixelTests.OutsideTheFont`). + Accept-all is `a` with Shift held, read from the key rather than from the case of the letter, + which Caps Lock also changes. A turn of the loop is not a frame on the screen. `deview_present` + builds a frame only when something one is built from has arrived - another screen by its bytes, + the pointer, a key, the window, a decode, a font, a tooltip's delay, a picture's file written + again - or a second of built frames has yet to come out the same, which is what a spinner fails. + It draws a built frame only when its draw lists differ from those of the frame on the screen + (`Fingerprint`), or the window cannot be taken to show what was last drawn (`stale`: resized, + shown again, or asked for by the window system through GLFW's refresh callback, which raylib + leaves unset). Every turn ends in `Rest`, the wait and the event read `EndDrawing` did for a + frame it had drawn, so the loop still turns sixty times a second and `EndDrawing` is not called. + Drawn every turn, an idle window under a software rasteriser took more than half a core, and all + four of the rasteriser's threads at 4K. Anything new that `BuildFrame` reads has to be asked + about in `deview_present` before a window is left alone, or the window keeps the frame before. + The checkerboard is one quad of a two by two texture set to repeat, behind a picture that has a + pixel to see through, which the decoder looks for as it decodes: it was a quad a dark square, + 113,000 triangles a frame at 4K, behind opaque pictures too. - Group headers fold. `SessionState.Collapsed` holds `QueueItem.GroupKey`s and `QueueProjection` skips their members, so the marker rides in the label and no head or ABI field knows about it. Whether an entry is hidden is always read back out of `VisibleEntries`, never recomputed — the rules about when a header exists at all live in one place and must stay there. A fold is a view: `AcceptAll` still sweeps what it hides, which `CollapseTests` pins. -- Accept-all goes an entry at a time, because it takes as long as the queue is long. +- Accept-all goes a step at a time, because it takes as long as the queue is long. `ViewerSession.BeginAcceptAll` records an `AcceptBatch`, and `AcceptAllRunner` claims an entry under `SessionHost`'s lock (`ClaimNext`), applies it outside (`ApplyClaimed`), and records it under the lock again - snapshots before files, since whether a delete is held turns on how the - snapshots went. The render loop takes that lock every frame, so one transition over the queue + snapshots went. A snapshot is claimed with every other one the batch still has to do in the + same source file (`AcceptBatch.Together`), and they are written with one read and one write, + each still with an outcome of its own: a step is a file where a file has several. The render loop takes that lock every frame, so one transition over the queue froze the window for the whole batch. A window's batch runs on a worker, a wire `AcceptAll` on its listener thread, and `ViewerSession.Apply(AcceptAll)` is the same steps back to back, which is what the tests drive. Owners put `AcceptProgress` on their listings - the tray completes each snapshot with `InlineQueue.AcceptInBatch` rather than all at the end - and `OwnerLink.Run` lists beside an in-flight send rather than after it, so an attached window follows the owner's batch. While `SessionState.Progress` is set the status line shows it and the window refuses anything - `ChangesQueue` names. + `ChangesQueue` names. "Accept all in" a header is the same batch over that header's members + (`BeginAcceptGroup`, `AcceptBatch.Only`), not a transition of its own: in a queue of one solution + the header's group is the whole queue. So it goes by the batch's rules, a snapshot the applier + would not take staying in the queue with what the applier said, and it counts as still needing + review only its own members. Only the bulk discards are still one transition, since a discard + waits on nothing. A batch's record step is the one inline transition that does not rebuild the + list from the queue: it asks `InlineQueue.AcceptInBatch` of a queue holding the claimed entry + alone and takes that entry out of the list, or marks it, where it stands. Rebuilt an entry, the + bookkeeping grew with the square of the queue, seconds and gigabytes for 2,000 snapshots. For + the same reason which entries are visible is found by `QueueProjection`'s one walk without + describing the rows, and not asked at all when nothing is folded. - Images (`Images/`, extensions in `DiffEngine/Viewer/ImageExtensions.cs`, linked into the viewer so the tool registration and the renderer cannot disagree) are a side, not a mode. `FileSide.Read` decides text or picture **by extension**, because the expected side of a new snapshot has no bytes @@ -187,7 +296,8 @@ apart. one frame that has to come out the same every time (`ViewerCanvas.Synchronous`, the Swift renderer's `capturing`, `state.capturing` in the shim). A spinner turns by repainting only its own rectangle: WinForms and macOS redraw only when something changed, and the frame is otherwise - unchanged for as long as a page takes. + unchanged for as long as a page takes. On Linux it turns by being there: a frame with a spinner + in it differs from the one before, so frames go on being built and drawn while one is up. - Documents (PDF, docx, xlsx, pptx, and SVG and maps drawn beside their text) need **`src/DiffEngineViewer.Documents`**, a separate assembly with Morph, Morph.PDFium, Skia, GeoConvert and the OpenXml SDK behind it: tens of MB per RID. So it ships only in a `documents/` folder of the three tool packages and of the tray (one folder @@ -204,11 +314,28 @@ apart. documents)`, `TrackedEntry`, `TrackedWatch`, `OwnerLink`, `MessageHandler` - never found by each, so a test process does not read with whatever folder sits beside it. - In process, by choice: a native fault in PDFium or Skia ends the window. A hang is given up on - after `DocumentWatch.Timeout`, and a PDF left behind holds PDFium's lock, so PDFs then fail at once. + once `DocumentWatch.Timeout` passes with nothing coming of it - counted from the last page to + land, not from the start, so a long document that keeps landing pages is never left behind. A + PDF left behind holds PDFium's lock until its call returns, so PDFs wait for that + (`AwaitPdfium`) rather than failing: nothing is recorded against a document for a reason that + is not about it. The same goes for a copy that could not be written. Both throw out of `Pump` + before anything is marked as started, and `Turn` says why, once, and tries again - the loop + never ends on a fault, which used to stop every document until the viewer was restarted. + - Both sides of the entry on screen are drawn at once, a `Call` each with a clock and a + left-behind flag of its own. Drawn one after the other, the right pane was a spinner for every + page of the left. Two Office files take a core each; two PDFs take turns at PDFium's lock, which + is held only while a page is rasterised, so they too finish in about the time of one. Two PDFs + are started one behind the other, the second once the first has landed a page, because blame + is told from whose pages stopped first: when one stops inside PDFium the other stops at the + lock, and the one that ran out of time first is given up on while the other is put back as + not started, with nothing recorded against it. `pdfiumHeld` is a count of PDFs left behind and + not yet returned. A call left behind is stopped where its next page lands, by throwing from + the page callback, which is the one place it can be. - `FileSide.Read` only hashes a document, because it runs on the listener thread a test process waits on. `DocumentWatch` (owned, attached and file modes) does the slow part, for the entry on screen only - never the next one ahead of time, because a call into Morph or PDFium cannot be - stopped, and one drawing ahead was one the reader waited behind when they picked another entry. + stopped part way through a conversion or a page, and one drawing ahead was one the reader + waited behind when they picked another entry. It works from a copy taken under the cache's hash directory, checked against the hash, so nothing holds a lock on the user's file and what is drawn is what the hash says. Text replaces the entry once both sides are read @@ -283,6 +410,12 @@ apart. is one segment at column 0 - so a character a fallback font draws at its own width moves nothing after it, and the highlight, the hit test and the copy count the same cells. Selection ends snap to cluster boundaries (`CellGrid.Snap`), so a wide character is taken whole or not at all. + What can go in a run is asked of the embedded font (`FontCoverage`, out of its cmap and hmtx): + a glyph at the cell's advance. It was four ranges written down, which left out the font's box + drawing, arrows and punctuation, a segment a character each, and took in letters the font lacks, + which a fallback font then drew at its own width mid run. The grid still decides width: a + character it gives two cells, a mark, and half a surrogate pair are never in a run. A row of + nothing but run characters is measured as a row of ASCII is, with no walk through clusters. - An entry opens at its first change, not line 1: every path that changes what is being read goes through `ViewerSession.Open`, so none resets to row 0 on its own. The minimal view ("Changes only", `SessionState.Minimal`) is a second `DiffView` built with each entry - changes plus @@ -309,7 +442,10 @@ apart. `OwnerLink.ReadChanges` has always done the equivalent for a displayed queue, on the same 200ms cadence and the same `FileStamp` test, which is why the two are worth reading together. A pass that finds nothing must return the identical `SessionState`, or the open context menu closes - five times a second. It stops short of the tray's third rule, dropping a pair whose two files + five times a second. A pass looks at the entry on screen and at up to `Budget` (a hundred) of + the others, in turn, so a queue no longer than that is looked at whole as it always was and a + thousand pending pairs are not two thousand stats a pass; hidden, the passes are a second apart, + as `OwnerLink`'s are. It stops short of the tray's third rule, dropping a pair whose two files became byte equal: that check exists because an external diff tool might have converged them, and here the viewer is the diff tool. They are ordinary `QueueEntryKind.Move`/`Delete` entries — the same ones an attached viewer draws for the tray's — so nothing about how they look or what their menu offers @@ -352,7 +488,7 @@ apart. this folder, so a recursive clone on every checkout would serve a path almost nobody takes. - Building it needs CMake 3.24+, a C++17 compiler and network access. Contributors do not need any of that, because the binaries are committed. -- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eight exports +- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eleven exports taking one flat blittable frame description. The ABI is `native/include/deview.h`; bump `DEVIEW_VERSION` whenever the structs change **or a field changes meaning**. The managed side refuses a library whose version is not an exact match, so a bump and a binaries rebuild land @@ -403,7 +539,18 @@ apart. the pipe `dotnet test` reads the host's output from kept the run from returning until its window closed. That is why an inline patch goes in a file rather than on stdin, which a ShellExecute launch cannot redirect, and why the Windows head is a `WinExe`: ShellExecute gives - a console executable a console window. + a console executable a console window. The viewer also starts in its own folder, since a child + that inherits the host's working directory keeps that directory from being deleted for as long + as it runs. The third party tools declared `UseShellExecute: false` are started on Windows by + `WindowsProcess.StartInheritingNothing`, a `CreateProcess` with handle inheritance off and no + console, rather than through ShellExecute: asked for hidden, ShellExecute also hides the own + window of a console program that opens one, and nothing in the file says which kind it is. +- `ViewerLaunchGate` is handed the process it started. One that has exited with a failure before + anything held the queue is `Failed`, so the caller stages; it used to be waited on for the whole + of `BindWait` and reported as launched, and an inline snapshot was then in no queue and not + staged either. A clean exit is left to the wait, since a viewer that hands its work to an owner + exits with zero. `ViewerContract` is the other half: resolution passes over a copy older than + 20.5.0, which exits on `--payload`, when a newer one is further down the search order. - Unless that diff tool is the viewer, which is the `Diff` verb and `--diff `. Then the premise above is false — there is no window for the pair yet — so it is tracked exactly as a move and a window is raised over the entry, and `DiffRunner` skips the whole process per @@ -428,6 +575,21 @@ apart. - `DebugReport` / `DebugForm` - the menu's "Debug view": every field of every tracked move, delete and snapshot as text, plus the queued patches when this tray owns the queue. The report is a string so it can be copied into an issue and snapshot tested without rendering a window. +- A delete can be the last copy of a snapshot, so the tracker is careful about which it carries + out. `AddMove` withdraws a tracked delete of its target, as `SettleDelete` would have. An + accept-all lists its deletes as it begins and carries out only those + (`ITrackedFiles.AcceptAll(deleteKeys, ...)`), leaves one whose file a move in the same sweep + wrote or still awaits (`WrittenOrAwaited`), and holds them all when the queue's owner could not + be asked: `IInlineHost.TryList` tells an owner that did not answer from there being none, the + second being what `ViewerClient.FoundUnowned` is for. +- `Program.Main` is a synchronous `[STAThread]` method that blocks on `Inner`. An attribute on an + `async Task Main` lands on a method the runtime does not start, and the thread came up MTA. +- `SessionEndWindow` is a hidden top level window that hears `WM_ENDSESSION`, which neither the + notify icon nor a message filter does. An owning tray stages its queue from inside the message + (`OwnedInlineHost.SessionEnding`) and refuses patches from then on, because a logoff never comes + back through `Application.Run()`. +- A move that arrives for a tracked pair with no tool keeps the tool it was tracked with + (`Tracker.Retarget`): one over the viewer port carries two paths and nothing else. - Allows accepting/discarding diffs from system tray **Packaging.Tests (`src/Packaging.Tests/`):** @@ -454,10 +616,10 @@ apart. ### Key Patterns -- Tool discovery uses wildcard path matching (`WildcardFileFinder`) to find executables in common install locations +- Tool discovery uses wildcard path matching (`WildcardFileFinder`) to find executables in common install locations. A wildcard whose matches are all version-named folders takes the highest version; anything else takes the most recently written - Tool order can be customized via `DiffEngine_ToolOrder` environment variable - `DisabledChecker` respects `DiffEngine_Disabled` env var -- `ViewerClient` remembers a port found unowned for ten minutes (`RecheckUnownedAfter`), and the library's telling sends - settle, retire, move, delete, the first inline or diff send - skip the connect while that stands. A refused loopback connection costs two seconds on Windows (firewall stealth mode drops the reset), and a green run settles once per inline verification, which was six minutes for a class of 188 inline tests. Probes (`IsOwned`), the hosts and `InlineQueueClient` always connect and correct the memory; so does `SettleAppliedInline`, being one send per accept +- `ViewerClient` remembers a port found unowned for ten minutes (`RecheckUnownedAfter`), and the library's telling sends - settle, retire, move, delete, the first inline or diff send - skip the connect while that stands. A refused loopback connection costs two seconds on Windows (firewall stealth mode drops the reset), and a green run settles once per inline verification, which was six minutes for a class of 188 inline tests. Probes (`IsOwned`), the hosts and `InlineQueueClient` always ask and correct the memory; so does `SettleAppliedInline`, being one send per accept. Asking, on Windows, is the operating system's listener table first (`ListenerTable`, shared with `PiperClient`): no listener on the port means nobody to connect to, said without the two seconds, and a listener or a table that cannot be read leaves the connect to answer. So the first telling send of a test process, the launch gate's probe and each of its polls no longer wait to be refused. By port alone, whichever address, since the table is only believed when it says nobody is there. Not for a port that accepted a connection in the last second (`TrustOwnerFor`), because reading the table is reading every connection the machine has, and a run of settles to a live owner would pay more for each than the connect costs - `TrayDisabledChecker` respects `DiffEngine_TrayDisabled` env var, behind `DiffRunner.TrayDisabled`. Separate from `Disabled` because tracking a pending move is separate from launching a tool: every exit of `InnerLaunch`, `Disabled` included, still calls `AddMove`. `PendingFiles.TrayAvailable` is the single gate - Tests use TUnit and Verify for snapshot testing - The native pixel snapshots (`PixelTests`) are opt in through `DIFFENGINE_VIEWER_PIXEL_TESTS`, which `MachineSettings.Ignore` has to leave alone: it clears every `DiffEngine_*` variable without regard to case, and clearing that one skipped them on the CI job that sets it, silently, for as long as nobody looked. Every call into the shim goes through one thread (`OnShimThread`), because on Linux the window's GL context belongs to the thread that made it and each test starts on whichever pool thread picks it up. The Linux baselines reproduce in an `ubuntu:24.04` container set up as the `unix` job in `build.yml` is - the shim built from source, Xvfb, llvmpipe - which is also the only way to run the C++ at all from Windows diff --git a/docs/diff-tool.md b/docs/diff-tool.md index 74de560a9..86cc74e14 100644 --- a/docs/diff-tool.md +++ b/docs/diff-tool.md @@ -17,6 +17,8 @@ Behavior depends on if an [EmptyFiles](https://github.com/SimonCropp/EmptyFiles) * If an EmptyFiles can be found matching the current extension, then the tool will be launched to compare the input to that empty file. * If no EmptyFiles can be found no tool will be launched. +The bundled [DiffEngineViewer](/docs/viewer.md) is the exception. It needs no file to compare against, so none is written: the input is shown against an empty side, and stays that way until it is accepted. That holds for every extension, including those with no EmptyFiles. + ## Detected difference behavior diff --git a/docs/inline.md b/docs/inline.md index 5e3357573..efe6f5ccb 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -75,8 +75,9 @@ DiffEngineViewer --inline --source --line < the.inlinepat For the producing side — a test library with a failing inline snapshot: -* `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff. -* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. That framework is the running process's, which makes this the test run's verb and only the test run's: a surface applying a patch of its own wants `SettleAppliedInline`, [below](#applying-a-patch-from-another-surface). Pass `memberName` and `value` too: `value` is what the passing call's expected argument holds, as the library compared it (for F#, after `SourceLanguage.SnapshotValue`). Once an accept above a call site moves it, its line no longer names its entry and the member is the fallback. `value` narrows that fallback to an entry the value settles, one anchored to it or waiting to become it. Without `value`, a passing call can settle the entry of a failing sibling in the same member. +* `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff. A viewer that was started and exited with a failure before it held the queue is `NoViewerFound` as well: one too old for the launch, or with no runtime to run on, took nothing. +* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. That framework is the running process's, which makes this the test run's verb and only the test run's: a surface applying a patch of its own wants `SettleAppliedInline`, [below](#applying-a-patch-from-another-surface). Pass `memberName` and `value` too: `value` is what the passing call's expected argument holds, as the library compared it (for F#, after `SourceLanguage.SnapshotValue`). Once an accept above a call site moves it, its line no longer names its entry and the member is the fallback. `value` narrows that fallback to an entry the value settles, one anchored to it or waiting to become it. Without `value`, a passing call can settle the entry of a failing sibling in the same member. The line can also come to name another call's entry, so one found under it that was queued from a different member is left alone unless `value` settles it. A failing re-run of a call site that has moved is recognised the same way, by its member, test and anchor, and updates its entry rather than queueing a second one beside it. +* `InlineStaging.Settle(sourceFile, line, memberName)` clears what the running framework staged for a call site that now passes. `InlinePatchFile.Write` labels a patch that carries no framework with the running one, so a framework that passes does not clear what another one staged. `InlineStaging.Clear` still clears every framework's, which is what retiring a call site wants. * An absent owner is also remembered. A port found with nothing listening is taken as still unowned for ten minutes, and the sends that only tell the owner something — settle, retire, a move or delete to track, the first attempt to queue a patch — return without connecting while that stands. A refused loopback connection is not free on Windows: the firewall's stealth mode, on by default, drops the reset a closed port would answer with, so each refusal takes two seconds, and a green run settling once per inline verification was spending minutes on them. Anything that has to reach an owner probes for itself before launching a viewer, and that probe, like every listing, always connects and corrects the memory with what it finds. * `AddInlineAsync` stamps `patch.Framework` with the consuming project's target framework ("net9.0", "net48") unless the caller already set it, which is what lets the owner tell a re-run from another framework disagreeing. The value is the `$(TargetFramework)` the package's build targets stamp into the project's runtimeconfig, read back rather than asked of the process — in a hosted test run the entry assembly is the runner (testhost, ReSharperTestRunner), whose framework is not the project's — with the running runtime's version as the fallback for consumers without the targets. Callers may also set `patch.TestName`, which the viewer uses to group and label the queue; without it, items are labeled by call site. * Set `patch.OriginalExpression` from `CallerArgumentExpression` where the language supplies one, and `patch.OriginalValue` — the previous expected argument's value — where it does not. One of the two is what stops a patch rewriting the wrong call site when the file has moved since the run. `patch.MemberName` from `CallerMemberName` narrows it further, and is supported everywhere including F#. @@ -100,7 +101,7 @@ originalValue: {base64} memberName: {base64} ``` -`lineHint` is a hint: locating the call is content anchored, so a file that shifted since the test run still patches, and one whose call site changed reports rather than corrupts. `mode` is `Set` (replace or insert the expected argument), `Append` (add a Snapshot call where none exists yet), or `Remove` (delete the call, used when migrating a snapshot back to a file). `testName` and `framework` are optional provenance — who produced the patch and under which target framework — parsed tolerantly: absent means unknown, and unknown trailing lines are ignored. +`lineHint` is a hint: locating the call is content anchored, so a file that shifted since the test run still patches, and one whose call site changed reports rather than corrupts. `mode` is `Set` (replace or insert the expected argument), `Append` (add a Snapshot call where none exists yet), or `Remove` (delete the call, used when migrating a snapshot back to a file). A `Remove` of a call that is a statement by itself on a variable, `settings.Snapshot("old");`, takes the statement with it, since the variable alone is no statement, and is `NotFound` where the statement cannot be lifted out whole. Where the call's value is awaited, assigned, returned or passed, the call alone goes. `testName` and `framework` are optional provenance — who produced the patch and under which target framework — parsed tolerantly: absent means unknown, and unknown trailing lines are ignored. The anchor is `originalExpression`, the source text of the argument the test run saw. A producer whose language does not implement `CallerArgumentExpression` sends `originalValue` instead — the argument's *value* — and the call whose literal parses to it is the one rewritten. Either identifies the call; the expression is used where both arrived, being what the source actually says. With neither, all a patch has is the hint, and a literal that differs is taken as the snapshot that changed rather than as a conflict — otherwise an inline snapshot could be accepted once and never updated. @@ -131,7 +132,7 @@ await Verify(value).Snapshot( """); ``` -**Indentation.** The call line's own leading whitespace, plus one level. What a level is comes from two places: the character from the call site, so a tab indented method inside a space indented file stays on tabs, and the width from the file, taken as the most common run of whitespace its lines add to the line above. A file that indents by two spaces gets two; four spaces is a fallback for a file with no indentation to read, not a default. Blank lines inside the content are emitted bare, so the literal carries no trailing whitespace. +**Indentation.** The call line's own leading whitespace, plus one level. In F#, where indentation is syntax, a call with something before it on its line (`do!`, `let! result =`) is measured from the column its expression starts at instead, since a continuation has to sit right of that column to compile. What a level is comes from two places: the character from the call site, so a tab indented method inside a space indented file stays on tabs, and the width from the file, taken as the most common run of whitespace its lines add to the line above. A file that indents by two spaces gets two; four spaces is a fallback for a file with no indentation to read, not a default. Blank lines inside the content are emitted bare, so the literal carries no trailing whitespace. **Line endings.** The file's dominant ending, with the content normalised to it, so a patch produced on one platform applies cleanly on another. A file that mixes endings keeps every ending it already had: only the spliced span is written, and the rest of the file — encoding, BOM and all — is preserved byte for byte. @@ -168,7 +169,7 @@ The alternative, writing content at the left margin so the literal means itself, One thing C# can do that F# cannot is widen a delimiter (FS1232), so content containing `"""`, or starting or ending with a quote, has no multi-line form at all and takes a regular literal on one source line. Single line content is always a regular literal, escaping what both languages escape (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. -Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. +Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. The same holds in C# for `ConfigureAwait` and `GetAwaiter`, and for `ToTask` there too: none of them returns something `Snapshot` can be called on, so in either language an appended call goes in front of the first of them. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. ```fsharp // before @@ -214,6 +215,8 @@ For the staging fallback, where no viewer could be resolved and the patch is a f `AlreadyApplied` covers an `Append` onto a call that already has a `Snapshot` call holding this same content, which is what a multi-targeted project transitioning to inline meets: every framework fails the call site and queues an append, and whichever is accepted first writes the literal the rest are carrying. Only a chained call holding *different* content is `NotFound` — that one genuinely cannot say what it wants until it has been re-run against the literal now in the source. Accepting one framework's append before the others have run does mean the queue never sees them together, so a real disagreement between frameworks is reported as that `NotFound` rather than as a conflict to pick from. +That is the call on the recorded line. Where the line no longer lands on the call and the patch names its member, an `Append` passes over the calls in that member that already have a `Snapshot` call and takes the first that has none. + `Remove` mode patches are configuration changes with nothing to review: apply them directly; `AddInlineAsync` refuses them. diff --git a/docs/mdsource/diff-tool.source.md b/docs/mdsource/diff-tool.source.md index 7b00f11d2..99f4fc032 100644 --- a/docs/mdsource/diff-tool.source.md +++ b/docs/mdsource/diff-tool.source.md @@ -10,6 +10,8 @@ Behavior depends on if an [EmptyFiles](https://github.com/SimonCropp/EmptyFiles) * If an EmptyFiles can be found matching the current extension, then the tool will be launched to compare the input to that empty file. * If no EmptyFiles can be found no tool will be launched. +The bundled [DiffEngineViewer](/docs/viewer.md) is the exception. It needs no file to compare against, so none is written: the input is shown against an empty side, and stays that way until it is accepted. That holds for every extension, including those with no EmptyFiles. + ## Detected difference behavior diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index cc8f92fe5..be28af051 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -68,8 +68,9 @@ DiffEngineViewer --inline --source --line < the.inlinepat For the producing side — a test library with a failing inline snapshot: -* `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff. -* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. That framework is the running process's, which makes this the test run's verb and only the test run's: a surface applying a patch of its own wants `SettleAppliedInline`, [below](#applying-a-patch-from-another-surface). Pass `memberName` and `value` too: `value` is what the passing call's expected argument holds, as the library compared it (for F#, after `SourceLanguage.SnapshotValue`). Once an accept above a call site moves it, its line no longer names its entry and the member is the fallback. `value` narrows that fallback to an entry the value settles, one anchored to it or waiting to become it. Without `value`, a passing call can settle the entry of a failing sibling in the same member. +* `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff. A viewer that was started and exited with a failure before it held the queue is `NoViewerFound` as well: one too old for the launch, or with no runtime to run on, took nothing. +* `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. That framework is the running process's, which makes this the test run's verb and only the test run's: a surface applying a patch of its own wants `SettleAppliedInline`, [below](#applying-a-patch-from-another-surface). Pass `memberName` and `value` too: `value` is what the passing call's expected argument holds, as the library compared it (for F#, after `SourceLanguage.SnapshotValue`). Once an accept above a call site moves it, its line no longer names its entry and the member is the fallback. `value` narrows that fallback to an entry the value settles, one anchored to it or waiting to become it. Without `value`, a passing call can settle the entry of a failing sibling in the same member. The line can also come to name another call's entry, so one found under it that was queued from a different member is left alone unless `value` settles it. A failing re-run of a call site that has moved is recognised the same way, by its member, test and anchor, and updates its entry rather than queueing a second one beside it. +* `InlineStaging.Settle(sourceFile, line, memberName)` clears what the running framework staged for a call site that now passes. `InlinePatchFile.Write` labels a patch that carries no framework with the running one, so a framework that passes does not clear what another one staged. `InlineStaging.Clear` still clears every framework's, which is what retiring a call site wants. * An absent owner is also remembered. A port found with nothing listening is taken as still unowned for ten minutes, and the sends that only tell the owner something — settle, retire, a move or delete to track, the first attempt to queue a patch — return without connecting while that stands. A refused loopback connection is not free on Windows: the firewall's stealth mode, on by default, drops the reset a closed port would answer with, so each refusal takes two seconds, and a green run settling once per inline verification was spending minutes on them. Anything that has to reach an owner probes for itself before launching a viewer, and that probe, like every listing, always connects and corrects the memory with what it finds. * `AddInlineAsync` stamps `patch.Framework` with the consuming project's target framework ("net9.0", "net48") unless the caller already set it, which is what lets the owner tell a re-run from another framework disagreeing. The value is the `$(TargetFramework)` the package's build targets stamp into the project's runtimeconfig, read back rather than asked of the process — in a hosted test run the entry assembly is the runner (testhost, ReSharperTestRunner), whose framework is not the project's — with the running runtime's version as the fallback for consumers without the targets. Callers may also set `patch.TestName`, which the viewer uses to group and label the queue; without it, items are labeled by call site. * Set `patch.OriginalExpression` from `CallerArgumentExpression` where the language supplies one, and `patch.OriginalValue` — the previous expected argument's value — where it does not. One of the two is what stops a patch rewriting the wrong call site when the file has moved since the run. `patch.MemberName` from `CallerMemberName` narrows it further, and is supported everywhere including F#. @@ -93,7 +94,7 @@ originalValue: {base64} memberName: {base64} ``` -`lineHint` is a hint: locating the call is content anchored, so a file that shifted since the test run still patches, and one whose call site changed reports rather than corrupts. `mode` is `Set` (replace or insert the expected argument), `Append` (add a Snapshot call where none exists yet), or `Remove` (delete the call, used when migrating a snapshot back to a file). `testName` and `framework` are optional provenance — who produced the patch and under which target framework — parsed tolerantly: absent means unknown, and unknown trailing lines are ignored. +`lineHint` is a hint: locating the call is content anchored, so a file that shifted since the test run still patches, and one whose call site changed reports rather than corrupts. `mode` is `Set` (replace or insert the expected argument), `Append` (add a Snapshot call where none exists yet), or `Remove` (delete the call, used when migrating a snapshot back to a file). A `Remove` of a call that is a statement by itself on a variable, `settings.Snapshot("old");`, takes the statement with it, since the variable alone is no statement, and is `NotFound` where the statement cannot be lifted out whole. Where the call's value is awaited, assigned, returned or passed, the call alone goes. `testName` and `framework` are optional provenance — who produced the patch and under which target framework — parsed tolerantly: absent means unknown, and unknown trailing lines are ignored. The anchor is `originalExpression`, the source text of the argument the test run saw. A producer whose language does not implement `CallerArgumentExpression` sends `originalValue` instead — the argument's *value* — and the call whose literal parses to it is the one rewritten. Either identifies the call; the expression is used where both arrived, being what the source actually says. With neither, all a patch has is the hint, and a literal that differs is taken as the snapshot that changed rather than as a conflict — otherwise an inline snapshot could be accepted once and never updated. @@ -124,7 +125,7 @@ await Verify(value).Snapshot( """); ``` -**Indentation.** The call line's own leading whitespace, plus one level. What a level is comes from two places: the character from the call site, so a tab indented method inside a space indented file stays on tabs, and the width from the file, taken as the most common run of whitespace its lines add to the line above. A file that indents by two spaces gets two; four spaces is a fallback for a file with no indentation to read, not a default. Blank lines inside the content are emitted bare, so the literal carries no trailing whitespace. +**Indentation.** The call line's own leading whitespace, plus one level. In F#, where indentation is syntax, a call with something before it on its line (`do!`, `let! result =`) is measured from the column its expression starts at instead, since a continuation has to sit right of that column to compile. What a level is comes from two places: the character from the call site, so a tab indented method inside a space indented file stays on tabs, and the width from the file, taken as the most common run of whitespace its lines add to the line above. A file that indents by two spaces gets two; four spaces is a fallback for a file with no indentation to read, not a default. Blank lines inside the content are emitted bare, so the literal carries no trailing whitespace. **Line endings.** The file's dominant ending, with the content normalised to it, so a patch produced on one platform applies cleanly on another. A file that mixes endings keeps every ending it already had: only the spliced span is written, and the rest of the file — encoding, BOM and all — is preserved byte for byte. @@ -161,7 +162,7 @@ The alternative, writing content at the left margin so the literal means itself, One thing C# can do that F# cannot is widen a delimiter (FS1232), so content containing `"""`, or starting or ending with a quote, has no multi-line form at all and takes a regular literal on one source line. Single line content is always a regular literal, escaping what both languages escape (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. -Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. +Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. The same holds in C# for `ConfigureAwait` and `GetAwaiter`, and for `ToTask` there too: none of them returns something `Snapshot` can be called on, so in either language an appended call goes in front of the first of them. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. ```fsharp // before @@ -207,6 +208,8 @@ For the staging fallback, where no viewer could be resolved and the patch is a f `AlreadyApplied` covers an `Append` onto a call that already has a `Snapshot` call holding this same content, which is what a multi-targeted project transitioning to inline meets: every framework fails the call site and queues an append, and whichever is accepted first writes the literal the rest are carrying. Only a chained call holding *different* content is `NotFound` — that one genuinely cannot say what it wants until it has been re-run against the literal now in the source. Accepting one framework's append before the others have run does mean the queue never sees them together, so a real disagreement between frameworks is reported as that `NotFound` rather than as a conflict to pick from. +That is the call on the recorded line. Where the line no longer lands on the call and the patch names its member, an `Append` passes over the calls in that member that already have a `Snapshot` call and takes the first that has none. + `Remove` mode patches are configuration changes with nothing to review: apply them directly; `AddInlineAsync` refuses them. diff --git a/docs/mdsource/tray.source.md b/docs/mdsource/tray.source.md index 0bf1f9488..25a1e4f46 100644 --- a/docs/mdsource/tray.source.md +++ b/docs/mdsource/tray.source.md @@ -43,7 +43,7 @@ A test can produce multiple resulting snapshots. If the accepted versions has a Clicking "file1" or "file2" will delete file1 or file2 respectively. The drop down will expose extra actions for that change. -A delete is withdrawn when a later test run verifies against its file again, since the file is then in use rather than stale: `DiffRunner.SettleDelete(file)` drops the pending delete and leaves the file alone. It reaches a tray that owns the inline queue, which is the usual arrangement since the tray starts at login. +A delete is withdrawn when a later test run verifies against its file again, since the file is then in use rather than stale: `DiffRunner.SettleDelete(file)` drops the pending delete and leaves the file alone. It reaches a tray that owns the inline queue, which is the usual arrangement since the tray starts at login. A pending move onto the file withdraws the delete as well, whichever process owns the queue: a run that produced a received file for it is a run that verified against it. ### Pending snapshots @@ -56,13 +56,15 @@ The viewer installed with the tray also reads [documents](/docs/viewer.md#docume "Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way. -Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests. +Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. Logging off or shutting down does the same, and a snapshot that arrives once the session is ending is refused, so the test run stages it itself. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests. ### Accept all "Accept all" will accept all pending moves, deletes and inline snapshots. Snapshots whose target frameworks disagree about the content are skipped rather than picked between; resolve those in the viewer. +The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that a move in the same accept has written is left pending rather than carried out. + A long queue takes a while to accept. An open [DiffEngineViewer](/docs/viewer.md) window shows how far it has got, with each snapshot leaving the list as it lands. diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index 65a6c2e71..88d290edc 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -47,7 +47,7 @@ flowchart TD Tray -->|no| Bundled{"A copy for this platform bundled in the
DiffEngine package the project references?"} Bundled -->|yes| Package["The bundled copy"] Bundled -->|no| Cache{"A DiffEngine package
in the NuGet cache?"} - Cache -->|yes| Cached["The copy bundled in that package:
this version first, then any"] + Cache -->|yes| Cached["The copy bundled in that package:
this version first, then the highest"] Cache -->|no| OnPath{"DiffEngineViewer on PATH?"} OnPath -->|yes| Found["The copy on PATH"] OnPath -->|no| None["No viewer: files go to another diff tool, and
an inline snapshot is staged as files instead"] @@ -64,6 +64,10 @@ flowchart TD An installed tool comes first because installing one is an explicit choice of which viewer to run. The bundled copy comes ahead of the NuGet cache because it is the version the library about to launch it was built with; the cache is searched as well because not every project shape tells the library where its package is. +There is one exception to the first copy found. A copy from before 20.5.0 is passed over when a newer one is further down that list, and taken only when it is the only one there is. A viewer that old exits on the `--payload` file a failing inline snapshot is launched with, so an installed tool or tray that had not been updated lost the snapshot. A copy named by `DiffEngine_DiffEngineViewer` is used as named, whatever its version. + +A viewer that is started and exits with a failure before it holds the queue is reported as not started, so an inline snapshot is staged as files rather than said to be queued. + The folder is looked for beside the copy that was found rather than assumed from where it came from, so a tool installed before documents existed is a minimal viewer, and is not offered files it would show as text. Setting `DiffEngine_DiffEngineViewer` to a path with no viewer at it is an error rather than a fall through to the next copy. So the full viewer is one install away, with nothing to configure afterwards: @@ -177,7 +181,7 @@ What lands on the clipboard is what is on screen: tabs already expanded to the f A test run that fails several inline snapshots produces one window, not several. Whichever process binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. -**Accept all** takes as long as the queue is long, so it goes one entry at a time. Each entry leaves the list as it lands, the status line says how far it has got (`Accepting 12 of 40`), and the window keeps responding throughout. **Accept**, **Discard** and **Accept all** are disabled until it finishes; scrolling, selecting and copying are not. It is the same when [DiffEngineTray](/docs/tray.md) holds the queue, and when the accept-all was started from the tray's menu: the window follows the tray's progress. +**Accept all** takes as long as the queue is long, so it goes a step at a time. Each entry leaves the list as it lands, the status line says how far it has got (`Accepting 12 of 40`), and the window keeps responding throughout. The snapshots of one source file are a single step: they are written into the file together, with one read and one write, and leave the list together. **Accept**, **Discard** and **Accept all** are disabled until it finishes; scrolling, selecting and copying are not. **Accept all in ...** on a header goes the same way, over that header's entries. It is the same when [DiffEngineTray](/docs/tray.md) holds the queue, and when the accept-all was started from the tray's menu: the window follows the tray's progress. Failing file comparisons join the same queue, so a run that fails ten snapshots opens one window whether they are inline or on disk. Every other diff tool gets a process per pair, and DiffEngine closes each one as its test starts passing; the viewer is told to drop that row instead. @@ -230,7 +234,7 @@ A conflicted entry is marked `*` in the list, the pane header names the framewor When [DiffEngineTray](/docs/tray.md) owns the queue, the viewer also lists the tray's pending file moves and deletes beside the snapshots, grouped by solution like everything else. A move shows the received file against the committed one; a delete shows the file's content against nothing. The files are read locally — the protocol never leaves the machine — and accept and discard are forwarded to the tray, which is why the buttons name the act: **Accept move**, **Accept delete**. -**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. +**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. The deletes are the ones pending when it began, and are held back when a snapshot was not written. One for a file that a move in the same sweep has written is left pending. A viewer that owns the queue itself never shows moves or deletes, because DiffEngine only sends them to a running tray. @@ -294,7 +298,7 @@ A document opens at its first page that differs, the way a text comparison opens The status line says what is known about the pair as it becomes known: `reading text` and `drawing` while that happens, which page is showing and which differ, `every page draws the same` when the files differ only where nothing shows, and `documents are identical` when the bytes match. Each pane's header names the page it shows, `received.pdf (page 2 of 5)`, or `(no page 6)` when that side has fewer. -Reading and drawing happen on a thread of their own once the window is up, so a long document never holds a test run waiting on the viewer, and never holds the window either. Only the entry on screen is read and drawn: with several documents pending, the rest wait until they are opened, rather than keeping the thread busy with documents that may never be looked at. While a page is still being drawn, a spinner turns where it will appear. Each document is read from a copy the viewer takes, never the file itself, so it cannot hold a lock that stops it being accepted. They run inside the viewer's process: a document that hangs is given up on after two minutes, but one that crashes PDFium or Skia takes the window with it, and with no tray running, any inline snapshots the window was holding. +Reading and drawing happen on a thread of their own once the window is up, so a long document never holds a test run waiting on the viewer, and never holds the window either. Only the entry on screen is read and drawn: with several documents pending, the rest wait until they are opened, rather than keeping the thread busy with documents that may never be looked at. While a page is still being drawn, a spinner turns where it will appear. The two sides of a pair are drawn at the same time, so the pages of the right appear beside the pages of the left, and which pages differ is known as they are drawn rather than once both are done. Each document is read from a copy the viewer takes, never the file itself, so it cannot hold a lock that stops it being accepted. They run inside the viewer's process: each side is given up on once two minutes pass with nothing coming of it, neither its text nor another page, and a PDF opened while that one is still being read, the other side of its own pair included, waits for it rather than failing. A copy that could not be taken, because the disk was full or something held the file, is said in the status line and tried again. One that crashes PDFium or Skia takes the window with it, and with no tray running, any inline snapshots the window was holding. A file that is not the document its extension says is ordinary — a test that failed part way through writing its snapshot, an empty file, an error page saved as a PDF — and is reported rather than drawn. The status line says so once, about the file: `could not read report.received.docx: Not a readable Word document: it is not a zip archive, or was cut short`, or `The file is empty`. Its pane shows what the file is and how large instead of text, its header says `(not drawn)`, and the other side, if it is whole, is still read and drawn. Nothing about it carries over to the next document. diff --git a/docs/tray.md b/docs/tray.md index cf8c90525..d55593617 100644 --- a/docs/tray.md +++ b/docs/tray.md @@ -50,7 +50,7 @@ A test can produce multiple resulting snapshots. If the accepted versions has a Clicking "file1" or "file2" will delete file1 or file2 respectively. The drop down will expose extra actions for that change. -A delete is withdrawn when a later test run verifies against its file again, since the file is then in use rather than stale: `DiffRunner.SettleDelete(file)` drops the pending delete and leaves the file alone. It reaches a tray that owns the inline queue, which is the usual arrangement since the tray starts at login. +A delete is withdrawn when a later test run verifies against its file again, since the file is then in use rather than stale: `DiffRunner.SettleDelete(file)` drops the pending delete and leaves the file alone. It reaches a tray that owns the inline queue, which is the usual arrangement since the tray starts at login. A pending move onto the file withdraws the delete as well, whichever process owns the queue: a run that produced a received file for it is a run that verified against it. ### Pending snapshots @@ -63,13 +63,15 @@ The viewer installed with the tray also reads [documents](/docs/viewer.md#docume "Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way. -Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests. +Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's `obj/VerifyInline/`, where accept tooling such as [Verify.Terminal](https://github.com/VerifyTests/Verify.Terminal) still finds them. Logging off or shutting down does the same, and a snapshot that arrives once the session is ending is refused, so the test run stages it itself. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests. ### Accept all "Accept all" will accept all pending moves, deletes and inline snapshots. Snapshots whose target frameworks disagree about the content are skipped rather than picked between; resolve those in the viewer. +The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that a move in the same accept has written is left pending rather than carried out. + A long queue takes a while to accept. An open [DiffEngineViewer](/docs/viewer.md) window shows how far it has got, with each snapshot leaving the list as it lands. diff --git a/docs/viewer.md b/docs/viewer.md index 59bd23dea..d9b4037b7 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -54,7 +54,7 @@ flowchart TD Tray -->|no| Bundled{"A copy for this platform bundled in the
DiffEngine package the project references?"} Bundled -->|yes| Package["The bundled copy"] Bundled -->|no| Cache{"A DiffEngine package
in the NuGet cache?"} - Cache -->|yes| Cached["The copy bundled in that package:
this version first, then any"] + Cache -->|yes| Cached["The copy bundled in that package:
this version first, then the highest"] Cache -->|no| OnPath{"DiffEngineViewer on PATH?"} OnPath -->|yes| Found["The copy on PATH"] OnPath -->|no| None["No viewer: files go to another diff tool, and
an inline snapshot is staged as files instead"] @@ -71,6 +71,10 @@ flowchart TD An installed tool comes first because installing one is an explicit choice of which viewer to run. The bundled copy comes ahead of the NuGet cache because it is the version the library about to launch it was built with; the cache is searched as well because not every project shape tells the library where its package is. +There is one exception to the first copy found. A copy from before 20.5.0 is passed over when a newer one is further down that list, and taken only when it is the only one there is. A viewer that old exits on the `--payload` file a failing inline snapshot is launched with, so an installed tool or tray that had not been updated lost the snapshot. A copy named by `DiffEngine_DiffEngineViewer` is used as named, whatever its version. + +A viewer that is started and exits with a failure before it holds the queue is reported as not started, so an inline snapshot is staged as files rather than said to be queued. + The folder is looked for beside the copy that was found rather than assumed from where it came from, so a tool installed before documents existed is a minimal viewer, and is not offered files it would show as text. Setting `DiffEngine_DiffEngineViewer` to a path with no viewer at it is an error rather than a fall through to the next copy. So the full viewer is one install away, with nothing to configure afterwards: @@ -184,7 +188,7 @@ What lands on the clipboard is what is on screen: tabs already expanded to the f A test run that fails several inline snapshots produces one window, not several. Whichever process binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. -**Accept all** takes as long as the queue is long, so it goes one entry at a time. Each entry leaves the list as it lands, the status line says how far it has got (`Accepting 12 of 40`), and the window keeps responding throughout. **Accept**, **Discard** and **Accept all** are disabled until it finishes; scrolling, selecting and copying are not. It is the same when [DiffEngineTray](/docs/tray.md) holds the queue, and when the accept-all was started from the tray's menu: the window follows the tray's progress. +**Accept all** takes as long as the queue is long, so it goes a step at a time. Each entry leaves the list as it lands, the status line says how far it has got (`Accepting 12 of 40`), and the window keeps responding throughout. The snapshots of one source file are a single step: they are written into the file together, with one read and one write, and leave the list together. **Accept**, **Discard** and **Accept all** are disabled until it finishes; scrolling, selecting and copying are not. **Accept all in ...** on a header goes the same way, over that header's entries. It is the same when [DiffEngineTray](/docs/tray.md) holds the queue, and when the accept-all was started from the tray's menu: the window follows the tray's progress. Failing file comparisons join the same queue, so a run that fails ten snapshots opens one window whether they are inline or on disk. Every other diff tool gets a process per pair, and DiffEngine closes each one as its test starts passing; the viewer is told to drop that row instead. @@ -237,7 +241,7 @@ A conflicted entry is marked `*` in the list, the pane header names the framewor When [DiffEngineTray](/docs/tray.md) owns the queue, the viewer also lists the tray's pending file moves and deletes beside the snapshots, grouped by solution like everything else. A move shows the received file against the committed one; a delete shows the file's content against nothing. The files are read locally — the protocol never leaves the machine — and accept and discard are forwarded to the tray, which is why the buttons name the act: **Accept move**, **Accept delete**. -**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. +**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. The deletes are the ones pending when it began, and are held back when a snapshot was not written. One for a file that a move in the same sweep has written is left pending. A viewer that owns the queue itself never shows moves or deletes, because DiffEngine only sends them to a running tray. @@ -301,7 +305,7 @@ A document opens at its first page that differs, the way a text comparison opens The status line says what is known about the pair as it becomes known: `reading text` and `drawing` while that happens, which page is showing and which differ, `every page draws the same` when the files differ only where nothing shows, and `documents are identical` when the bytes match. Each pane's header names the page it shows, `received.pdf (page 2 of 5)`, or `(no page 6)` when that side has fewer. -Reading and drawing happen on a thread of their own once the window is up, so a long document never holds a test run waiting on the viewer, and never holds the window either. Only the entry on screen is read and drawn: with several documents pending, the rest wait until they are opened, rather than keeping the thread busy with documents that may never be looked at. While a page is still being drawn, a spinner turns where it will appear. Each document is read from a copy the viewer takes, never the file itself, so it cannot hold a lock that stops it being accepted. They run inside the viewer's process: a document that hangs is given up on after two minutes, but one that crashes PDFium or Skia takes the window with it, and with no tray running, any inline snapshots the window was holding. +Reading and drawing happen on a thread of their own once the window is up, so a long document never holds a test run waiting on the viewer, and never holds the window either. Only the entry on screen is read and drawn: with several documents pending, the rest wait until they are opened, rather than keeping the thread busy with documents that may never be looked at. While a page is still being drawn, a spinner turns where it will appear. The two sides of a pair are drawn at the same time, so the pages of the right appear beside the pages of the left, and which pages differ is known as they are drawn rather than once both are done. Each document is read from a copy the viewer takes, never the file itself, so it cannot hold a lock that stops it being accepted. They run inside the viewer's process: each side is given up on once two minutes pass with nothing coming of it, neither its text nor another page, and a PDF opened while that one is still being read, the other side of its own pair included, waits for it rather than failing. A copy that could not be taken, because the disk was full or something held the file, is said in the status line and tried again. One that crashes PDFium or Skia takes the window with it, and with no tray running, any inline snapshots the window was holding. A file that is not the document its extension says is ordinary — a test that failed part way through writing its snapshot, an empty file, an error page saved as a PDF — and is reported rather than drawn. The status line says so once, about the file: `could not read report.received.docx: Not a readable Word document: it is not a zip archive, or was cut short`, or `The file is empty`. Its pane shows what the file is and how large instead of text, its header says `(not drawn)`, and the other side, if it is whole, is still read and drawn. Nothing about it carries over to the next document. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 476c4a8f4..5800fd126 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -36,9 +36,13 @@ set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE) # SUPPORT_CUSTOM_FRAME_CONTROL was: EndDrawing then never put a frame on the screen, never read # input and never waited for the next frame, so the Linux window stayed blank, took no keys and # spun a core. A capture draws into a texture and never gets that far, which is why the pixel -# snapshots passed throughout; PixelTests.PresentWaitsForTheNextFrame is what notices. The busy -# wait loop was on the same way, and would have spun through the whole of every frame's wait once -# there was one. The rest are formats and features nothing here uses. +# snapshots passed throughout; PixelTests.PresentWaitsForTheNextFrame is what notices. +# deview_present has since stopped calling EndDrawing: it puts a frame on the screen only when it +# differs from the one there, and waits and reads input itself whether it drew or not (Rest in +# deview.cpp). So nothing rests on this flag any more, and it stays off for whatever ends a frame +# through raylib next. The busy wait loop was on the same way, and that one still matters: Rest +# waits with raylib's WaitTime, which would spin through the whole of every frame's wait. The rest +# are formats and features nothing here uses. foreach(flag IN ITEMS SUPPORT_CUSTOM_FRAME_CONTROL SUPPORT_BUSY_WAIT_LOOP @@ -96,9 +100,14 @@ add_library(diffengine_viewer SHARED ${imgui_SOURCE_DIR}/imgui_widgets.cpp) target_include_directories(diffengine_viewer PRIVATE include ${imgui_SOURCE_DIR}) -# Threads for the thread pictures are decoded on. +# ImGui's characters are sixteen bits unless told otherwise, and everything past the basic plane, +# which is most emoji and the rarer Han characters, is then the replacement character before any +# font is asked for it. +target_compile_definitions(diffengine_viewer PRIVATE IMGUI_USE_WCHAR32) +# Threads for the threads pictures are decoded and fonts are found on, and dl for fontconfig, +# which is looked for at run time rather than linked. find_package(Threads REQUIRED) -target_link_libraries(diffengine_viewer PRIVATE raylib Threads::Threads) +target_link_libraries(diffengine_viewer PRIVATE raylib Threads::Threads ${CMAKE_DL_LIBS}) # The managed side probes for a bare name; keep it identical on every platform apart from the # platform's own prefix and extension. diff --git a/native/include/deview.h b/native/include/deview.h index 574c08ad0..fd4e67976 100644 --- a/native/include/deview.h +++ b/native/include/deview.h @@ -431,7 +431,13 @@ DEVIEW_API int32_t deview_init( float fontSize, int32_t hidden); -/* Draws one frame. Returns 0 once the window has been closed. */ +/* + * One turn of the window for this screen: whatever of it the window does not already show is + * drawn, the window system's events are read, and the call comes back when the next frame is due, + * which is what holds the managed loop to sixty turns a second. A turn that would draw the frame + * already on the screen draws nothing, so this is called every turn whatever has changed, and the + * caller never has to say what did. Returns 0 once the window has been closed. + */ DEVIEW_API int32_t deview_present(const DeviewScreen* screen); DEVIEW_API void deview_poll_input(DeviewInput* input); diff --git a/native/src/deview.cpp b/native/src/deview.cpp index 8c33de56a..85f7d0289 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -18,6 +18,8 @@ #include "rlgl.h" #include +#include +#include #include #include #include @@ -25,14 +27,22 @@ #include #include #include +#include #include #include #include +#include #include #include #include +#include #include +/* For fontconfig, which is found at run time rather than linked: see Fontconfig. */ +#if !defined(_WIN32) +#include +#endif + /* * raylib latches GLFW's close flag and exposes no way to clear it, but the window has to survive a * close when a tray is running, otherwise every later frame would report closing again. raylib @@ -42,6 +52,19 @@ extern "C" void* glfwGetCurrentContext(void); extern "C" void glfwSetWindowShouldClose(void* window, int value); +/* + * And GLFW's callback for the window system wanting a window's content drawn again, because part + * of it has been uncovered or it has been put back on the screen. raylib asks to be told when a + * window is resized, moved, minimised or focused and not this, since it draws every frame whatever + * happens. A window that has stopped drawing frames nobody needs has to hear it: see State::stale. + * Setting it here takes nothing from raylib, which sets none. + */ +extern "C" +{ +typedef void (*DeviewRefresh)(void* window); +DeviewRefresh glfwSetWindowRefreshCallback(void* window, DeviewRefresh callback); +} + namespace { void ClearCloseFlag() @@ -82,7 +105,8 @@ constexpr float grabWidth = 4.0f; * The correction is the font's own ascent plus descent over its em, and it is a constant because * the only font that reaches here is the JetBrains Mono the managed side embeds: 1020 and 300 over * 1000 units. Swapping that font means revisiting this number, hence naming it rather than folding - * it into the size. + * it into the size. The machine's fonts, merged in for the characters that one lacks, do not come + * through here, and have theirs read out of their own tables: see EmScaleOf. */ constexpr float emScale = 1.32f; @@ -92,6 +116,25 @@ constexpr float emScale = 1.32f; */ constexpr float checkerSize = 8.0f; +/* + * How long a frame lasts, which is what holds the managed loop to sixty turns a second: it calls + * deview_present as fast as that returns. raylib used to do the waiting, inside EndDrawing. It is + * done here now, because a frame that is not drawn has to be waited out as well: see Rest. + */ +constexpr double frameSeconds = 1.0 / 60.0; + +/* + * How many frames in a row have to be built with nothing arriving, and come out as the frame on + * the screen, before frames stop being built: a second of them, since each is waited out. + * + * ImGui does things over several frames and counts some of them in the time it is told has passed. + * A layout can take a second frame to settle, input given in one frame may be acted on over the + * next few, and for a quarter of a second after the pointer leaves a row with a tooltip the next + * row's comes up without its delay. A second is longer than any of them, and costs little: these + * are frames that are built and compared, not drawn. + */ +constexpr int settledFrames = 60; + /* * One decoded picture, kept because BuildFrame runs sixty times a second and decoding an image per * frame is what turns a window that is merely showing something into one that is busy. @@ -118,11 +161,15 @@ struct CachedTexture /* Sampled as its own pixels rather than smoothed: see SampleAsPixels. */ bool point = false; + + /* Some of it can be seen through, so it is drawn over a checkerboard: see SeeThrough. */ + bool translucent = false; }; /* * One picture to decode, or decoded: the path, the stamp the decode was asked for, and once it is - * done the pixels, which are empty when raylib could not read the file. + * done the pixels, which are empty when raylib could not read the file, and whether any of them + * can be seen through. */ struct Decode { @@ -130,6 +177,7 @@ struct Decode std::uintmax_t length = 0; std::filesystem::file_time_type written{}; Image image{}; + bool translucent = false; }; /* @@ -151,6 +199,35 @@ struct Decoder bool stopping = false; }; +/* + * One of the machine's fonts, read whole, for the characters the embedded one does not have: + * its bytes, which of the faces in them, and the scale that makes deview_init's size an em for + * that face, as emScale does for the embedded font. + */ +struct FoundFont +{ + std::vector data; + int face = 0; + float scale = 1.0f; +}; + +/* + * Those fonts are looked for and read on a thread of their own, for the reason pictures are + * decoded on one: fontconfig reading its caches and a CJK collection coming off the disk are tens + * of milliseconds each, and the first is seconds on a machine whose caches are stale. Shared and + * detached as the decoder is, and for its reason. + */ +struct FontFinder +{ + std::mutex mutex; + std::condition_variable wake; + + /* Characters the window's font cannot draw, each asked about once. */ + std::vector wanted; + std::vector found; + bool stopping = false; +}; + struct State { bool initialised = false; @@ -158,6 +235,31 @@ struct State ImGuiContext* context = nullptr; DeviewInput input{}; + /* + * The font the window draws with: the embedded font, and merged into it whichever of the + * machine's fonts a character on screen has needed. + * + * Not the font a capture draws with. That is the embedded font alone, which the atlas holds + * a second time and ahead of this one, so that a capture is the same picture on a machine + * with every font installed and on one with none: a character the embedded font lacks is the + * replacement glyph there, as it was everywhere before this. What the two draw from the + * embedded font is the same glyphs, so the captures still describe the window. + */ + ImFont* font = nullptr; + + /* Started with the first character the font cannot draw, so a window that never shows one + * never has it, nor fontconfig. */ + std::shared_ptr finder; + + /* One bit a code point, set once it has been looked at, so it is asked about once and a + * frame of text already seen costs a pass over its bytes. Empty until something past ASCII + * turns up. */ + std::vector asked; + + /* The bytes of each font merged in. ImGui rasterises a glyph out of them when a character is + * first drawn, so they are kept for as long as the atlas is. */ + std::deque> fontData; + /* Whether the last screen carried a context menu, which is what makes Escape and a click * outside it a dismissal rather than what they would otherwise mean. */ bool menuOpen = false; @@ -190,6 +292,14 @@ struct State /* Started with the first picture asked for, so a window that never shows one never has it. */ std::shared_ptr decoder; + /* + * The checkerboard behind a picture that can be seen through: two squares by two, a texel + * each, which the picture's texture coordinates repeat across it. Made with the first picture + * to need it, and once, whether or not that worked: see Checker. + */ + Texture2D checker{}; + bool checkerMade = false; + /* Inside deview_capture, which draws one frame that has to come out the same every time: its * pictures are decoded there and then, and a spinner stands still. */ bool capturing = false; @@ -263,10 +373,69 @@ struct State */ bool tracked = false; DeviewPlacement normal{}; + + /* + * What decides whether a frame is put on the screen, and whether one is built at all: see + * deview_present. First the screen as it was last handed over, every byte of it, and the one + * being held against it. + */ + std::vector presented; + std::vector arriving; + + /* What was drawn to make the frame on the screen, reduced to a number: see Fingerprint. */ + uint64_t shown = 0; + + /* + * The window cannot be taken to be showing the frame last drawn into it: nothing has been + * drawn yet, the window system has asked for its content again, it has changed size or come + * back from being hidden, or a texture has been put behind a name that frame may have used. + * The next frame is drawn, whatever it comes out as. + */ + bool stale = true; + + /* Frames in a row that had nothing arrive and came out as the one on the screen. */ + int settled = 0; + + /* A queue row's tooltip is waiting out its delay, which ImGui counts in the frames it is + * given: see BuildFrame. */ + bool tooltipDue = false; + + /* When the last present began, and when the last frame's wait ended, by GetTime. */ + double began = 0.0; + double ended = 0.0; + + /* The pointer and the window as the last present found them. */ + Vector2 pointer{}; + int width = 0; + int height = 0; + bool hidden = false; + bool minimised = false; + bool focused = false; + + /* + * The file behind each picture the last frame built for the window asked for, as that frame + * found it: there or not, and if there, written when and how long. See PicturesRewritten. + */ + struct Watched + { + std::string path; + bool there = false; + std::filesystem::file_time_type written{}; + std::uintmax_t length = 0; + }; + + std::vector watched; }; State state; +/* GLFW's refresh callback, called from inside PollInputEvents: the window system has uncovered + * some of the window, or shown it, and what was there is gone. */ +extern "C" void WindowRefreshed(void* window) +{ + state.stale = true; +} + /* * Whether a remembered window would open somewhere it can be reached: its top edge on a monitor, * with enough of its width there to take hold of. Monitors come and go between runs, and a window @@ -488,6 +657,58 @@ void SampleAsPixels(const std::string& path, bool point) found->second.point = point; } +/* + * Whether any of a decoded picture can be seen through: whether it has a pixel that is less than + * opaque. That is what the checkerboard behind a picture is for, and behind a picture with no such + * pixel every square of it is covered, so it is not drawn. + * + * Asked of the pixels rather than of the format. A screenshot or a drawn page of a document is + * usually saved with an alpha channel that is 255 throughout, and those are most of the pictures + * there are. One pass over them as they are decoded, which is off the window's thread for every + * picture but a capture's. + */ +bool SeeThrough(const Image& image) +{ + if (image.data == nullptr) + { + return false; + } + + size_t stride = 0; + size_t alpha = 0; + switch (image.format) + { + case PIXELFORMAT_UNCOMPRESSED_GRAYSCALE: + case PIXELFORMAT_UNCOMPRESSED_R8G8B8: + case PIXELFORMAT_UNCOMPRESSED_R5G6B5: + return false; + case PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA: + stride = 2; + alpha = 1; + break; + case PIXELFORMAT_UNCOMPRESSED_R8G8B8A8: + stride = 4; + alpha = 3; + break; + /* Not one the decoders built here hand back for a picture the viewer shows. Taken to have + * something to see through, which costs one quad where it has not. */ + default: + return true; + } + + const unsigned char* pixels = static_cast(image.data); + const size_t count = static_cast(image.width) * static_cast(image.height); + for (size_t pixel = 0; pixel < count; pixel++) + { + if (pixels[pixel * stride + alpha] != 255) + { + return true; + } + } + + return false; +} + void DecodeLoop(std::shared_ptr decoder) { std::unique_lock lock(decoder->mutex); @@ -505,6 +726,7 @@ void DecodeLoop(std::shared_ptr decoder) /* The file and stb_image under it, and nothing that touches GL. */ decode.image = LoadImage(decode.path.c_str()); + decode.translucent = SeeThrough(decode.image); lock.lock(); if (decoder->stopping) @@ -586,12 +808,15 @@ void StopDecoder() * Uploads what the decoder has finished into the entries still waiting for it. At the top of a * frame, on the thread that owns the GL context. A decode for an entry since forgotten, or for a * file since rewritten, is thrown away. + * + * Returns whether any entry was waiting for what landed, which is a frame to build: the picture is + * there to draw now, or is known not to be coming and its spinner goes. */ -void TakeDecoded() +bool TakeDecoded() { if (!state.decoder) { - return; + return false; } std::vector done; @@ -600,6 +825,7 @@ void TakeDecoded() done.swap(state.decoder->done); } + bool landed = false; for (auto& decode : done) { const auto found = state.pictures.find(decode.path); @@ -610,6 +836,7 @@ void TakeDecoded() { CachedTexture& entry = found->second; entry.decoding = false; + landed = true; if (decode.image.data != nullptr) { const Texture2D texture = LoadTextureFromImage(decode.image); @@ -617,13 +844,21 @@ void TakeDecoded() { entry.texture = texture; entry.loaded = true; + entry.translucent = decode.translucent; PrepareTexture(entry); + + /* GL hands out the name of a texture that has been unloaded again, so a frame + * drawn with this one can be, number for number, a frame drawn with the one + * that had the name before it. */ + state.stale = true; } } } UnloadImage(decode.image); } + + return landed; } void ForgetPicture(const std::string& path) @@ -647,6 +882,47 @@ void ForgetPicture(const std::string& path) state.pictures.erase(found); } +/* + * A file's write time and length, which between them say whether a picture decoded from it is + * still what the file holds. False when either cannot be read, which is a file that has gone. + */ +bool Stamp(const std::string& path, std::filesystem::file_time_type& written, std::uintmax_t& length) +{ + const std::filesystem::path file(path); + std::error_code error; + written = std::filesystem::last_write_time(file, error); + if (error) + { + return false; + } + + length = std::filesystem::file_size(file, error); + return !error; +} + +/* + * Whether the file behind any picture the last frame asked for is no longer as that frame found + * it: written again, gone, or there where it was not. Picture asks this of each picture as a frame + * is built. A window that is being left alone builds no frames, so it is asked of all of them here + * before the window is left alone again. + */ +bool PicturesRewritten() +{ + for (const State::Watched& watched : state.watched) + { + std::filesystem::file_time_type written; + std::uintmax_t length = 0; + const bool there = Stamp(watched.path, written, length); + if (there != watched.there || + (there && (written != watched.written || length != watched.length))) + { + return true; + } + } + + return false; +} + /* * The decoded picture for a path, or null when there is none to draw: either this build cannot * read it, or it is still being decoded, which `loading` says so the pane can show that it is coming @@ -657,7 +933,7 @@ void ForgetPicture(const std::string& path) * queue poller uses: a re-run that rewrites a received image has to refresh the pane rather than * leave the previous one up. */ -const Texture2D* Picture(const std::string& path, bool& loading) +const CachedTexture* Picture(const std::string& path, bool& loading) { loading = false; if (path.empty()) @@ -665,17 +941,15 @@ const Texture2D* Picture(const std::string& path, bool& loading) return nullptr; } - const std::filesystem::path file(path); - std::error_code error; - const auto written = std::filesystem::last_write_time(file, error); - if (error) + std::filesystem::file_time_type written; + std::uintmax_t length = 0; + const bool there = Stamp(path, written, length); + if (!state.capturing) { - ForgetPicture(path); - return nullptr; + state.watched.push_back({path, there, written, length}); } - const auto length = std::filesystem::file_size(file, error); - if (error) + if (!there) { ForgetPicture(path); return nullptr; @@ -691,7 +965,7 @@ const Texture2D* Picture(const std::string& path, bool& loading) { found->second.used = true; loading = found->second.decoding; - return found->second.loaded ? &found->second.texture : nullptr; + return found->second.loaded ? &found->second : nullptr; } ForgetPicture(path); @@ -703,12 +977,20 @@ const Texture2D* Picture(const std::string& path, bool& loading) entry.used = true; if (state.capturing) { - const Texture2D texture = LoadTexture(path.c_str()); - if (IsTextureValid(texture)) + /* What LoadTexture does, taken apart so the pixels can be looked at on the way through. */ + const Image image = LoadImage(path.c_str()); + if (image.data != nullptr) { - entry.texture = texture; - entry.loaded = true; - PrepareTexture(entry); + const Texture2D texture = LoadTextureFromImage(image); + if (IsTextureValid(texture)) + { + entry.texture = texture; + entry.loaded = true; + entry.translucent = SeeThrough(image); + PrepareTexture(entry); + } + + UnloadImage(image); } } else @@ -719,7 +1001,7 @@ const Texture2D* Picture(const std::string& path, bool& loading) } const auto inserted = state.pictures.emplace(path, entry).first; - return inserted->second.loaded ? &inserted->second.texture : nullptr; + return inserted->second.loaded ? &inserted->second : nullptr; } /* @@ -730,43 +1012,622 @@ const Texture2D* Picture(const std::string& path, bool& loading) * every image reviewed in a session stayed decoded, on the GPU, until the session ended. A picture * scrolled or navigated back to is decoded again, which is one file read. */ -void ForgetUnusedPictures() +void ForgetUnusedPictures() +{ + for (auto entry = state.pictures.begin(); entry != state.pictures.end();) + { + if (entry->second.used) + { + entry->second.used = false; + ++entry; + continue; + } + + if (entry->second.loaded) + { + UnloadTexture(entry->second.texture); + } + + if (entry->second.decoding) + { + CancelDecode(entry->first); + } + + entry = state.pictures.erase(entry); + } +} + +/* + * The checkerboard's texture, or null on a context that would not make one, where a picture is + * drawn over the lighter of the two tones instead. + * + * Sampled as its two tones and nothing between them, and repeating, which is what lets one quad + * the size of the picture stand for every square behind it. + */ +const Texture2D* Checker() +{ + if (!state.checkerMade) + { + state.checkerMade = true; + + /* Light where the row and the column are both even or both odd, and dark elsewhere. */ + unsigned char pixels[] = { + 64, 64, 64, 255, 48, 48, 48, 255, + 48, 48, 48, 255, 64, 64, 64, 255}; + Image image{}; + image.data = pixels; + image.width = 2; + image.height = 2; + image.mipmaps = 1; + image.format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8; + state.checker = LoadTextureFromImage(image); + if (IsTextureValid(state.checker)) + { + SetTextureFilter(state.checker, TEXTURE_FILTER_POINT); + SetTextureWrap(state.checker, TEXTURE_WRAP_REPEAT); + } + } + + return IsTextureValid(state.checker) ? &state.checker : nullptr; +} + +void UnloadPictures() +{ + StopDecoder(); + for (auto& entry : state.pictures) + { + if (entry.second.loaded) + { + UnloadTexture(entry.second.texture); + } + } + + state.pictures.clear(); + + if (IsTextureValid(state.checker)) + { + UnloadTexture(state.checker); + } + + state.checker = Texture2D{}; + state.checkerMade = false; +} + +/* ---- fonts ---- */ + +/* + * The machine's own fonts, for the characters the embedded one does not have. + * + * JetBrains Mono has Latin, Greek, Cyrillic and the symbols code is written in, and it was the + * only font here: Chinese, Japanese, Korean, Arabic, Hebrew, Thai and emoji all drew as the + * replacement glyph, so a snapshot holding any of them could not be reviewed in this head. A line + * with one such character changed was marked as changed and looked the same on both sides. The + * other two heads have their toolkits' font fallback. ImGui has none of its own, but it does draw + * a character from the first of a font's sources to have it, so the machine's fonts are merged + * into the window's font as further sources. + * + * Drawn, and not shaped: each character is the glyph its font has for it, where the grid put it. + * Arabic is its letters unjoined and in the order they are stored, and an emoji made of several + * is as many of them as its cells hold. That is enough to see which characters a snapshot holds, + * which is what this is for. + * + * Only the fonts a character on screen has needed, and only once one has. Every font fontconfig + * knows of can be hundreds of megabytes of files, and a screen of ASCII, which is nearly every + * screen, costs a pass over its bytes and nothing else. Until a font lands its characters are the + * replacement glyph they always were. + * + * Never for a capture: see State::font. + */ + +/* As many of the machine's fonts as are ever merged in. ImGui numbers a font's sources in four + * bits, and the embedded font is the first of them. */ +constexpr size_t fontLimit = 15; + +/* + * fontconfig's FcFontSet, whose layout is part of its ABI, and the entry points this uses. + * + * Found in the library when a character first needs them rather than linked. Linked, a machine + * without fontconfig could not load this library at all, and building it would need fontconfig's + * headers. Found at run time, such a machine has no fonts to offer, which is what every machine + * had before. + */ +struct FontSet +{ + int count; + int capacity; + void** fonts; +}; + +struct Fontconfig +{ + void* (*initLoadConfigAndFonts)() = nullptr; + void* (*nameParse)(const unsigned char* name) = nullptr; + int (*configSubstitute)(void* config, void* pattern, int kind) = nullptr; + void (*defaultSubstitute)(void* pattern) = nullptr; + FontSet* (*fontSort)(void* config, void* pattern, int trim, void** charset, int* result) = nullptr; + int (*patternGetString)(const void* pattern, const char* object, int index, unsigned char** value) = nullptr; + int (*patternGetInteger)(const void* pattern, const char* object, int index, int* value) = nullptr; + int (*patternGetBool)(const void* pattern, const char* object, int index, int* value) = nullptr; + int (*patternGetCharSet)(const void* pattern, const char* object, int index, void** value) = nullptr; + int (*charSetHasChar)(const void* charset, unsigned int codepoint) = nullptr; +}; + +/* + * Every font on the machine that says which characters it has, in the order fontconfig falls + * back through them from a monospace font for this user's language. That is the answer every + * other program here is given, and it is what puts the Japanese forms of the Han characters + * ahead of the Chinese ones for a Japanese reader. + */ +struct SystemFonts +{ + bool opened = false; + Fontconfig fontconfig; + + struct Candidate + { + const void* pattern; + const void* charset; + }; + + std::vector candidates; + + /* Handed over already, and found to be something stb_truetype cannot draw from. By file and + * face rather than by candidate, because one face can be listed more than once. */ + std::set> taken; + std::set> unusable; +}; + +#if !defined(_WIN32) +template +bool Resolve(void* library, const char* name, Entry& entry) +{ + entry = reinterpret_cast(dlsym(library, name)); + return entry != nullptr; +} +#endif + +/* On the finder's thread, the first time a character is asked about. What fontconfig hands back + * is not given back: the candidates point into it for as long as the thread runs, which is as + * long as the window does, and a process has the one window. */ +void OpenSystemFonts(SystemFonts& fonts) +{ + fonts.opened = true; +#if !defined(_WIN32) + void* library = dlopen("libfontconfig.so.1", RTLD_NOW | RTLD_LOCAL); + if (library == nullptr) + { + return; + } + + Fontconfig& fontconfig = fonts.fontconfig; + if (!Resolve(library, "FcInitLoadConfigAndFonts", fontconfig.initLoadConfigAndFonts) || + !Resolve(library, "FcNameParse", fontconfig.nameParse) || + !Resolve(library, "FcConfigSubstitute", fontconfig.configSubstitute) || + !Resolve(library, "FcDefaultSubstitute", fontconfig.defaultSubstitute) || + !Resolve(library, "FcFontSort", fontconfig.fontSort) || + !Resolve(library, "FcPatternGetString", fontconfig.patternGetString) || + !Resolve(library, "FcPatternGetInteger", fontconfig.patternGetInteger) || + !Resolve(library, "FcPatternGetBool", fontconfig.patternGetBool) || + !Resolve(library, "FcPatternGetCharSet", fontconfig.patternGetCharSet) || + !Resolve(library, "FcCharSetHasChar", fontconfig.charSetHasChar)) + { + return; + } + + void* config = fontconfig.initLoadConfigAndFonts(); + void* pattern = fontconfig.nameParse(reinterpret_cast("monospace")); + if (config == nullptr || + pattern == nullptr) + { + return; + } + + /* The two steps every match is prepared with: the configuration's rules, which is where + * "monospace" becomes the fonts the machine means by it, and the defaults, which is where + * the user's language comes from. */ + fontconfig.configSubstitute(config, pattern, 0); + fontconfig.defaultSubstitute(pattern); + + /* Untrimmed. Trimming leaves out a font with no character the ones ahead of it lack, which + * it works out by uniting every character set in turn, and each character is asked of them + * here anyway. */ + int result = 0; + const FontSet* sorted = fontconfig.fontSort(config, pattern, 0, nullptr, &result); + if (sorted == nullptr) + { + return; + } + + for (int index = 0; index < sorted->count; index++) + { + void* charset = nullptr; + if (fontconfig.patternGetCharSet(sorted->fonts[index], "charset", 0, &charset) == 0 && + charset != nullptr) + { + fonts.candidates.push_back({sorted->fonts[index], charset}); + } + } +#endif +} + +constexpr uint32_t Tag(char first, char second, char third, char fourth) +{ + return static_cast(static_cast(first)) << 24 | + static_cast(static_cast(second)) << 16 | + static_cast(static_cast(third)) << 8 | + static_cast(static_cast(fourth)); +} + +/* + * The scale that makes deview_init's size an em for a face: its ascent plus its descent over its + * em, which is what emScale is for the embedded font. Merged at ImGui's own scale it is each + * font's height that is matched, so one with tall lines comes out small and one with short lines + * large. An em is what the other two heads fall back at, and what leaves a CJK character, an em + * wide, inside the two cells the grid gives it. + * + * False for a face stb_truetype, which is what rasterises here, cannot draw from. Those are its + * own conditions, asked first. It wants outlines, as TrueType's or in a CFF table, and a variable + * font of the CFF2 kind has neither, which is one of the forms Noto CJK comes in. Asked here, on + * the finder's thread, the next font with the character is tried instead; left to ImGui, the + * refusal comes on the render thread as an error, and nothing else is tried. + */ +bool EmScaleOf(const std::vector& data, int face, float& scale) +{ + const size_t size = data.size(); + const auto u16 = [&data, size](size_t at) -> uint32_t + { + return at + 2 <= size + ? static_cast(data[at]) << 8 | static_cast(data[at + 1]) + : 0; + }; + const auto u32 = [&u16](size_t at) -> uint32_t { return u16(at) << 16 | u16(at + 2); }; + + /* A collection starts with where each of its faces does. */ + size_t start = 0; + if (u32(0) == Tag('t', 't', 'c', 'f')) + { + if (static_cast(face) >= u32(8)) + { + return false; + } + + start = u32(12 + static_cast(face) * 4); + } + else if (face != 0) + { + return false; + } + + size_t head = 0; + size_t hhea = 0; + bool cmap = false; + bool hmtx = false; + bool glyf = false; + bool loca = false; + bool cff = false; + const uint32_t tables = u16(start + 4); + for (uint32_t table = 0; table < tables; table++) + { + const size_t record = start + 12 + static_cast(table) * 16; + switch (u32(record)) + { + case Tag('h', 'e', 'a', 'd'): head = u32(record + 8); break; + case Tag('h', 'h', 'e', 'a'): hhea = u32(record + 8); break; + case Tag('c', 'm', 'a', 'p'): cmap = true; break; + case Tag('h', 'm', 't', 'x'): hmtx = true; break; + case Tag('g', 'l', 'y', 'f'): glyf = true; break; + case Tag('l', 'o', 'c', 'a'): loca = true; break; + case Tag('C', 'F', 'F', ' '): cff = true; break; + default: break; + } + } + + const int unitsPerEm = static_cast(u16(head + 18)); + const int ascent = static_cast(u16(hhea + 4)); + const int descent = static_cast(u16(hhea + 6)); + if (head == 0 || + hhea == 0 || + !cmap || + !hmtx || + !(cff || (glyf && loca)) || + unitsPerEm == 0 || + ascent <= descent) + { + return false; + } + + scale = static_cast(ascent - descent) / static_cast(unitsPerEm); + return true; +} + +bool ReadFont(const Fontconfig& fontconfig, const void* pattern, const std::pair& face, FoundFont& found) +{ + /* Outlines, and nothing else. A bitmap font has none to scale, and a colour font - which is + * what the emoji font usually is - keeps its pictures in tables stb_truetype does not read. + * Merged, it would draw every emoji as nothing, ahead of a font with plain ones. */ + int flag = 0; + if ((fontconfig.patternGetBool(pattern, "outline", 0, &flag) == 0 && flag == 0) || + (fontconfig.patternGetBool(pattern, "color", 0, &flag) == 0 && flag != 0)) + { + return false; + } + + std::ifstream file(face.first, std::ios::binary | std::ios::ate); + const std::streamoff length = file.tellg(); + /* ImGui takes a length as an int. */ + if (!file || + length <= 0 || + length > INT_MAX) + { + return false; + } + + std::vector data(static_cast(length)); + file.seekg(0); + if (!file.read(reinterpret_cast(data.data()), length) || + !EmScaleOf(data, face.second, found.scale)) + { + return false; + } + + found.data = std::move(data); + found.face = face.second; + return true; +} + +/* + * The first font in fontconfig's order to have a character and be one that can be drawn from, + * read whole. False when there is none, and when that font has been handed over already, since + * the character is then on its way with it. + */ +bool FindFont(SystemFonts& fonts, uint32_t codepoint, FoundFont& found) +{ + if (!fonts.opened) + { + OpenSystemFonts(fonts); + } + + const Fontconfig& fontconfig = fonts.fontconfig; + for (const SystemFonts::Candidate& candidate : fonts.candidates) + { + if (fontconfig.charSetHasChar(candidate.charset, codepoint) == 0) + { + continue; + } + + unsigned char* file = nullptr; + if (fontconfig.patternGetString(candidate.pattern, "file", 0, &file) != 0 || + file == nullptr) + { + continue; + } + + /* The face of a collection is the low half of the index. The high half names an instance + * of a variable font, which stb_truetype draws as its default whichever is asked for. */ + int index = 0; + fontconfig.patternGetInteger(candidate.pattern, "index", 0, &index); + const std::pair face(reinterpret_cast(file), index & 0xFFFF); + if (fonts.taken.count(face) != 0) + { + return false; + } + + if (fonts.unusable.count(face) != 0) + { + continue; + } + + if (ReadFont(fontconfig, candidate.pattern, face, found)) + { + fonts.taken.insert(face); + return true; + } + + fonts.unusable.insert(face); + } + + return false; +} + +void FindFonts(std::shared_ptr finder) +{ + SystemFonts fonts; + std::unique_lock lock(finder->mutex); + while (true) + { + finder->wake.wait(lock, [&finder] { return finder->stopping || !finder->wanted.empty(); }); + if (finder->stopping) + { + return; + } + + std::vector wanted; + wanted.swap(finder->wanted); + lock.unlock(); + + /* fontconfig and the disk, and nothing of ImGui's, which belongs to the other thread. */ + std::vector found; + for (const uint32_t codepoint : wanted) + { + FoundFont font; + if (FindFont(fonts, codepoint, font)) + { + found.push_back(std::move(font)); + } + } + + lock.lock(); + for (FoundFont& font : found) + { + finder->found.push_back(std::move(font)); + } + } +} + +void StopFontFinder() +{ + if (!state.finder) + { + return; + } + + { + const std::lock_guard lock(state.finder->mutex); + state.finder->stopping = true; + state.finder->wanted.clear(); + state.finder->found.clear(); + } + + state.finder->wake.notify_one(); + state.finder.reset(); +} + +/* + * Asks for a font for every character of a screen that the window's font cannot draw. Every + * string of the frame is in the one blob, so one pass over it covers the title, the queue and + * the tooltips with the rows. + */ +void FindFontsFor(const DeviewScreen* screen) +{ + if (state.font == nullptr || + screen->strings == nullptr || + state.fontData.size() >= fontLimit) + { + return; + } + + const char* text = reinterpret_cast(screen->strings); + const char* const end = text + screen->stringsLength; + std::vector wanted; + while (text < end) + { + /* ASCII, all of which the embedded font has, and which is nearly every byte of nearly + * every screen. */ + if (static_cast(*text) < 0x80) + { + text++; + continue; + } + + unsigned int codepoint = 0; + text += std::max(1, ImTextCharFromUtf8(&codepoint, text, end)); + if (codepoint > IM_UNICODE_CODEPOINT_MAX) + { + continue; + } + + if (state.asked.empty()) + { + state.asked.resize(static_cast(IM_UNICODE_CODEPOINT_MAX) + 1); + } + + if (state.asked[codepoint]) + { + continue; + } + + state.asked[codepoint] = true; + if (!state.font->IsGlyphInFont(static_cast(codepoint))) + { + wanted.push_back(codepoint); + } + } + + if (wanted.empty()) + { + return; + } + + if (!state.finder) + { + state.finder = std::make_shared(); + std::thread(FindFonts, state.finder).detach(); + } + + { + const std::lock_guard lock(state.finder->mutex); + state.finder->wanted.insert(state.finder->wanted.end(), wanted.begin(), wanted.end()); + } + + state.finder->wake.notify_one(); +} + +/* + * Merges what the finder has read into the window's font. At the top of a frame: ImGui takes a + * new source between frames, and drops what it had rasterised from the font as it does, so a + * character already drawn as missing is looked for again. + * + * Returns whether a font was merged, which is a frame to build: characters on the screen as the + * replacement glyph may have a glyph now. + */ +bool TakeFonts() { - for (auto entry = state.pictures.begin(); entry != state.pictures.end();) + if (!state.finder) { - if (entry->second.used) - { - entry->second.used = false; - ++entry; - continue; - } + return false; + } - if (entry->second.loaded) + std::vector found; + { + const std::lock_guard lock(state.finder->mutex); + found.swap(state.finder->found); + } + + bool merged = false; + ImGuiIO& io = ImGui::GetIO(); + for (FoundFont& font : found) + { + if (state.fontData.size() >= fontLimit) { - UnloadTexture(entry->second.texture); + return merged; } - if (entry->second.decoding) + state.fontData.push_back(std::move(font.data)); + std::vector& data = state.fontData.back(); + + /* Merged into the font added before it, which is the window's: see deview_init. */ + ImFontConfig config; + config.MergeMode = true; + config.FontNo = static_cast(font.face); + config.ExtraSizeScale = font.scale; + config.FontDataOwnedByAtlas = false; + + /* A font stb_truetype turns out not to read after all is left out. That is nothing for + * ImGui to assert or to write to its log, which is what it does with a font it is given + * and cannot use. */ + const bool asserts = io.ConfigErrorRecoveryEnableAssert; + const bool logs = io.ConfigErrorRecoveryEnableDebugLog; + io.ConfigErrorRecoveryEnableAssert = false; + io.ConfigErrorRecoveryEnableDebugLog = false; + const ImFont* added = io.Fonts->AddFontFromMemoryTTF( + data.data(), + static_cast(data.size()), + 0.0f, + &config); + io.ConfigErrorRecoveryEnableAssert = asserts; + io.ConfigErrorRecoveryEnableDebugLog = logs; + if (added == nullptr) { - CancelDecode(entry->first); + state.fontData.pop_back(); + continue; } - entry = state.pictures.erase(entry); + merged = true; } + + return merged; } -void UnloadPictures() +ImFont* AddEmbeddedFont(const uint8_t* fontTtf, int32_t fontLength, float fontSize) { - StopDecoder(); - for (auto& entry : state.pictures) - { - if (entry.second.loaded) - { - UnloadTexture(entry.second.texture); - } - } - - state.pictures.clear(); + /* ImGui frees font data with its own allocator, so hand it a copy rather than memory owned + * by the managed heap. */ + void* copy = IM_ALLOC(static_cast(fontLength)); + memcpy(copy, fontTtf, static_cast(fontLength)); + ImFontConfig config; + config.FontDataOwnedByAtlas = true; + config.ExtraSizeScale = emScale; + return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(copy, fontLength, fontSize <= 0.0f ? 15.0f : fontSize, &config); } /* ---- texture protocol (ImGuiBackendFlags_RendererHasTextures) ---- */ @@ -906,11 +1767,17 @@ void RenderDrawData(ImDrawData* drawData) /* ---- input ---- */ -void PumpInput() +/* + * `elapsed` is the time since the present before this one began, built or not, which is a frame's + * length while frames are coming. Not the time since the last frame ImGui was given: after a + * window has been left alone for an hour, that would have a tooltip's delay, and everything else + * ImGui times, over in the first frame. + */ +void PumpInput(float elapsed) { ImGuiIO& io = ImGui::GetIO(); io.DisplaySize = ImVec2(static_cast(GetScreenWidth()), static_cast(GetScreenHeight())); - io.DeltaTime = GetFrameTime() > 0.0f ? GetFrameTime() : 1.0f / 60.0f; + io.DeltaTime = elapsed; const Vector2 mouse = GetMousePosition(); io.AddMousePosEvent(mouse.x, mouse.y); @@ -943,16 +1810,28 @@ int ReadKey() return DEVIEW_KEY_NONE; } + /* The key itself held down, rather than read off the case of what was typed: see below. */ + const bool shift = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); + /* Letters by the character typed rather than by key position. raylib's key codes are * positions on a US layout, so on AZERTY the key labelled Q reported KEY_A and accepted - a * snapshot written into source by a key meant to quit - while the one labelled A quit. * Characters follow the layout, the way the macOS and Windows heads already do. */ for (int character = GetCharPressed(); character != 0; character = GetCharPressed()) { + /* Which letter, and nothing of its case. A capital says that Shift or Caps Lock was on and + * not which of them, so read as typed Caps Lock turned a plain A into accept all - every + * pending snapshot written into source, with nothing asked first, by the key that accepts + * one - and left D, V, Q, N, P, M, R and J doing nothing. */ + if (character >= 'A' && character <= 'Z') + { + character += 'a' - 'A'; + } + switch (character) { - case 'a': return DEVIEW_KEY_ACCEPT; - case 'A': return DEVIEW_KEY_ACCEPT_ALL; + /* Accept all is A with Shift held, which is what the other two heads go by. */ + case 'a': return shift ? DEVIEW_KEY_ACCEPT_ALL : DEVIEW_KEY_ACCEPT; case 'd': return DEVIEW_KEY_DISCARD; case 'v': return DEVIEW_KEY_NEXT_VARIANT; case 'q': return DEVIEW_KEY_QUIT; @@ -979,9 +1858,7 @@ int ReadKey() if (IsKeyPressed(KEY_PAGE_DOWN)) return DEVIEW_KEY_PAGE_DOWN; if (IsKeyPressed(KEY_HOME)) return DEVIEW_KEY_HOME; if (IsKeyPressed(KEY_END)) return DEVIEW_KEY_END; - if (IsKeyPressed(KEY_TAB)) return IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT) - ? DEVIEW_KEY_PREVIOUS_ITEM - : DEVIEW_KEY_NEXT_ITEM; + if (IsKeyPressed(KEY_TAB)) return shift ? DEVIEW_KEY_PREVIOUS_ITEM : DEVIEW_KEY_NEXT_ITEM; if (IsKeyPressed(KEY_ESCAPE)) return DEVIEW_KEY_QUIT; return DEVIEW_KEY_NONE; } @@ -1093,11 +1970,41 @@ void RowText(const DeviewScreen* screen, const DeviewRow& row, ImVec2 textPos) continue; } - list->AddText( - ImVec2(textPos.x + static_cast(segment.column) * cell, textPos.y), - colour, - begin, - end); + const ImVec2 position(textPos.x + static_cast(segment.column) * cell, textPos.y); + + /* + * Cut off where the next character starts, when it is drawn wider than the cells the grid + * gave it: a character from one of the machine's fonts, which is as wide as that font + * made it. The grid says where everything after it goes whatever its width, so drawn + * whole it would run on under the characters that follow. Over spaces it may, there being + * nothing there to run under, which is what leaves a warning sign or a star with a space + * after it whole. A run from the embedded font is exactly its cells and is never cut, and + * the last segment has nothing after it. + */ + if (index + 1 < row.segmentCount) + { + const DeviewSegment& next = screen->segments[row.segmentOffset + index + 1]; + int column = next.column; + const char* following; + const char* followingEnd; + if (Slice(screen, next.textOffset, next.textLength, &following, &followingEnd)) + { + for (; following < followingEnd && *following == ' '; following++) + { + column++; + } + } + + const float limit = textPos.x + static_cast(column) * cell; + if (ImGui::CalcTextSize(begin, end).x > limit - position.x) + { + const ImVec4 cells(position.x, -FLT_MAX, limit, FLT_MAX); + list->AddText(nullptr, 0.0f, position, colour, begin, end, 0.0f, &cells); + continue; + } + } + + list->AddText(position, colour, begin, end); } ImGui::Dummy(ImVec2(0.0f, ImGui::GetTextLineHeight())); @@ -1250,7 +2157,6 @@ void UpdateSelection( mouse.y > bodyMin.y + bodyAvail.y || mouse.x > bodyMin.x + bodyAvail.x || leftHit.cellLeft < 0.0f || - leftHit.textLeft < 0.0f || mouse.x < leftHit.cellLeft || /* The splitter's grab zone overlaps the left pane's edge, and a drag that started * there would otherwise also select whatever it began over. */ @@ -1260,12 +2166,17 @@ void UpdateSelection( } const bool right = rightHit.cellLeft >= 0.0f && mouse.x >= rightHit.cellLeft; - if (right && rightHit.textLeft < 0.0f) + const PaneHit& hit = right ? rightHit : leftHit; + + /* Nothing but filler on screen in the pane that was pressed, so nothing there to select. + * Asked of that pane and of no other: asked of the left one whichever was pressed, a left + * pane of filler ruled out the right pane's text with it, which is the whole of a pending + * delete and wherever a long removed block has been scrolled to. */ + if (hit.textLeft < 0.0f) { return; } - const PaneHit& hit = right ? rightHit : leftHit; const DeviewPane& pane = screen->panes[right ? 1 : 0]; state.dragging = true; state.dragSide = right ? 1 : 0; @@ -1331,27 +2242,32 @@ void RecordPaneImage(PaneImage& bounds, const DeviewPane& pane, int index) bounds.pitch = cursor.y - bounds.first; } +/* + * The checkerboard behind a picture, as one quad: the texture is two squares across and two down, + * so texture coordinates that run to the picture's size over two squares repeat it at the size of + * a square, counted from the picture's own top left corner. + * + * It was a quad a dark square, tessellated again every frame: about 4,500 for two pictures at the + * size the window opens at and over 56,000 for two in a maximised 4K window, sixty times a second. + * The pixels are the same ones. A square's edge falls on a whole pixel, half a pixel from the + * nearest pixel centre either side, which is where the texture is sampled. + */ void DrawChecker(ImDrawList* list, const ImVec2& min, const ImVec2& max) { - list->AddRectFilled(min, max, IM_COL32(64, 64, 64, 255)); - const ImU32 dark = IM_COL32(48, 48, 48, 255); - int row = 0; - for (float y = min.y; y < max.y; y += checkerSize, row++) + const Texture2D* checker = Checker(); + if (checker == nullptr) { - int column = 0; - for (float x = min.x; x < max.x; x += checkerSize, column++) - { - if ((row & 1) == (column & 1)) - { - continue; - } - - list->AddRectFilled( - ImVec2(x, y), - ImVec2(std::min(x + checkerSize, max.x), std::min(y + checkerSize, max.y)), - dark); - } + list->AddRectFilled(min, max, IM_COL32(64, 64, 64, 255)); + return; } + + const float repeat = checkerSize * 2.0f; + list->AddImage( + static_cast(checker->id), + min, + max, + ImVec2(0.0f, 0.0f), + ImVec2((max.x - min.x) / repeat, (max.y - min.y) / repeat)); } /* @@ -1434,8 +2350,8 @@ void DrawPaneImage(const DeviewScreen* screen, const DeviewPane& pane, const Pan bool loading = false; const std::string path = Copy(screen, pane.imagePathOffset, pane.imagePathLength); - const Texture2D* texture = Picture(path, loading); - if (texture == nullptr) + const CachedTexture* decoded = Picture(path, loading); + if (decoded == nullptr) { /* Nothing at all for a picture this build cannot decode: the rows have said what it is. */ if (loading) @@ -1475,7 +2391,7 @@ void DrawPaneImage(const DeviewScreen* screen, const DeviewPane& pane, const Pan SampleAsPixels( path, pane.imageZoom > 1.0f && - fitted.x * pane.imageZoom >= static_cast(texture->width)); + fitted.x * pane.imageZoom >= static_cast(decoded->texture.width)); if (pane.imageZoom > 1.0f) { const ImVec2 whole(fitted.x * pane.imageZoom, fitted.y * pane.imageZoom); @@ -1509,8 +2425,14 @@ void DrawPaneImage(const DeviewScreen* screen, const DeviewPane& pane, const Pan top + (available - size.y) * 0.5f)); const ImVec2 max(min.x + size.x, min.y + size.y); - DrawChecker(list, min, max); - list->AddImage(static_cast(texture->id), min, max, uvMin, uvMax); + /* Only behind a picture some of it would show through. Behind any other every square is under + * an opaque pixel, and filling them costs a software rasteriser the picture's area again. */ + if (decoded->translucent) + { + DrawChecker(list, min, max); + } + + list->AddImage(static_cast(decoded->texture.id), min, max, uvMin, uvMax); /* An outline, so a picture whose edges are the colour of the pane still has visible extent. */ list->AddRect( ImVec2(min.x - 1.0f, min.y - 1.0f), @@ -1598,6 +2520,96 @@ bool UpdatePan(const DeviewScreen* screen) return true; } +/* + * One line of text in no more than a width, ending in an ellipsis where it was cut short, so it + * reads as cut rather than as all there was. It takes the place in the layout the text would. + */ +void TextWithin(const char* begin, const char* end, float width) +{ + const float room = std::max(width, 0.0f); + const ImVec2 size = ImGui::CalcTextSize(begin, end); + const ImVec2 position = ImGui::GetCursorScreenPos(); + const ImVec2 limit(position.x + room, position.y + size.y); + ImGui::Dummy(ImVec2(std::min(size.x, room), size.y)); + ImGui::RenderTextEllipsis(ImGui::GetWindowDrawList(), position, limit, limit.x, begin, end, &size); +} + +/* + * How the footer is laid out: its buttons, on as many rows as the window's width makes of them, + * and the status line, right aligned beside the last of those rows or on a line of its own. + * + * Worked out before the body is laid out, because the body is given what the footer leaves. A + * footer that fits on one line is every footer there used to be, and is still laid out as it was: + * each button after the one before, and the status after the last. That was the only layout, so + * one that did not fit ran off the window. A paged document pending in a queue has eleven buttons, + * 1199 pixels of them in a window with 1084: the last was past the window's edge, where it could + * not be clicked, and the status past that, where it could not be read - and the status line is + * where the page on screen, a page that could not be drawn, a selection and an accept that failed + * are said. + */ +struct Footer +{ + std::vector labels; + + /* Whether each button goes to the start of a new row rather than after the one before it. */ + std::vector wraps; + + std::string status; + float statusWidth = 0.0f; + + /* On a line of its own under the buttons, for want of room beside the last row of them. */ + bool statusBelow = false; + + /* What all of it takes from the bottom of the window. */ + float height = 0.0f; +}; + +Footer LayOutFooter(const DeviewScreen* screen, float width) +{ + const ImGuiStyle& style = ImGui::GetStyle(); + Footer footer; + int rows = 1; + + /* How far along its row the last button reaches. */ + float reach = 0.0f; + for (int index = 0; index < screen->buttonCount; index++) + { + const DeviewButton& button = screen->buttons[index]; + footer.labels.push_back(Copy(screen, button.labelOffset, button.labelLength)); + + /* What ImGui::Button makes of a label: its text, less whatever follows a ##, inside the + * frame's padding. */ + const float size = + ImGui::CalcTextSize(footer.labels.back().c_str(), nullptr, true).x + style.FramePadding.x * 2.0f; + const bool wraps = index > 0 && reach + style.ItemSpacing.x + size > width; + footer.wraps.push_back(wraps); + if (wraps) + { + rows++; + reach = size; + } + else + { + reach += (index > 0 ? style.ItemSpacing.x : 0.0f) + size; + } + } + + footer.status = Copy(screen, screen->statusOffset, screen->statusLength); + if (!footer.status.empty()) + { + footer.statusWidth = ImGui::CalcTextSize(footer.status.c_str()).x; + /* Beside the buttons only with room to spare, which is the test it was always put to. */ + footer.statusBelow = + screen->buttonCount > 0 && + width - reach - style.ItemSpacing.x <= footer.statusWidth; + } + + footer.height = + static_cast(rows) * ImGui::GetFrameHeightWithSpacing() + style.ItemSpacing.y + + (footer.statusBelow ? ImGui::GetTextLineHeightWithSpacing() : 0.0f); + return footer; +} + void BuildFrame(const DeviewScreen* screen) { /* Read by the input pass, which has no screen of its own: Escape means dismiss while one of @@ -1614,18 +2626,47 @@ void BuildFrame(const DeviewScreen* screen) ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar); - Text(screen, screen->titleOffset, screen->titleLength); + /* + * The title, which stops a character short of the subtitle where it would otherwise run on + * under it: the subtitle is drawn in from the right edge wherever the title ended. It is the + * title that gives way, because what it says is also in the pane headers and the queue, and + * which entry of the queue this is is said only by the subtitle. + */ const std::string subtitle = Copy(screen, screen->subtitleOffset, screen->subtitleLength); + const float subtitleWidth = subtitle.empty() ? 0.0f : ImGui::CalcTextSize(subtitle.c_str()).x; + const float titleRoom = subtitle.empty() + ? ImGui::GetContentRegionAvail().x + : ImGui::GetContentRegionAvail().x - subtitleWidth - ImGui::CalcTextSize("M").x; + const char* titleBegin; + const char* titleEnd; + if (Slice(screen, screen->titleOffset, screen->titleLength, &titleBegin, &titleEnd) && + ImGui::CalcTextSize(titleBegin, titleEnd).x > titleRoom) + { + TextWithin(titleBegin, titleEnd, titleRoom); + } + else + { + Text(screen, screen->titleOffset, screen->titleLength); + } + if (!subtitle.empty()) { - const float width = ImGui::CalcTextSize(subtitle.c_str()).x; - ImGui::SameLine(ImGui::GetContentRegionAvail().x - width); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - subtitleWidth); ImGui::TextDisabled("%s", subtitle.c_str()); } ImGui::Separator(); - const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + /* + * Its height comes off the body, and the managed side is not asked for fewer rows to make up + * for it. That side keeps eight lines for everything that is not a row, where this head's + * title, headers and one line of footer take under five, so there are 62 pixels and more under + * the last row it slices. A second row of buttons takes 23 of them and a line for the status + * 17, and a third row of buttons on top of both is a pixel over at most. It is only past + * that - four rows, which a paged document's buttons come to in a window under 450 pixels + * wide - that the last rows of the body are cut off, behind a footer that can at least be read. + */ + const Footer footer = LayOutFooter(screen, ImGui::GetContentRegionAvail().x); /* * The strip the pane scrollbar gets, taken off the body before anything is laid out in it. @@ -1633,7 +2674,7 @@ void BuildFrame(const DeviewScreen* screen) * and went would shift the pane split every time the selection changed. */ const float scrollbarWidth = ImGui::GetStyle().ScrollbarSize; - ImGui::BeginChild("##body", ImVec2(-scrollbarWidth, -footer), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + ImGui::BeginChild("##body", ImVec2(-scrollbarWidth, -footer.height), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); /* Read back rather than recomputed, so the scrollbar lands against the body whatever the * negative sizes above worked out as. */ @@ -1764,6 +2805,15 @@ void BuildFrame(const DeviewScreen* screen) ImGui::SetTooltip("%s", tip.c_str()); } } + else if (item.tooltipLength > 0 && + !state.capturing && + ImGui::IsItemHovered()) + { + /* Under the pointer and still waiting out the delay, which ImGui counts + * in the frames it is given and the time it is told each took. So frames + * have to keep coming with the pointer at rest, until the tip is up. */ + state.tooltipDue = true; + } if (index == screen->menuRow && screen->menuCount > 0) @@ -1987,9 +3037,9 @@ void BuildFrame(const DeviewScreen* screen) for (int index = 0; index < screen->buttonCount; index++) { const DeviewButton& button = screen->buttons[index]; - const std::string label = Copy(screen, button.labelOffset, button.labelLength); const bool enabled = (button.flags & DEVIEW_BUTTON_ENABLED) != 0; - if (index > 0) + if (index > 0 && + !footer.wraps[static_cast(index)]) { ImGui::SameLine(); } @@ -2000,7 +3050,7 @@ void BuildFrame(const DeviewScreen* screen) } ImGui::PushID(index); - if (ImGui::Button(label.c_str())) + if (ImGui::Button(footer.labels[static_cast(index)].c_str())) { state.input.clickedButton = index; } @@ -2012,23 +3062,278 @@ void BuildFrame(const DeviewScreen* screen) } } - const std::string status = Copy(screen, screen->statusOffset, screen->statusLength); - if (!status.empty()) + if (!footer.status.empty()) { - const float width = ImGui::CalcTextSize(status.c_str()).x; - ImGui::SameLine(); - const float available = ImGui::GetContentRegionAvail().x; - if (available > width) + if (!footer.statusBelow) { - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + available - width); + ImGui::SameLine(); } - ImGui::TextDisabled("%s", status.c_str()); + const float available = ImGui::GetContentRegionAvail().x; + if (available > footer.statusWidth) + { + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + available - footer.statusWidth); + ImGui::TextDisabled("%s", footer.status.c_str()); + } + else + { + /* Wider than the window even with a line to itself. From the left edge then, so that + * what is lost is its end, and said to be lost. */ + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + TextWithin(footer.status.data(), footer.status.data() + footer.status.size(), available); + ImGui::PopStyleColor(); + } } ImGui::End(); } +/* ---- which frames are drawn ---- */ + +void Append(std::vector& bytes, const void* data, size_t size) +{ + if (data == nullptr || + size == 0) + { + return; + } + + const unsigned char* begin = static_cast(data); + bytes.insert(bytes.end(), begin, begin + size); +} + +/* An array of the screen with its count ahead of it, so two screens whose arrays are the same + * bytes in all but split differently are not the same screen. */ +template +void AppendArray(std::vector& bytes, const Element* elements, int32_t count) +{ + Append(bytes, &count, sizeof count); + if (count > 0) + { + Append(bytes, elements, static_cast(count) * sizeof(Element)); + } +} + +/* + * Whether the screen handed over differs from the one handed over last, which is kept either way. + * + * By its bytes, every one of them: the managed side builds each screen into the same buffers, so + * where they point says nothing, and none of the structs has padding to differ for no reason. The + * same bytes are the same strings, rows, panes, queue and menu, and so the same frame for as long + * as nothing else that a frame is built from has moved. + */ +bool Changed(const DeviewScreen* screen) +{ + std::vector& bytes = state.arriving; + bytes.clear(); + AppendArray(bytes, screen->strings, screen->stringsLength); + AppendArray(bytes, screen->panes, screen->paneCount); + AppendArray(bytes, screen->rows, screen->rowCount); + AppendArray(bytes, screen->segments, screen->segmentCount); + AppendArray(bytes, screen->buttons, screen->buttonCount); + AppendArray(bytes, screen->queue, screen->queueCount); + AppendArray(bytes, screen->menu, screen->menuCount); + const int32_t rest[] = { + screen->pendingCount, + screen->titleOffset, + screen->titleLength, + screen->subtitleOffset, + screen->subtitleLength, + screen->statusOffset, + screen->statusLength, + screen->menuRow, + screen->menuPane}; + Append(bytes, rest, sizeof rest); + + if (bytes == state.presented) + { + return false; + } + + bytes.swap(state.presented); + return true; +} + +/* + * Whether anything has come from the pointer, the keys or the window since the last present: what + * raylib gathered when it last read the window system's events, which is what the frame about to + * be built would be given. + * + * A key is not something a frame is built from, since ImGui is given none: what a key does comes + * back from the managed side as a different screen. It counts all the same, because a window is + * only left alone when nothing at all is happening to it. By the press rather than by what is + * down, since raylib reports Caps Lock and Num Lock as held for as long as they are on. + */ +bool Arrived() +{ + bool arrived = false; + + const Vector2 pointer = GetMousePosition(); + if (pointer.x != state.pointer.x || + pointer.y != state.pointer.y) + { + state.pointer = pointer; + arrived = true; + } + + for (const int button : {MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_MIDDLE}) + { + if (IsMouseButtonDown(button) || + IsMouseButtonReleased(button)) + { + arrived = true; + } + } + + const Vector2 wheel = GetMouseWheelMoveV(); + if (wheel.x != 0.0f || + wheel.y != 0.0f) + { + arrived = true; + } + + /* Taken off raylib's queue, which nothing else here reads: ReadKey asks about keys by name + * and takes characters from a queue of their own. */ + while (GetKeyPressed() != 0) + { + arrived = true; + } + + const int width = GetScreenWidth(); + const int height = GetScreenHeight(); + const bool hidden = IsWindowState(FLAG_WINDOW_HIDDEN); + const bool minimised = IsWindowMinimized(); + const bool focused = IsWindowFocused(); + if (width != state.width || + height != state.height || + hidden != state.hidden || + minimised != state.minimised) + { + /* A window of another size has another framebuffer, and one that was not on the screen + * was not being kept by anything. */ + state.stale = true; + arrived = true; + } + + if (focused != state.focused) + { + arrived = true; + } + + state.width = width; + state.height = height; + state.hidden = hidden; + state.minimised = minimised; + state.focused = focused; + return arrived; +} + +uint64_t Mix(uint64_t hash, const void* data, size_t size) +{ + const unsigned char* bytes = static_cast(data); + for (; size >= sizeof(uint64_t); bytes += sizeof(uint64_t), size -= sizeof(uint64_t)) + { + uint64_t word; + memcpy(&word, bytes, sizeof word); + hash = (hash ^ word) * 0x9E3779B97F4A7C15ull; + hash ^= hash >> 29; + } + + for (; size > 0; bytes++, size--) + { + hash = (hash ^ *bytes) * 0x100000001B3ull; + } + + return hash; +} + +/* + * Everything RenderDrawData would draw a frame from, reduced to one number: the size drawn at, and + * for every draw list its vertices, its indices and what each command clips to and draws with. + * + * Two frames with the same number are the same pixels, short of a texture's content having changed + * behind its name, which is asked separately. That is what lets a frame be built and then not + * drawn: it is held against the frame on the screen, and a frame that would put the same pixels + * there again is a full window for a software rasteriser to fill, and for the window system to + * copy, to no effect. Sixty four bits, so two frames that differ share a number about as often as + * never, and a pass over the vertices costs a small part of what drawing them would. + */ +uint64_t Fingerprint(const ImDrawData* drawData) +{ + uint64_t hash = 0xCBF29CE484222325ull; + hash = Mix(hash, &drawData->DisplaySize, sizeof drawData->DisplaySize); + for (int list = 0; list < drawData->CmdListsCount; list++) + { + const ImDrawList* commands = drawData->CmdLists[list]; + hash = Mix( + hash, + commands->VtxBuffer.Data, + static_cast(commands->VtxBuffer.Size) * sizeof(ImDrawVert)); + hash = Mix( + hash, + commands->IdxBuffer.Data, + static_cast(commands->IdxBuffer.Size) * sizeof(ImDrawIdx)); + for (const ImDrawCmd& command : commands->CmdBuffer) + { + const uint64_t drawn[] = { + static_cast(command.GetTexID()), + command.VtxOffset, + command.IdxOffset, + command.ElemCount}; + hash = Mix(hash, &command.ClipRect, sizeof command.ClipRect); + hash = Mix(hash, drawn, sizeof drawn); + } + } + + return hash; +} + +/* + * Whether ImGui is waiting for a texture to be made, updated or destroyed, which RenderDrawData + * does as it draws: the font atlas, when a character is drawn for the first time. Such a frame is + * drawn whatever its fingerprint, so the atlas on the GPU never falls behind the one ImGui holds. + */ +bool TexturesWaiting(const ImDrawData* drawData) +{ + if (drawData->Textures == nullptr) + { + return false; + } + + for (const ImTextureData* texture : *drawData->Textures) + { + if (texture->Status != ImTextureStatus_OK) + { + return true; + } + } + + return false; +} + +/* + * The end of every frame, drawn or not: what EndDrawing does once a frame is on the screen, which + * it can no longer be left to do, since it only does it for a frame it has put there. + * + * It waits out what is left of the frame, counted from when the last one's wait ended, so the + * managed loop turns sixty times a second whatever a turn drew: without the wait a window with + * nothing to draw would spin a core. And then it reads the window system's events, last, so that + * what deview_poll_input reports and what the next frame is built from are as fresh as they can + * be. That is the order raylib kept them in. + */ +void Rest() +{ + const double left = frameSeconds - (GetTime() - state.ended); + if (left > 0.0) + { + /* Never longer than a frame, whatever the clock has done. */ + WaitTime(std::min(left, frameSeconds)); + } + + PollInputEvents(); + state.ended = GetTime(); +} + void ApplyStyle() { ImGuiStyle& style = ImGui::GetStyle(); @@ -2111,7 +3416,22 @@ int32_t deview_init( } SetExitKey(KEY_NULL); - SetTargetFPS(60); + + /* No SetTargetFPS: raylib only holds to it inside EndDrawing, which is no longer called. The + * frame is ended, and waited out, by Rest. And nothing about a window that came before this + * one says anything about this one, whose clock has started again from nothing. */ + glfwSetWindowRefreshCallback(glfwGetCurrentContext(), WindowRefreshed); + state.presented.clear(); + state.watched.clear(); + state.shown = 0; + state.stale = true; + state.settled = 0; + state.tooltipDue = false; + state.began = 0.0; + state.ended = 0.0; + state.pointer = Vector2{}; + state.width = 0; + state.height = 0; state.context = ImGui::CreateContext(); ImGui::SetCurrentContext(state.context); @@ -2127,14 +3447,15 @@ int32_t deview_init( if (fontTtf != nullptr && fontLength > 0) { - /* ImGui frees font data with its own allocator, so hand it a copy rather than memory - * owned by the managed heap. */ - void* copy = IM_ALLOC(static_cast(fontLength)); - memcpy(copy, fontTtf, static_cast(fontLength)); - ImFontConfig config; - config.FontDataOwnedByAtlas = true; - config.ExtraSizeScale = emScale; - io.Fonts->AddFontFromMemoryTTF(copy, fontLength, fontSize <= 0.0f ? 15.0f : fontSize, &config); + /* + * Twice. The first is the atlas's default, and so what a capture's context draws with: + * the embedded font and nothing else, on every machine. The second is the window's, which + * the machine's fonts are merged into, and it is added last because a merge goes into + * the font added before it. See State::font. + */ + AddEmbeddedFont(fontTtf, fontLength, fontSize); + state.font = AddEmbeddedFont(fontTtf, fontLength, fontSize); + io.FontDefault = state.font; } ResetInput(); @@ -2160,10 +3481,59 @@ int32_t deview_present(const DeviewScreen* screen) } ImGui::SetCurrentContext(state.context); - PumpInput(); - /* Before the frame asks for its pictures, so one that finished decoding since the last frame is - * drawn in this one. */ - TakeDecoded(); + + const double now = GetTime(); + const float elapsed = state.began > 0.0 && now > state.began + ? static_cast(now - state.began) + : static_cast(frameSeconds); + state.began = now; + + /* + * What has arrived since the last present. Each of these is asked every time, whatever the + * ones before it said, since each also takes what it finds. + * + * Before the frame asks for its pictures, so one that finished decoding since the last frame + * is drawn in this one. + */ + bool arrived = TakeDecoded(); + /* And its fonts, for that reason and because a font can only be added between frames. */ + arrived = TakeFonts() || arrived; + arrived = Changed(screen) || arrived; + arrived = Arrived() || arrived; + arrived = arrived || state.tooltipDue; + + /* + * A window nothing is happening to is left alone: no frame is built, and nothing is drawn. + * It used to be built, drawn and put on the screen sixty times a second, each one the frame + * already there, and under a software rasteriser or over a remote session each of those is the + * whole window filled and copied again. + * + * Left alone only once it is certain the next frame would be the one on the screen. The screen + * handed over is the last one byte for byte, nothing has come from the pointer, the keys, the + * window system, the decoder or the font finder, no tooltip is waiting to appear, the files + * behind the pictures are as they were, and a second of frames built since any of that last + * changed have all come out as the frame on the screen. That last is what a spinner fails, + * and anything else that moves by itself. + */ + if (!arrived && + !state.stale && + state.settled >= settledFrames) + { + if (!PicturesRewritten()) + { + Rest(); + MeasureGrid(); + TrackPlacement(); + return 1; + } + + arrived = true; + } + + PumpInput(elapsed); + FindFontsFor(screen); + state.watched.clear(); + state.tooltipDue = false; ImGui::NewFrame(); BuildFrame(screen); ImGui::Render(); @@ -2179,10 +3549,37 @@ int32_t deview_present(const DeviewScreen* screen) SetMouseCursor(cursor); } - BeginDrawing(); - ClearBackground(Color{24, 24, 24, 255}); - RenderDrawData(ImGui::GetDrawData()); - EndDrawing(); + /* + * Drawn only if it is not the frame on the screen: the pointer crossing a pane, a key that did + * nothing and the frames that follow any change mostly come out as the frame before them. + * Building one costs a fraction of drawing it, and is what all of ImGui's own state is kept + * moving by, so those frames are built and not drawn rather than not built. + */ + ImDrawData* drawData = ImGui::GetDrawData(); + const uint64_t frame = Fingerprint(drawData); + if (state.stale || + frame != state.shown || + TexturesWaiting(drawData)) + { + BeginDrawing(); + ClearBackground(Color{24, 24, 24, 255}); + RenderDrawData(drawData); + rlDrawRenderBatchActive(); + SwapScreenBuffer(); + state.shown = frame; + state.stale = false; + state.settled = 0; + } + else if (arrived) + { + state.settled = 0; + } + else if (state.settled < settledFrames) + { + state.settled++; + } + + Rest(); ForgetUnusedPictures(); MeasureGrid(); @@ -2313,6 +3710,9 @@ void deview_set_hidden(int32_t hidden) } ClearWindowState(FLAG_WINDOW_HIDDEN); + /* The window system asks for a window it has just shown to be drawn. Not waited for: nothing + * was keeping what a hidden window showed. */ + state.stale = true; } void deview_set_clipboard(const char* text) @@ -2345,6 +3745,8 @@ void deview_focus(void) } SetWindowFocused(); + /* Shown, restored or raised: drawn again, as in deview_set_hidden. */ + state.stale = true; } void deview_set_placement(const DeviewPlacement* placement) @@ -2381,6 +3783,7 @@ void deview_shutdown(void) /* Before CloseWindow, which takes the GL context these live in with it. */ UnloadPictures(); + StopFontFinder(); if (state.context != nullptr) { @@ -2389,6 +3792,15 @@ void deview_shutdown(void) state.context = nullptr; } + /* After the context, whose atlas was still reading glyphs out of these. */ + state.font = nullptr; + state.fontData.clear(); + state.asked.clear(); + + state.presented.clear(); + state.arriving.clear(); + state.watched.clear(); + CloseWindow(); state.initialised = false; state.windowOpen = false; diff --git a/native/swift/Sources/Deview/Exports.swift b/native/swift/Sources/Deview/Exports.swift index 3cf629e30..d56df0f53 100644 --- a/native/swift/Sources/Deview/Exports.swift +++ b/native/swift/Sources/Deview/Exports.swift @@ -85,8 +85,7 @@ public func deviewPollInput(_ input: UnsafeMutablePointer?) { } } - input.pointee = runtime.input - runtime.resetInput() + input.pointee = runtime.takeInput() } @_cdecl("deview_set_hidden") diff --git a/native/swift/Sources/Deview/MainMenu.swift b/native/swift/Sources/Deview/MainMenu.swift index 79e86a7d3..a8c1425de 100644 --- a/native/swift/Sources/Deview/MainMenu.swift +++ b/native/swift/Sources/Deview/MainMenu.swift @@ -12,7 +12,7 @@ final class ControlTarget: NSObject { /// loop answer it. @objc func quit(_ sender: Any?) { - Runtime.shared.input.key = DEVIEW_KEY_QUIT.value + Runtime.shared.post(.key(DEVIEW_KEY_QUIT.value)) } /// A menu bar command. The item's tag is the `DeviewKey`, so the menu carries no mapping of @@ -23,7 +23,7 @@ final class ControlTarget: NSObject { return } - Runtime.shared.input.key = Int32(item.tag) + Runtime.shared.post(.key(Int32(item.tag))) } /// A context menu item. The tag is its index in the frame's menu, which is the whole payload: @@ -34,7 +34,7 @@ final class ControlTarget: NSObject { return } - Runtime.shared.input.clickedMenuItem = Int32(item.tag) + Runtime.shared.post(.menuItem(Int32(item.tag))) } @objc diff --git a/native/swift/Sources/Deview/Palette.swift b/native/swift/Sources/Deview/Palette.swift index bfa6f9546..aa9277d75 100644 --- a/native/swift/Sources/Deview/Palette.swift +++ b/native/swift/Sources/Deview/Palette.swift @@ -56,14 +56,23 @@ enum Palette { static let checkerLight = grey(64) static let checkerDark = grey(48) + /// What a changed row's text is drawn in, and the label of an entry that failed. One of each + /// for the life of the process, where `foreground` made one for every call, which was every + /// segment of every changed row. It has to be one of each: `Renderer` finds a line it has kept + /// by its text and by which colour object it was drawn in, so a colour made afresh would find + /// nothing. + private static let added = rgb(126, 214, 139) + private static let removed = rgb(233, 129, 129) + private static let modified = rgb(231, 197, 113) + static func foreground(_ kind: Int32) -> CGColor { switch kind { case DEVIEW_ROW_ADDED.value: - return rgb(126, 214, 139) + return added case DEVIEW_ROW_REMOVED.value: - return rgb(233, 129, 129) + return removed case DEVIEW_ROW_MODIFIED.value: - return rgb(231, 197, 113) + return modified // Dimmed like the gutter, since what it says is about the file rather than from it. case DEVIEW_ROW_FOLDED.value: return dim diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index 265c1c610..1e990210d 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -51,6 +51,31 @@ final class Renderer { /// view because this is what lays the rule out, and the drag has to land where it was drawn. private var queueWidth: CGFloat = 0 + /// What the draw in progress can reach, or nil for everything: the bounds of the context's + /// clip, outside which nothing is painted whatever is asked for. What lies wholly outside it is + /// not laid out either: see `shows`. + /// + /// A spinner turns by invalidating its own rectangle, some twenty times a second for as long + /// as a page takes. Where AppKit narrows the clip to that rectangle, a turn lays out the + /// spinner and nothing else, where it used to lay out every line of text in the window to + /// paint none of it. + /// + /// It does not always narrow it, which is why the clip is read at each draw and never worked + /// out from what was invalidated. Since macOS 11 a view whose backing store AppKit manages can + /// be handed its whole bounds whatever was invalidated, clip included, and later versions ask + /// for the whole of a view when they see fit. Then all of it is drawn, as it has to be, and + /// what keeps that cheap is `lines`. + /// + /// Set as a draw begins and read only during it. + private var dirty: CGRect? + + /// Both panes name the one picture in the draw in progress, as a byte equal pair of documents + /// does: their pages are kept under the hash of the document. There is one scaled copy a + /// picture and the two panes can be a point apart in width, so each would have the copy made + /// for its own size in turn, without end. An enlarged pair is not given the chance: it is + /// drawn from the picture, as every enlarged picture was before it had a copy. + private var shared = false + /// Decoded pictures, keyed by the path the screen model handed over and invalidated by the /// file's write time and length — the same freshness test the queue poller uses, so a re-run /// that rewrites a received image refreshes the pane rather than leaving the previous one up. @@ -70,9 +95,14 @@ final class Renderer { var modified: Date var length: UInt64 - /// The picture scaled down to the device pixels it last filled. Drawing a large picture - /// scaled costs a resample of every source pixel, and a window showing one did that on - /// every redraw; this is a copy. + /// The picture scaled down to the device pixels it was last drawn at: the ones it fills + /// when it is fitted, and the ones the whole of it takes, shown or not, when it is enlarged + /// and still below its own size. Drawing a large picture scaled costs a resample of every + /// source pixel, and a window showing one did that on every redraw; this is a copy. + /// + /// One copy, for whichever size was asked for last. It is only made narrower than the + /// picture or shorter, so at its largest it is about the size the picture is itself. It + /// goes when a copy for another size lands, and with the picture. var scaled: CGImage? /// Being decoded on `work`. The pane shows a spinner until it lands. @@ -108,6 +138,50 @@ final class Renderer { case scaled(path: String, modified: Date, length: UInt64, size: Pixels, image: CGImage?) } + /// The lines the last two draws drew, by what each was made from. + /// + /// Every piece of text is an attributed string and a line, and each was made again on every + /// draw: about a hundred and fifty for a window of plain text, and one more for every + /// character the managed side sends as a segment of its own, which is most of a pane of + /// Chinese. Yet most draws draw what the one before drew. A turn of a spinner or a dragged + /// picture changes none of the text, and a scroll keeps all but a row of it. So a line is + /// kept for as long as it goes on being drawn. + /// + /// `lines` is what this draw has drawn so far, and `earlier` what the last draw to draw any + /// text drew. A line found in `earlier` is carried into `lines`, and what is still only in + /// `earlier` when `lines` takes its place was not drawn again and goes with it. That is the + /// bound: two draws' worth of lines, however long the session. + /// + /// A line is made from its text, its colour and the font. The first two are the key, and the + /// font is this renderer's for as long as it lives, so a line kept here is never one that + /// would be made differently now. A capture draws from here too: it is the same line. + private var lines: [LineKey: CTLine] = [:] + private var earlier: [LineKey: CTLine] = [:] + + /// What a kept line was made from. Two texts are the same when their bytes are, rather than + /// as Swift compares strings, which holds a composed character equal to the same one + /// decomposed: Core Text is handed different characters for the two, and need not draw them + /// alike. + /// + /// The colour by which object it is, which `Palette` makes the same as which colour it is by + /// handing out one object each. Asking Core Foundation whether two are equal instead would + /// rest on Core Graphics hashing equal colours alike, which nothing here can check, and a key + /// whose hash and equality disagree is one a dictionary can trap on. The colour is held as + /// well as compared, so nothing else can be at its address while a line is kept under it. + private struct LineKey: Hashable { + let text: String + let colour: CGColor + + static func == (left: LineKey, right: LineKey) -> Bool { + left.colour === right.colour && left.text.utf8.elementsEqual(right.text.utf8) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(text) + hasher.combine(ObjectIdentifier(colour)) + } + } + /// One character cell. Measured from the font that was actually loaded, which is what the ABI /// reports back so the managed side can slice a pane to rows that fit. let cell: CGSize @@ -182,7 +256,7 @@ final class Renderer { } init(fontData: Data?, size: CGFloat) { - font = Renderer.load(fontData, size) + font = Renderer.withoutLigatures(Renderer.load(fontData, size), size) ascent = CTFontGetAscent(font) descent = CTFontGetDescent(font) @@ -232,6 +306,26 @@ final class Renderer { return CTFontCreateWithGraphicsFont(cgFont, size, nil, nil) } + /// `font` with its ligatures off, so every character of a snapshot is drawn as itself. + /// + /// JetBrains Mono draws `<>`, `!=`, `<=`, `=>`, `->`, `==` and a good many more as one glyph + /// each, and closes up `...`, all through its `calt` feature, which Core Text applies unless + /// told not to. The other two heads draw a glyph a character, so the same title read `<>` on + /// Windows and Linux and as one diamond here, in a tool whose whole job is to show which + /// characters a snapshot holds. + /// + /// `liga` goes off with it. The embedded font has no such feature, but the face taken when + /// nothing is embedded might, and the answer should not turn on which font is in use. + private static func withoutLigatures(_ font: CTFont, _ size: CGFloat) -> CTFont { + let features: [[CFString: Any]] = [ + [kCTFontOpenTypeFeatureTag: "calt", kCTFontOpenTypeFeatureValue: 0], + [kCTFontOpenTypeFeatureTag: "liga", kCTFontOpenTypeFeatureValue: 0] + ] + let attributes: [CFString: Any] = [kCTFontFeatureSettingsAttribute: features] + let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary) + return CTFontCreateCopyWithAttributes(font, size, nil, descriptor) + } + /// The window size in character cells, which is what version 2 of the ABI reports. Net of the /// scroller, because a column the scroller is sitting on is not a column the diff can use. func grid(for size: CGSize) -> (columns: Int32, rows: Int32) { @@ -240,9 +334,23 @@ final class Renderer { /// `capturing` decodes and scales pictures here and now, and stands a spinner still: a capture /// draws one frame, which has to have its pictures in it and come out the same every time. + /// + /// The layout that comes back is the whole window's whatever is being repainted. What a + /// repaint of part of it leaves out is the drawing, and a capture leaves out nothing. @discardableResult func draw(_ frame: Frame, in context: CGContext, size: CGSize, capturing: Bool = false) -> Layout { takeFinished() + dirty = capturing ? nil : context.boundingBoxOfClipPath + // The lines the last draw drew are the ones this one may use again, and what the draw + // before it drew and it did not goes here. A draw that reached no text is passed over: + // where the clip is a spinner, a turn would otherwise leave nothing kept for whatever is + // drawn after it. + if !lines.isEmpty { + earlier = lines + lines = [:] + } + + lines.reserveCapacity(earlier.count) var layout = Layout() context.setFillColor(Palette.background) context.fill(CGRect(origin: .zero, size: size)) @@ -279,8 +387,10 @@ final class Renderer { rule(top: headerTop + line + Renderer.gap, width: size.width, in: context, size) let bodyTop = Renderer.padding + (line + Renderer.gap) * 2 + Renderer.gap * 2 - let footerHeight = line + Renderer.gap * 2 - let capacity = max(1, Int((size.height - bodyTop - footerHeight - Renderer.padding) / line)) + // Before the body, which ends where the footer begins. The footer is as tall as its + // buttons take, and that is more than one row of them once they are wider than the window. + let placed = place(frame, width: size.width, line: line) + let capacity = max(1, Int((size.height - bodyTop - placed.height - Renderer.padding) / line)) let rows = min(capacity, max(frame.queue.count, max(frame.left.rows.count, frame.right.rows.count))) for index in 0 ..< rows { @@ -301,6 +411,7 @@ final class Renderer { // path was asked for again and had changed or gone, so every image reviewed in a session // was held until the session ended. let shown: Set = [frame.left.imagePath, frame.right.imagePath] + shared = !frame.left.imagePath.isEmpty && frame.left.imagePath == frame.right.imagePath pictures = pictures.filter { shown.contains($0.key) } gate.lock() wanted = shown @@ -339,39 +450,120 @@ final class Renderer { textLeft: panesLeft + half + gutter, width: panesWidth - half) ] - layout.buttons = footer(frame, size: size, height: footerHeight, line: line, in: context) + layout.buttons = footer(frame, placed, size: size, line: line, in: context) return layout } - private func footer(_ frame: Frame, size: CGSize, height: CGFloat, line: CGFloat, in context: CGContext) -> [CGRect] { - let top = size.height - height - Renderer.padding - rule(top: top - Renderer.gap, width: size.width, in: context, size) + /// Where the footer's buttons go and how tall that makes it. Worked out before anything is + /// drawn, because the body ends where the footer begins. + private struct Footer { + /// One for each of the frame's buttons, in their order. + var slots: [Slot] = [] - var rects: [CGRect] = [] + /// How many rows the buttons take: one, unless they are wider than the window. + var rows = 1 + + /// The status has a line of its own above the buttons, for want of room beside them. + var statusAbove = false + + var height: CGFloat = 0 + + struct Slot { + var row = 0 + var left: CGFloat = 0 + var width: CGFloat = 0 + } + } + + /// Lays the footer out: the buttons left to right, onto another row where the next would pass + /// the window's edge, and the status right of the last of them where there is room for it. + /// + /// One row used to be all there was. A paged document has eleven buttons, wider together than + /// the window opens, so the last of them were off it and could not be clicked. And the status + /// was drawn from the right edge whatever was already there, which for an image pair in a + /// queue was the last button. A status with no room beside the buttons has a line of its own + /// instead, because it is where the page, the zoom, a selection and a failed accept are said. + /// + /// Where a button goes turns on the buttons and the window's width and nothing else. Not on + /// the status, which changes while the pointer is on its way to a button: its line is above + /// the buttons for that reason, so they stay where they are as it comes and goes. + private func place(_ frame: Frame, width: CGFloat, line: CGFloat) -> Footer { + var placed = Footer() + let edge = width - Renderer.padding var left = Renderer.padding + var end = Renderer.padding for button in frame.buttons { - let width = CGFloat(button.label.count + 4) * cell.width - let bounds = rect(top: top, left: left, width: width, height: height, size) + let span = CGFloat(button.label.count + 4) * cell.width + // Never the first of its row, which has nowhere better to go however wide it is + if left > Renderer.padding, left + span > edge { + placed.rows += 1 + left = Renderer.padding + } + + placed.slots.append(Footer.Slot(row: placed.rows - 1, left: left, width: span)) + end = left + span + left = end + Renderer.gap + } + + // Room is room right up to the last button, with no gap asked for: a conflicted entry's + // line count has always sat two points off its variant button, and reads. + let statusWidth = CGFloat(frame.status.count) * cell.width + placed.statusAbove = !frame.status.isEmpty && edge - statusWidth < end + let row = line + Renderer.gap * 2 + placed.height = row * CGFloat(placed.rows) + Renderer.gap * CGFloat(placed.rows - 1) + if placed.statusAbove { + placed.height += line + Renderer.gap + } + + return placed + } + + private func footer(_ frame: Frame, _ placed: Footer, size: CGSize, line: CGFloat, in context: CGContext) -> [CGRect] { + let top = size.height - placed.height - Renderer.padding + rule(top: top - Renderer.gap, width: size.width, in: context, size) + + let height = line + Renderer.gap * 2 + let first = placed.statusAbove ? top + line + Renderer.gap : top + var rects: [CGRect] = [] + for (button, slot) in zip(frame.buttons, placed.slots) { + let bounds = rect( + top: first + CGFloat(slot.row) * (height + Renderer.gap), + left: slot.left, + width: slot.width, + height: height, + size) rects.append(bounds) context.setFillColor(button.enabled ? Palette.buttonFace : Palette.buttonDisabled) context.fill(bounds) let label = bounds.insetBy(dx: cell.width * 2, dy: (height - line) / 2) text(button.label, in: label, button.enabled ? Palette.text : Palette.dim, context) - left += width + Renderer.gap } - if !frame.status.isEmpty { - let width = CGFloat(frame.status.count) * cell.width - let bounds = rect(top: top + (height - line) / 2, left: size.width - Renderer.padding - width, width: width, height: line, size) - text(frame.status, in: bounds, Palette.dim, context) + guard !frame.status.isEmpty else { + return rects } + let edge = size.width - Renderer.padding + let width = CGFloat(frame.status.count) * cell.width + if placed.statusAbove { + // From the left edge once it is wider than the window, so that what is cut off is its + // end, which is the end the text renderer loses. + let left = max(Renderer.padding, edge - width) + let above = rect(top: top, left: left, width: edge - left, height: line, size) + text(frame.status, in: above, Palette.dim, context) + return rects + } + + // Beside the last row of buttons, against the right edge + let last = first + CGFloat(placed.rows - 1) * (height + Renderer.gap) + let beside = rect(top: last + (height - line) / 2, left: edge - width, width: width, height: line, size) + text(frame.status, in: beside, Palette.dim, context) return rects } private func queueItem(_ frame: Frame, _ index: Int, _ bounds: CGRect, _ context: CGContext) { - guard index < frame.queue.count else { + guard index < frame.queue.count, shows(bounds) else { return } @@ -407,6 +599,13 @@ final class Renderer { return } + // Left alone when none of it is being repainted. Asked of the row with its gutter, which a + // pane narrower than one draws past the row's own edge. + let width = Renderer.gutterCells * cell.width + guard shows(CGRect(x: bounds.minX, y: bounds.minY, width: max(bounds.width, width), height: bounds.height)) else { + return + } + let row = pane.rows[index] if let background = Palette.rowBackground(row.kind) { context.setFillColor(background) @@ -420,7 +619,6 @@ final class Renderer { // A folded row has no number, and printing the -1 standing in for one put it in the gutter. let number = row.lineNumber < 0 ? "" : String(row.lineNumber) let gutter = "\(Palette.marker(row.kind)) \(String(repeating: " ", count: max(0, 4 - number.count)))\(number)" - let width = Renderer.gutterCells * cell.width // Behind the text rather than over it, and the text keeps its own colour: what kind of // change a line is has to survive being selected. @@ -516,7 +714,7 @@ final class Renderer { width: max(1, (CGFloat(pane.imageWidth) * scale).rounded(.down)), height: max(1, (CGFloat(pane.imageHeight) * scale).rounded(.down))) if pane.imageZoom > 1 { - enlarged(pane, picture, fitted: drawn, top: imageTop, left: left, available: available, in: context, size, &layout) + enlarged(pane, picture, fitted: drawn, top: imageTop, left: left, available: available, capturing: capturing, in: context, size, &layout) return } @@ -533,6 +731,12 @@ final class Renderer { return } + // Left alone when none of it is being repainted, as when the clip is the other pane's + // spinner. Asked of the picture with its outline, which is the point outside it. + guard shows(bounds.insetBy(dx: -1, dy: -1)) else { + return + } + checker(bounds, in: context) context.saveGState() @@ -555,8 +759,11 @@ final class Renderer { /// part around the centre the managed side asked for, moved in as far as it takes to keep the /// space full: it does not know how many points a pane has, so it can ask for one at the edge. /// - /// Drawn straight from the decoded picture, clipped, rather than from a copy scaled to that - /// size, which at the last step would be hundreds of megabytes to show one corner of it. + /// Past its own size it is drawn straight from the decoded picture, clipped, rather than from a + /// copy scaled to that size, which at the last step would be hundreds of megabytes to show one + /// corner of it. Below its own size a copy is about the size of the picture at the most, and + /// the window draws from one: see `reduced`. A capture draws from the picture at any size, as + /// it always has. private func enlarged( _ pane: Frame.Pane, _ picture: CGImage, @@ -564,6 +771,7 @@ final class Renderer { top: CGFloat, left: CGFloat, available: CGSize, + capturing: Bool, in context: CGContext, _ size: CGSize, _ layout: inout Layout @@ -593,29 +801,107 @@ final class Renderer { width: whole.width, height: whole.height) + // The last one appended is this pane's, by `image`, before it knew the picture was there. + // Said before anything is drawn, because where the picture is does not turn on how much of + // the window this draw is for. + if !layout.pictures.isEmpty { + layout.pictures[layout.pictures.count - 1].enlarged = true + layout.pictures[layout.pictures.count - 1].whole = whole + layout.pictures[layout.pictures.count - 1].centre = centre + layout.pictures[layout.pictures.count - 1].across = across + layout.pictures[layout.pictures.count - 1].down = down + } + + // Below its own size the window draws it from a copy scaled to the pixels the whole of it + // takes: see `reduced`. Asked for here rather than past the test below, as a fitted + // picture's copy is, so that a draw which leaves the picture out has still started the + // copy the next one will want. Not for a capture, which is one frame with no later one + // for a copy to land in, and not when both panes name the one picture. + let device = context.convertToDeviceSpace(all).size + let reducing = !capturing && !shared && abs(device.width) < CGFloat(picture.width) + let made = reducing ? self.fitted(pane.imagePath, picture, device: device, capturing: false) : nil + + // Left alone when none of it is being repainted, as a fitted one is + guard shows(bounds.insetBy(dx: -1, dy: -1)) else { + return + } + checker(bounds, in: context) context.saveGState() context.clip(to: bounds) - // Its pixels as they are once it is past its own size, which is what zooming that far in - // is for: smoothed, a one pixel difference between the two sides is a blur on both. - let device = context.convertToDeviceSpace(all).size - context.interpolationQuality = abs(device.width) >= CGFloat(picture.width) ? .none : .high - context.draw(picture, in: all) + if reducing { + reduced(made, picture, at: all, device: device, in: context) + } else { + // Its pixels as they are once it is past its own size, which is what zooming that far + // in is for: smoothed, a one pixel difference between the two sides is a blur on both. + context.interpolationQuality = abs(device.width) >= CGFloat(picture.width) ? .none : .high + context.draw(picture, in: all) + } + context.restoreGState() context.setStrokeColor(Palette.rule) context.setLineWidth(1) context.stroke(bounds.insetBy(dx: -0.5, dy: -0.5)) + } - // The last one appended is this pane's, by `image`, before it knew the picture was there - if !layout.pictures.isEmpty { - layout.pictures[layout.pictures.count - 1].enlarged = true - layout.pictures[layout.pictures.count - 1].whole = whole - layout.pictures[layout.pictures.count - 1].centre = centre - layout.pictures[layout.pictures.count - 1].across = across - layout.pictures[layout.pictures.count - 1].down = down + /// The whole of an enlarged picture that is still below its own size, at `all`, for the + /// window. The caller has clipped to the part of it that shows, and `made` is what `fitted` + /// had for the pixels the whole of it takes. + /// + /// Drawn from a copy scaled to those pixels, made on `work` and kept as the fitted one is, in + /// its place. Drawn from the picture itself, this was a resample at `.high` of every source + /// pixel under the clip on every redraw, and a drag is a redraw a frame: both panes of a pair + /// of screenshots, to move them. Where the picture has been dragged to is no part of the copy, + /// so a drag is the same copy drawn somewhere else. + /// + /// Only where the whole of it is narrower than the picture, so the copy is about the picture's + /// own size at the most. Until it lands the picture itself is drawn, quickly rather than well, + /// and drawn again when it does. + private func reduced(_ made: CGImage?, _ picture: CGImage, at all: CGRect, device: CGSize, in context: CGContext) { + guard let scaled = made, + scaled !== picture, + scaled.width == Int(abs(device.width).rounded()), + scaled.height == Int(abs(device.height).rounded()) + else { + // The picture itself. As well as it ever was drawn when it is not one to make a copy + // of, being within a pixel of its own size or having failed to scale. Quickly + // otherwise: the copy for this size is on its way, and whatever `fitted` had to hand + // back in the meantime was made for another. + context.interpolationQuality = made === picture ? .high : .low + context.draw(picture, in: all) + return } + + // A pixel of the copy to a pixel of the screen. `all` starts wherever the drag left it, + // which is seldom on a pixel, and is the copy's size only to the nearest one. Drawn into + // `all` as it stands the copy would be sampled again on every frame: smoothed, each pane + // softening its own by a different fraction of a pixel, or not, and losing or doubling a + // row or a column of it where the two sizes part. So it goes on the pixel nearest to where + // `all` starts, at its own size, which leaves every part of it within about a pixel of + // where `all` has it. + let target = context.convertToDeviceSpace(all) + let placed = context.convertToUserSpace( + CGRect( + x: Renderer.pixel(target.minX), + y: Renderer.pixel(target.minY), + width: CGFloat(scaled.width), + height: CGFloat(scaled.height))) + context.interpolationQuality = .none + context.draw(scaled, in: placed) + } + + /// The pixel a device coordinate is put on: the nearest, with two things seen to first. + /// + /// Where a picture has been dragged to goes to the managed side and back as a Float, which + /// leaves a coordinate a few ten thousandths of a pixel either side of where it should be, + /// and where it should be is often exactly half way between two pixels. So it is taken to the + /// nearest eighth of a pixel first. And a half goes up whatever its sign, where `rounded()` + /// takes it away from zero and so the other way once a picture's edge has left the window. + /// Without the two, a picture dragged a pixel at a time moved by none or by two. + private static func pixel(_ value: CGFloat) -> CGFloat { + ((value * 8).rounded() / 8 + 0.5).rounded(.down) } /// Something turning, centred in `space`, while the picture that will be centred there is on @@ -685,6 +971,11 @@ final class Renderer { /// For the window the copy is made on `work`, and meanwhile this is the copy made for the size /// the pane last had, for the caller to stretch into place, or nil when there has never been /// one, which the pane shows a spinner for. + /// + /// `device` is the size the picture fills when it is fitted, and the size the whole of it + /// takes when `enlarged` asks, for one that is still below its own size. Either way it is the + /// one copy, so going from one to the other makes it again. Nil is no spinner there: `reduced` + /// draws the picture itself until the copy lands. private func fitted(_ path: String, _ picture: CGImage, device: CGSize, capturing: Bool) -> CGImage? { let width = Int(abs(device.width).rounded()) let height = Int(abs(device.height).rounded()) @@ -910,24 +1201,58 @@ final class Renderer { } /// Clipped to its own rect, so a long line stops at its column instead of running into the - /// next one. + /// next one. Which is also what lets it be left out when that rect is not being repainted: + /// none of it could have landed anywhere else. private func text(_ string: String, in bounds: CGRect, _ colour: CGColor, _ context: CGContext) { - guard !string.isEmpty, bounds.width > 0 else { + guard !string.isEmpty, bounds.width > 0, shows(bounds) else { return } + context.saveGState() + context.clip(to: bounds) + context.textPosition = CGPoint(x: bounds.minX, y: bounds.minY + descent) + CTLineDraw(typeset(string, colour), context) + context.restoreGState() + } + + /// `string` as a line in `colour`: the one this draw or the last one drew it with, or else a + /// new one, as every line used to be. + private func typeset(_ string: String, _ colour: CGColor) -> CTLine { + let flat = RowText.flatten(string) + let key = LineKey(text: flat, colour: colour) + if let kept = lines[key] { + return kept + } + + let line = earlier[key] ?? lay(flat, colour) + lines[key] = line + return line + } + + private func lay(_ flat: String, _ colour: CGColor) -> CTLine { let attributed = NSAttributedString( - string: RowText.flatten(string), + string: flat, attributes: [ .font: font, .foregroundColor: colour ]) + return CTLineCreateWithAttributedString(attributed) + } - context.saveGState() - context.clip(to: bounds) - context.textPosition = CGPoint(x: bounds.minX, y: bounds.minY + descent) - CTLineDraw(CTLineCreateWithAttributedString(attributed), context) - context.restoreGState() + /// Whether anything drawn in `bounds` can reach the screen in the draw in progress: always in + /// a capture, and in a window when what is being repainted touches `bounds`, by however + /// little. + /// + /// Asked only with a rect that holds everything the caller would paint. The background, the + /// rules, a button's face and a spinner are not asked about at all: each is a call or two, and + /// the clip sees to them. And asked only about drawing: where things are goes into the layout + /// whatever is being repainted, since the view resolves clicks against it afterwards. + private func shows(_ bounds: CGRect) -> Bool { + guard let dirty else { + return true + } + + return dirty.intersects(bounds) } private func rule(top: CGFloat, width: CGFloat, in context: CGContext, _ size: CGSize) { diff --git a/native/swift/Sources/Deview/Runtime.swift b/native/swift/Sources/Deview/Runtime.swift index eca021a13..39e0ee45d 100644 --- a/native/swift/Sources/Deview/Runtime.swift +++ b/native/swift/Sources/Deview/Runtime.swift @@ -40,6 +40,29 @@ final class Runtime { var input = DeviewInput() var initialised = false + /// Keys, clicks and menu events, in the order they happened, one handed over per poll. + /// + /// A slot per kind used to hold them, in `input`, and each handler overwrote its slot. `pump` + /// dispatches everything AppKit has queued before it returns, so with a slow frame - the loop + /// waiting behind an accept on InlineApplier's mutex - two presses of Down scrolled once, Tab + /// then a accepted the entry the reader meant to skip, and d then a click on another row + /// discarded the clicked one, which the reader had never looked at: the managed side applies + /// a frame's click before its key. The WinForms head queues them for the same reason. + /// + /// Only these. The wheel adds up, a drag and the scroller say where they are now and a close + /// is a flag, so each of those is whole however many events made it, and stays in `input`. + private var discrete: [Discrete] = [] + + enum Discrete { + case key(Int32) + case button(Int32) + case queueItem(Int32) + case rightClickedQueueItem(Int32) + case rightClickedPane(Int32) + case menuItem(Int32) + case menuClosed + } + /// Keeps App Nap off for as long as the runtime is open: see `open`. private var activity: NSObjectProtocol? @@ -194,11 +217,21 @@ final class Runtime { /// which has no window and so no scroller — is not left with a gap where one would be. private func makeScroller(in view: ViewerView, _ renderer: Renderer) { let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) - let scroller = NSScroller(frame: NSRect(x: 0, y: 0, width: width, height: view.bounds.height)) + let scroller = PaneScroller( + frame: NSRect(x: view.bounds.maxX - width, y: 0, width: width, height: view.bounds.height)) scroller.scrollerStyle = .legacy scroller.knobStyle = .light + // Kept against the right edge and as tall as the window leaves it by AppKit, as well as by + // `position`. A window being resized is a loop of AppKit's own, inside the pump, so + // `position` does not run until the mouse comes up, and until then the scroller stayed + // where the last frame had put it: out in the right hand pane of a window being widened. + scroller.autoresizingMask = [.minXMargin, .height] scroller.target = target scroller.action = #selector(ControlTarget.scrolled(_:)) + // Said rather than left to whatever a scroller starts as. A scroll view sees to this for + // its own scrollers and there is none here, and a scroller that is not enabled draws its + // slot with no knob in it to take hold of. + scroller.isEnabled = true view.addSubview(scroller) self.scroller = scroller @@ -266,8 +299,9 @@ final class Runtime { width: scrollerWidth, height: max(1, body.height)) - // Assigned rather than guarded against a drag in progress, because there cannot be one: - // a legacy scroller tracks in a loop of its own, inside the pump this runs before. + // Assigned during a drag of the knob as at any other time. `PaneScroller` reports where a + // drag has got to without moving the knob, so this is what moves it: to the row the panes + // are showing, which is where the WinForms bar's thumb sits too. let visible = max(1, frame.left.rows.count) let total = max(Int(frame.left.totalRows), visible) let maximum = total - visible @@ -275,7 +309,20 @@ final class Runtime { scroller.doubleValue = maximum <= 0 ? 0 : Double(frame.left.scrollTop) / Double(maximum) } - /// Translates wherever the scroller was grabbed into a first visible row. + /// The knob dragged to `value` of the way along its travel, as a first visible row. From + /// `PaneScroller`, which follows that drag itself. + func knobDragged(to value: Double) { + guard let frame = view?.model else { + return + } + + let visible = max(1, frame.left.rows.count) + let maximum = max(0, Int(frame.left.totalRows) - visible) + input.scrollTo = Int32((value * Double(maximum)).rounded()) + } + + /// Translates wherever the scroller was grabbed into a first visible row. What reaches here is + /// what AppKit tracked itself, which is a press in the slot. func scrolled(_ scroller: NSScroller) { guard let frame = view?.model else { return @@ -306,7 +353,12 @@ final class Runtime { return } - guard !menuShown, let view else { + // Not while anything is waiting to be handed over. It happened before this menu could be + // shown, and popping holds the managed loop for as long as the menu is up: a key pressed + // straight after the right-click would be applied when the menu closed, however much later + // that was. Handed over first, it is applied now, and a menu that outlives it is popped on + // the frame after. + guard !menuShown, discrete.isEmpty, let view else { return } @@ -340,15 +392,19 @@ final class Runtime { if !menu.popUp(positioning: nil, at: at, in: view) { // Escape, a click elsewhere, or focus lost. The click that did it was swallowed by the // tracking loop, so this is the only way the managed side can hear about it. - input.menuClosed = 1 + post(.menuClosed) } } /// Drains what is queued and then blocks until the deadline, which is both the pump and the /// frame throttle. Without the second part this would spin a core, since the managed loop /// calls straight back in. + /// + /// Not blocked while input is still waiting to be handed over, which is the next frame's, + /// now. It goes one event a poll, and a frame's wait between two of them would hand a held + /// key's repeats over more slowly than a fast repeat rate makes them. private func pump() { - let deadline = Date(timeIntervalSinceNow: 1.0 / 60.0) + let deadline = discrete.isEmpty ? Date(timeIntervalSinceNow: 1.0 / 60.0) : Date.distantPast while let event = NSApp.nextEvent(matching: .any, until: deadline, inMode: .default, dequeue: true) { NSApp.sendEvent(event) } @@ -394,9 +450,45 @@ final class Runtime { delegate = nil scroller = nil menuShown = false + discrete = [] initialised = false } + /// Records a key, a click or a menu event, behind whatever is already waiting. + func post(_ event: Discrete) { + discrete.append(event) + } + + /// What one poll hands over: everything in `input`, and the discrete event that is next in + /// line when there is one. Only the one, so the managed side applies them in the order they + /// happened rather than in the order it reads a frame's fields. + func takeInput() -> DeviewInput { + var taken = input + resetInput() + guard !discrete.isEmpty else { + return taken + } + + switch discrete.removeFirst() { + case let .key(key): + taken.key = key + case let .button(index): + taken.clickedButton = index + case let .queueItem(index): + taken.clickedQueueItem = index + case let .rightClickedQueueItem(index): + taken.rightClickedQueueItem = index + case let .rightClickedPane(side): + taken.rightClickedPane = side + case let .menuItem(index): + taken.clickedMenuItem = index + case .menuClosed: + taken.menuClosed = 1 + } + + return taken + } + /// Each event is delivered exactly once, so the poll that read them clears them. The grid is /// left alone: it is a state, not an event. func resetInput() { diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift index bff32f82d..24559dd1b 100644 --- a/native/swift/Sources/Deview/ViewerView.swift +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -127,7 +127,7 @@ final class ViewerView: NSView, NSViewToolTipOwner { // reason the first click after a menu no longer also selects a row. let point = convert(event.locationInWindow, from: nil) if let index = layout.buttons.firstIndex(where: { $0.contains(point) }) { - Runtime.shared.input.clickedButton = Int32(index) + Runtime.shared.post(.button(Int32(index))) return } @@ -140,7 +140,7 @@ final class ViewerView: NSView, NSViewToolTipOwner { if let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), index < model.queue.count { - Runtime.shared.input.clickedQueueItem = Int32(index) + Runtime.shared.post(.queueItem(Int32(index))) return } @@ -280,7 +280,7 @@ final class ViewerView: NSView, NSViewToolTipOwner { let point = convert(event.locationInWindow, from: nil) if let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), index < model.queue.count { - Runtime.shared.input.rightClickedQueueItem = Int32(index) + Runtime.shared.post(.rightClickedQueueItem(Int32(index))) return } @@ -293,7 +293,7 @@ final class ViewerView: NSView, NSViewToolTipOwner { point.y <= layout.body.maxY, point.x >= layout.panes[0].cellLeft, point.x <= layout.body.maxX { - Runtime.shared.input.rightClickedPane = point.x >= layout.panes[1].cellLeft ? 1 : 0 + Runtime.shared.post(.rightClickedPane(point.x >= layout.panes[1].cellLeft ? 1 : 0)) Runtime.shared.paneMenuPoint = point return } @@ -358,7 +358,7 @@ final class ViewerView: NSView, NSViewToolTipOwner { return } - Runtime.shared.input.key = key + Runtime.shared.post(.key(key)) } /// Matches ReadKey in deview.cpp and the WinForms head's Map, which is the keymap the docs @@ -445,6 +445,68 @@ final class ViewerView: NSView, NSViewToolTipOwner { } } +/// The pane scrollbar, with a drag of its knob followed here rather than in AppKit's own loop. +/// +/// `NSScroller` tracks a press on its knob in a loop of its own, inside `mouseDown`, and returns +/// when the button comes up. That is inside `Runtime.pump`, so `deview_present` did not return +/// for the length of the drag: the knob moved under the pointer, every move was reported, and the +/// managed loop that scrolls the panes in answer ran once, at the release. The WinForms head has +/// the same loop under its scroll bar and is handed frames from inside it, which this ABI has no +/// way to ask for. +/// +/// So the drag is three ordinary events instead, as a selection is in `ViewerView`. The window +/// sends the moves and the release to the view that took the press, each comes through the pump +/// on its own, and there is a frame between one and the next. +/// +/// Only a press on the knob. One in the slot is still AppKit's: what it means is the reader's +/// setting, a page or the place that was pressed, and it is over in a click. +final class PaneScroller: NSScroller { + /// A knob being dragged: where in the window the pointer took hold of it, where the knob was + /// then, as the fraction of its travel `doubleValue` is, and how long that travel is. + private var dragging = false + private var heldAt: CGFloat = 0 + private var heldValue = 0.0 + private var travel: CGFloat = 0 + + override func mouseDown(with event: NSEvent) { + let knob = rect(for: .knob) + let room = rect(for: .knobSlot).height - knob.height + guard room > 0, knob.contains(convert(event.locationInWindow, from: nil)) else { + super.mouseDown(with: event) + return + } + + dragging = true + heldAt = event.locationInWindow.y + heldValue = doubleValue + travel = room + } + + override func mouseDragged(with event: NSEvent) { + guard dragging else { + super.mouseDragged(with: event) + return + } + + // Measured from the press rather than from the last move, as a picture's drag is, and in + // the window's coordinates, which count up the screen while the document runs down it. + // The knob is not moved here. It goes where the frame that answers this puts it, which + // is the row the panes are showing. + let moved = Double((heldAt - event.locationInWindow.y) / travel) + Runtime.shared.knobDragged(to: min(max(heldValue + moved, 0), 1)) + } + + override func mouseUp(with event: NSEvent) { + guard dragging else { + super.mouseUp(with: event) + return + } + + // Nothing to report: the last move already said where it is. + dragging = false + } +} + /// Closing is the managed side's decision: with a tray to reopen from it hides, without one it /// exits. So the request is recorded and the close refused, and the answer comes back as either /// `deview_set_hidden` or `deview_shutdown`. diff --git a/native/swift/readme.md b/native/swift/readme.md index da840dab9..03eafcbcd 100644 --- a/native/swift/readme.md +++ b/native/swift/readme.md @@ -1,7 +1,7 @@ # macOS renderer The macOS half of `libdiffengine_viewer`, drawn with AppKit and Core Text. It implements the same -ABI as `native/` does for Linux — `native/include/deview.h`, eight exports over one flat frame +ABI as `native/` does for Linux — `native/include/deview.h`, eleven exports over one flat frame description — so the managed side is identical on both and `DiffEngineViewer.Core` has no idea which one it loaded. @@ -28,6 +28,14 @@ rather than handing control to `NSApplication.run`. That is what keeps the scrol the button lookup and the close-means-hide rule in `ViewerProgram` for every platform. It also means the deadline is the frame throttle: without it the managed loop would spin a core. +**AppKit keeps loops of its own**, and each runs inside that pump, so the managed loop waits it +out. A menu while it is open, which is deliberate: that loop is what buys the keyboard, Escape and +VoiceOver. A window while it is being resized, which is not: the panes keep the rows they were +sliced for until the mouse comes up, drawn into the new size, and it takes a way to ask the managed +side for a frame from inside the loop to do better, which the ABI does not have. The scroller's +knob used to be a third. `PaneScroller` follows a drag of it as three ordinary events instead, so +the panes scroll as it moves. + **No app bundle.** `setActivationPolicy(.regular)` plus `finishLaunching()` is enough to get a window that takes focus and appears in the dock, which is the same thing GLFW does for the Linux build. diff --git a/src/DiffEngine.Benchmarks/DiffEngine.Benchmarks.csproj b/src/DiffEngine.Benchmarks/DiffEngine.Benchmarks.csproj index cf63950cb..86aa938c1 100644 --- a/src/DiffEngine.Benchmarks/DiffEngine.Benchmarks.csproj +++ b/src/DiffEngine.Benchmarks/DiffEngine.Benchmarks.csproj @@ -5,6 +5,14 @@ net10.0 enable false + + true + $(MSBuildThisFileDirectory)..\key.snk diff --git a/src/DiffEngine.Benchmarks/InlineAcceptBenchmarks.cs b/src/DiffEngine.Benchmarks/InlineAcceptBenchmarks.cs new file mode 100644 index 000000000..52b61dd8d --- /dev/null +++ b/src/DiffEngine.Benchmarks/InlineAcceptBenchmarks.cs @@ -0,0 +1,206 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace DiffEngine.Benchmarks; + +// Accepting every inline snapshot in one test file, which is what "Accept all" does over a file +// that holds many. A patch at a time, each call reads, lexes and rewrites the whole file, and +// handed over together the file is read and written once. A member is twenty lines, so 25 call +// sites is a 500 line file of 30 KB, and 500 is 10,000 lines and 600 KB. +// +// The file is a real one, in a directory of its own under the temp folder, because the applier's +// cost is as much the file system's as the patcher's: a mutex, a read, a temporary written beside +// the source and a swap, per patch. Where that folder is scanned as it is written to, which is +// what a virus scanner does by default, the swap is most of it. +// +// Each literal accepted adds five lines, so every call site below it has moved by the time its +// own patch is applied, as it has in a real accept-all. +[MemoryDiagnoser] +public class InlineAcceptBenchmarks +{ + [Params(25, 500)] + public int CallSites; + + string directory = ""; + string path = ""; + byte[] pristine = []; + string source = ""; + InlinePatch[] patches = []; + InlinePatch[] appends = []; + + [GlobalSetup] + public void Setup() + { + directory = Path.Combine(Path.GetTempPath(), $"DiffEngineBenchmarks_{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + path = Path.Combine(directory, "GeneratedTests.cs"); + + var (text, snapshotLines, verifyLines) = Generate(CallSites); + source = text; + pristine = new UTF8Encoding(false).GetBytes(text); + File.WriteAllBytes(path, pristine); + patches = new InlinePatch[CallSites]; + appends = new InlinePatch[CallSites]; + for (var index = 0; index < CallSites; index++) + { + patches[index] = new(path, snapshotLines[index], $"\"old {index:D4}\"", $"first line of {index:D4}\nsecond line\nthird line") + { + TestName = $"GeneratedTests.Member{index:D4}", + MemberName = $"Member{index:D4}" + }; + // What a producer asks before it declares a verification inline: whether the verify + // call has somewhere to hang a Snapshot call + appends[index] = new(path, verifyLines[index], null, "content", InlinePatchMode.Append) + { + TestName = $"GeneratedTests.Member{index:D4}", + MemberName = $"Member{index:D4}" + }; + } + } + + [GlobalCleanup] + public void Cleanup() => + Directory.Delete(directory, true); + + // What the tray and the viewer do for a bulk accept: a call to Apply for each entry + [Benchmark] + public int AcceptEach() + { + File.WriteAllBytes(path, pristine); + var applied = 0; + foreach (var patch in patches) + { + if (InlineApplier.Apply(patch).Status == InlineApplyStatus.Applied) + { + applied++; + } + } + + return Every(applied); + } + + // The same patches handed over in one call, which reads and writes the file once + [Benchmark] + public int AcceptTogether() + { + File.WriteAllBytes(path, pristine); + var applied = 0; + foreach (var result in InlineApplier.ApplyAll(patches)) + { + if (result.Status == InlineApplyStatus.Applied) + { + applied++; + } + } + + return Every(applied); + } + + // What a test run does once for each call site it has not seen before. Nothing is written, + // so this is the read and the lexing by themselves + [Benchmark] + public int AnchorEach() + { + var anchored = 0; + foreach (var patch in appends) + { + if (InlineApplier.CanAnchor(patch).Status == InlineApplyStatus.Applied) + { + anchored++; + } + } + + return Every(anchored); + } + + // The patcher alone over the same patches, each applied to what the last one left: what the + // accepts above cost with the file system, the encoding and the locks taken out + [Benchmark] + public int PatchInMemory() + { + var current = source; + var applied = 0; + foreach (var patch in patches) + { + var status = InlinePatcher.TryApply( + SourceLanguage.CSharp, + current, + patch.LineHint, + patch.Mode, + patch.OriginalExpression, + patch.OriginalValue, + patch.MemberName, + patch.EntryPoints, + false, + patch.NewContent, + out var patched, + out _); + if (status == PatchStatus.Applied) + { + current = patched; + applied++; + } + } + + return Every(applied); + } + + // The lexing every one of those starts with + [Benchmark] + public bool Lex() + { + using var scan = SourceLanguage.CSharp.Scan(source); + return scan.IsCode(0); + } + + // A benchmark that measured five hundred refusals would look like a very fast applier + int Every(int count) + { + if (count != CallSites) + { + throw new($"{count} of {CallSites} generated patches applied."); + } + + return count; + } + + // Twenty lines a member: an attribute, a declaration, fourteen statements with a string and a + // comment each, and a verify call with its snapshot on a line of its own. + static (string source, int[] snapshotLines, int[] verifyLines) Generate(int members) + { + var builder = new StringBuilder(); + var snapshotLines = new int[members]; + var verifyLines = new int[members]; + var line = 0; + + void Add(string text) + { + builder.Append(text); + builder.Append("\r\n"); + line++; + } + + Add("public class GeneratedTests"); + Add("{"); + for (var member = 0; member < members; member++) + { + Add(" [Test]"); + Add($" public Task Member{member:D4}()"); + Add(" {"); + for (var statement = 0; statement < 14; statement++) + { + Add($" var value{statement:D2} = Describe(\"input {statement:D2} of member {member:D4}\", {statement}); // read below"); + } + + Add(" return Verify(value00)"); + verifyLines[member] = line; + Add($" .Snapshot(\"old {member:D4}\");"); + snapshotLines[member] = line; + Add(" }"); + Add(""); + } + + Add("}"); + return (builder.ToString(), snapshotLines, verifyLines); + } +} diff --git a/src/DiffEngine.Benchmarks/InlineStagingBenchmarks.cs b/src/DiffEngine.Benchmarks/InlineStagingBenchmarks.cs new file mode 100644 index 000000000..82f33f544 --- /dev/null +++ b/src/DiffEngine.Benchmarks/InlineStagingBenchmarks.cs @@ -0,0 +1,91 @@ +using BenchmarkDotNet.Attributes; + +namespace DiffEngine.Benchmarks; + +// InlineStaging.Clear as a passing inline verification calls it: once each, to take away whatever +// an earlier failing run staged for the call site. Almost always there is nothing to take, so +// what it costs is what it costs to find that out. +// +// The project is a real directory, with an obj shaped like the one a multi-targeted test project +// builds up: two configurations of five frameworks, each with its NuGet, ref and refint folders +// and a VerifyInline with nothing in it. 151 directories in all, which is Verify.Tests' own obj. +[MemoryDiagnoser] +public class InlineStagingBenchmarks +{ + string root = ""; + string source = ""; + string intermediate = ""; + string stagedSource = ""; + string stagedIntermediate = ""; + + [GlobalSetup] + public void Setup() + { + root = Path.Combine(Path.GetTempPath(), $"DiffEngineBenchmarks_{Guid.NewGuid():N}"); + (source, intermediate) = Project(Path.Combine(root, "Empty")); + (stagedSource, stagedIntermediate) = Project(Path.Combine(root, "Staged")); + + // Five snapshots a run with no viewer left behind, none of them for the call site asked + // about, written the way that run writes them + var staging = Path.Combine(stagedIntermediate, InlineStaging.DirectoryName); + for (var index = 0; index < 5; index++) + { + var patch = new InlinePatch(stagedSource, 100 + index, "\"old\"", "new") + { + TestName = $"SampleTests.Other{index}", + MemberName = $"Other{index}", + OriginalValue = "old" + }; + InlinePatchFile.Write(Path.Combine(staging, $"SampleTests.Other{index}.inlinepatch"), patch); + } + } + + [GlobalCleanup] + public void Cleanup() => + Directory.Delete(root, true); + + // Nothing staged anywhere under the project + [Benchmark] + public int NothingStaged() => + InlineStaging.Clear(source, 42, "Sample", intermediate, value: "content"); + + // Other call sites have snapshots staged, and this one has none + [Benchmark] + public int OthersStaged() => + InlineStaging.Clear(stagedSource, 42, "Sample", stagedIntermediate, value: "content"); + + static (string source, string intermediate) Project(string directory) + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "Sample.csproj"), ""); + var source = Path.Combine(directory, "SampleTests.cs"); + File.WriteAllText(source, "// sample"); + + var obj = Path.Combine(directory, "obj"); + foreach (var configuration in new[] {"Debug", "Release"}) + { + foreach (var framework in new[] {"net11.0", "net10.0", "net9.0", "net8.0", "net48"}) + { + var modern = framework != "net48"; + var intermediate = Path.Combine(obj, configuration, framework); + Directory.CreateDirectory(Path.Combine(intermediate, "VerifyReceived")); + Directory.CreateDirectory(Path.Combine(intermediate, InlineStaging.DirectoryName)); + if (modern) + { + Directory.CreateDirectory(Path.Combine(intermediate, "ref")); + Directory.CreateDirectory(Path.Combine(intermediate, "refint")); + } + + // What restores leave behind: a folder per version of a package that ships targets + string[] versions = modern ? ["1.0.174", "1.0.176", "1.0.180", "1.0.181"] : ["1.0.180", "1.0.181"]; + foreach (var version in versions) + { + Directory.CreateDirectory(Path.Combine(intermediate, "NuGet", "629807350F552F76", "ProjectDefaults", version, "ProjectDefaults")); + } + } + } + + // What a Release run of one framework stages under, and names when it clears + return (source, Path.Combine(obj, "Release", "net10.0")); + } +} diff --git a/src/DiffEngine.Benchmarks/Program.cs b/src/DiffEngine.Benchmarks/Program.cs index 022bdfc59..3d507a029 100644 --- a/src/DiffEngine.Benchmarks/Program.cs +++ b/src/DiffEngine.Benchmarks/Program.cs @@ -1,8 +1,27 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; -using DiffEngine.Benchmarks; +using BenchmarkDotNet.Toolchains.InProcess.Emit; -// Run with: dotnet run -c Release --project src/DiffEngine.Benchmarks +// Run with: dotnet run -c Release --project src/DiffEngine.Benchmarks -- --filter * // Filter e.g.: dotnet run -c Release --project src/DiffEngine.Benchmarks -- --filter *ProcessScan* +// +// The library's benchmarks. The viewer's model has its own project, DiffEngineViewer.Benchmarks, +// since the viewer links DiffEngine's sources and so declares the same type names; the WinForms +// head has a third, DiffEngineViewer.Windows.Benchmarks, because it only builds for Windows. +// +// In process, which is not BenchmarkDotNet's default. Its default generates a project under bin/ +// and builds it with one OutDir for everything that project references, and DiffEngine references +// the three viewer heads for build ordering. All three are named DiffEngineViewer, so they +// overwrite one another's files there and the build fails. Nothing is generated or built this way, +// so a benchmark measures the assemblies this project was built against. +// +// A short run: three warmups and three iterations. These guard against costs that are out by +// multiples, which that settles; pass --iterationCount and --warmupCount for a closer comparison. +var config = DefaultConfig.Instance + .AddJob( + Job.ShortRun + .WithToolchain(InProcessEmitToolchain.Instance)); BenchmarkSwitcher - .FromTypes([typeof(ProcessScanBenchmarks), typeof(DiffToolsLookupBenchmarks)]) - .Run(args); + .FromAssembly(typeof(Program).Assembly) + .Run(args, config); diff --git a/src/DiffEngine.Benchmarks/TextDiffBenchmarks.cs b/src/DiffEngine.Benchmarks/TextDiffBenchmarks.cs new file mode 100644 index 000000000..fe72b4e2a --- /dev/null +++ b/src/DiffEngine.Benchmarks/TextDiffBenchmarks.cs @@ -0,0 +1,63 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace DiffEngine.Benchmarks; + +// The line diff behind TextDiff.Compute, TextDiff.Format and every text pair the viewer shows. +// Myers costs by the number of edits, so what matters is how much the two sides share, not how +// long they are: the same line count is measured with almost everything shared, with nothing +// shared, and with everything shared but in another order. +[MemoryDiagnoser] +public class TextDiffBenchmarks +{ + [Params(10_000, 40_000)] + public int Lines; + + string expected = ""; + string onePercentChanged = ""; + string reindented = ""; + string reordered = ""; + + [GlobalSetup] + public void Setup() + { + expected = Json(Lines, " ", -1); + // One line in a hundred differs, the usual shape of a snapshot that failed. + onePercentChanged = Json(Lines, " ", 100); + // A serializer setting that changes the indentation: no line survives it. + reindented = Json(Lines, " ", -1); + // Every line survives, none in the place it was. + var lines = expected.Split('\n'); + Array.Reverse(lines); + reordered = string.Join('\n', lines); + } + + [Benchmark] + public int OnePercentChanged() => + LineDiff.Build(expected, onePercentChanged).Entries.Count; + + [Benchmark] + public int NothingInCommon() => + LineDiff.Build(expected, reindented).Entries.Count; + + [Benchmark] + public int SameLinesInAnotherOrder() => + LineDiff.Build(expected, reordered).Entries.Count; + + static string Json(int lines, string indent, int changeEvery) + { + var builder = new StringBuilder(); + for (var index = 0; index < lines; index++) + { + var value = changeEvery > 0 && index % changeEvery == 0 ? index + 1 : index; + builder.Append(indent); + builder.Append("\"property"); + builder.Append(index); + builder.Append("\": "); + builder.Append(value); + builder.Append(",\n"); + } + + return builder.ToString(); + } +} diff --git a/src/DiffEngine.Benchmarks/UnownedPortBenchmarks.cs b/src/DiffEngine.Benchmarks/UnownedPortBenchmarks.cs new file mode 100644 index 000000000..99a7e7cd2 --- /dev/null +++ b/src/DiffEngine.Benchmarks/UnownedPortBenchmarks.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Sockets; +using BenchmarkDotNet.Attributes; + +namespace DiffEngine.Benchmarks; + +// What it costs to learn that nothing owns the viewer port, which is the ordinary state of a +// machine with no tray: no viewer is open until a snapshot fails. Windows does not refuse a +// loopback connection to a closed port at once, so every one of these was a wait. +// +// Never the real port, which a tray on the machine running this owns. The port is one this +// process binds and does not listen on: nothing answers on it, as nothing answers on a port that +// is free, and nothing else can take it while the benchmark runs, which a port found free and let +// go cannot promise. The variable is pointed at it as well, so that nothing here could reach a +// live owner by leaving a port out. +[MemoryDiagnoser] +public class UnownedPortBenchmarks +{ + static readonly ViewerMessage settle = new(ViewerVerb.Settle, InlineKey.For("Tests.cs", 1)); + static readonly ViewerMessage list = new(ViewerVerb.List); + + Socket holder = null!; + int port; + // Null when the variable was not set, which is what putting it back has to restore + string previousPort = null!; + + [GlobalSetup] + public void Setup() + { + holder = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + ExclusiveAddressUse = true + }; + holder.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + port = ((IPEndPoint) holder.LocalEndPoint!).Port; + previousPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString()); + } + + [GlobalCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousPort); + holder.Dispose(); + } + + // The launch gate's question: asked before it starts a viewer, and on every poll while the + // one it started is binding + [Benchmark] + public bool Probe() => + ViewerClient.IsOwned(port); + + // The first settle, move or delete of a test process, with nothing remembered about the port + [Benchmark] + public bool FirstTellingSend() + { + ViewerClient.ForgetUnowned(); + return ViewerClient.TrySend(settle, out _, port, skipIfUnowned: true); + } + + // The same from AddInlineAsync, whose first send is the async one + [Benchmark] + public async Task FirstTellingSendAsync() + { + ViewerClient.ForgetUnowned(); + return await ViewerClient.SendAsync(settle, CancellationToken.None, port, skipIfUnowned: true) == SendOutcome.Accepted; + } + + // A pending file or a failing snapshot with nothing owning the queue and no viewer that can + // be started. The gate is held for all of it, so every other caller waits this long too + [Benchmark] + public bool GatedCallWithNothingToLaunch() => + ViewerLaunchGate.Launch( + retry: () => false, + launch: () => null, + isOwned: () => ViewerClient.IsOwned(port), + canLaunch: () => true) == ViewerLaunchOutcome.Launched; + + // A host or a review surface asking for the queue, which is never answered from memory + [Benchmark] + public bool Ask() => + ViewerClient.TrySend(list, out _, port, ViewerClient.ShortTimeout); +} diff --git a/src/DiffEngine.Tests/DefinitionsTest.cs b/src/DiffEngine.Tests/DefinitionsTest.cs index b32079cda..40bb4fe92 100644 --- a/src/DiffEngine.Tests/DefinitionsTest.cs +++ b/src/DiffEngine.Tests/DefinitionsTest.cs @@ -92,6 +92,19 @@ public async Task ToolOrderMatchesEnumOrder() await Assert.That(definitionsOrder).IsEqualTo(enumOrder); } + /// + /// The viewer reads a missing target as the empty side of a new snapshot, so it is the one + /// tool here that must not be handed a placeholder: it compares against whatever is written, + /// and EmptyFiles has nothing to write for most of the map formats the viewer is the only + /// tool for, which ended the launch. + /// + [Test] + public async Task TheViewerDoesNotNeedATarget() + { + var viewer = Definitions.Tools.Single(_ => _.Tool == DiffTool.DiffEngineViewer); + await Assert.That(viewer.RequiresTarget).IsFalse(); + } + [Test] public void WriteDefaultOrder() { diff --git a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs index 07e031670..969180c39 100644 --- a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs +++ b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs @@ -60,6 +60,15 @@ public class FsCompilerRoundTripTests "a\nb" + (char) 0x2028 + "c" ]; + /// + /// The call as each mode meets it: with a Snapshot call to put the literal in, and with none. + /// + static readonly (InlinePatchMode Mode, string Call)[] midLineCalls = + [ + (InlinePatchMode.Set, "Verify(\"x\").Snapshot().ToTask()"), + (InlinePatchMode.Append, "Verify(\"x\").ToTask()") + ]; + [Test] [RequiresDotnet] public async Task PatchedSourceCompilesAndReadsBack() @@ -92,6 +101,17 @@ member _.ToTask() = value let Verify (value: string) = Chain(value) + // Somewhere for do!, let! and return! to be written. Where the offside line falls is + // decided by those keywords and not by the builder behind them, so this one hands back + // what was bound, and the literal can be read out of a computation expression by the + // same check as out of anything else + type Capture() = + member _.Bind(value: string, continuation: unit -> string) = value + continuation () + member _.Zero() = "" + member _.ReturnFrom(value: string) = value + + let capture = Capture() + let mutable failures = 0 // The reader's half of the convention, written out in F# rather than called into @@ -183,6 +203,32 @@ let inner () = InlinePatchMode.Set, content)); builder.Append($"check \"formatted{index}\" (formatted{index} ()) \"{expected}\"\n\n"); + + // A call with something in front of it on its line. The column its expression starts + // at is then past the line's indentation, and that column is what a new line has to + // clear: the appended call, and a literal given a line of its own. Measured from the + // line instead, only the return! survived both, and the do! a literal alone + foreach (var (mode, call) in midLineCalls) + { + Add($"doBang{mode}", $"let doBang{mode}{index} () =\n capture {{\n do! {call}\n }}\n", 3, mode); + Add($"letBang{mode}", $"let letBang{mode}{index} () =\n capture {{\n let! _ = {call}\n return! \"\"\n }}\n", 3, mode); + Add($"returnBang{mode}", $"let returnBang{mode}{index} () =\n capture {{\n return! {call}\n }}\n", 3, mode); + Add($"local{mode}", $"let local{mode}{index} () =\n let result = {call}\n result\n", 2, mode); + Add($"oneLine{mode}", $"let oneLine{mode}{index} () = {call}\n", 1, mode); + } + + // The line the chain ends on is not one of its calls here, and sits at the column the + // expression starts at: a closing paren where a formatter puts it, and an argument + // that starts its own line. An appended call lined up with either is on the offside + // line, whether or not the call starts its line + Add("underParen", $"let underParen{index} () =\n Verify(\n \"x\"\n ).ToTask()\n", 2, InlinePatchMode.Append); + Add("underArgument", $"let underArgument{index} () =\n capture {{\n do! Verify(\n \"x\").ToTask()\n }}\n", 3, InlinePatchMode.Append); + + void Add(string name, string snippet, int lineHint, InlinePatchMode mode) + { + builder.Append(Patch(snippet, lineHint, mode, content)); + builder.Append($"check \"{name}{index}\" ({name}{index} ()) \"{expected}\"\n\n"); + } } builder.Append( diff --git a/src/DiffEngine.Tests/InlineApplierBatchTests.cs b/src/DiffEngine.Tests/InlineApplierBatchTests.cs new file mode 100644 index 000000000..8f7bd498c --- /dev/null +++ b/src/DiffEngine.Tests/InlineApplierBatchTests.cs @@ -0,0 +1,293 @@ +/// +/// Several patches in one call. What that changes is how often a file is read and written, and +/// these pin the two things it must not change: what each patch is told, and what is in the file +/// afterwards. +/// +public class InlineApplierBatchTests +{ + /// + /// The contract in one test: the same patches applied together and applied in turn leave the + /// same bytes and report the same outcomes, in the same order. The batch includes the cases + /// where a patch only reads as it does because of one before it - a call site the earlier + /// literals have moved, the same patch twice, a second patch for a call site already taken. + /// + [Test] + public async Task TogetherIsWhatInTurnLeaves() + { + using var together = new TempSource(Members(6)); + using var inTurn = new TempSource(Members(6)); + + InlinePatch[] Patches(string path) => + [ + Set(path, 0), + Set(path, 3), + // The same patch again, which finds its own literal already there + Set(path, 3), + // The call site the first one took, with different content: its anchor has gone + Set(path, 0, content: "something else\nagain"), + Set(path, 1), + // Never was in the file + Set(path, 2, anchor: "\"not in the source\""), + Set(path, 5), + Set(path, 4) + ]; + + var batch = InlineApplier.ApplyAll(Patches(together.FullName)); + var single = Patches(inTurn.FullName) + .Select(InlineApplier.Apply) + .ToList(); + + await Assert.That(Statuses(batch)).IsEqualTo(Statuses(single)); + await Assert.That(Statuses(batch)).IsEqualTo("Applied, Applied, AlreadyApplied, NotFound, Applied, NotFound, Applied, Applied"); + await Assert.That(Messages(batch)).IsEqualTo(Messages(single)); + await Assert.That(together.Text).IsEqualTo(inTurn.Text); + await Assert.That(together.Text).Contains("new 5"); + } + + /// + /// The point of handing them over together: one file, one swap, however many of its snapshots + /// are accepted. + /// + [Test] + public async Task AFileIsSwappedOnceForAllItsPatches() + { + using var file = new TempSource(Members(4)); + var swaps = 0; + + var results = InlineApplier.ApplyAll( + [Set(file.FullName, 0), Set(file.FullName, 1), Set(file.FullName, 2), Set(file.FullName, 3)], + (temporary, destination) => + { + swaps++; + File.Replace(temporary, destination, null); + }); + + await Assert.That(Statuses(results)).IsEqualTo("Applied, Applied, Applied, Applied"); + await Assert.That(swaps).IsEqualTo(1); + for (var member = 0; member < 4; member++) + { + await Assert.That(file.Text).Contains($"new {member}"); + } + + // And nothing of the temporary is left beside it + await Assert.That(Directory.GetFileSystemEntries(file.Root)).IsEquivalentTo([file.FullName]); + } + + /// + /// Patches arrive in queue order, which is not file order. Each file is still read and written + /// once, and each result is at the index its patch was given at. + /// + [Test] + public async Task PatchesForSeveralFilesKeepTheirOrder() + { + using var first = new TempSource(Members(2)); + using var second = new TempSource(Members(2)); + var swapped = new List(); + + var results = InlineApplier.ApplyAll( + [ + Set(first.FullName, 0), + Set(second.FullName, 1, anchor: "\"not in the source\""), + Set(first.FullName, 1), + Set(second.FullName, 0) + ], + (temporary, destination) => + { + swapped.Add(destination); + File.Replace(temporary, destination, null); + }); + + await Assert.That(Statuses(results)).IsEqualTo("Applied, NotFound, Applied, Applied"); + await Assert.That(string.Join(", ", swapped)).IsEqualTo($"{first.FullName}, {second.FullName}"); + await Assert.That(first.Text).Contains("new 0"); + await Assert.That(first.Text).Contains("new 1"); + await Assert.That(second.Text).Contains("new 0"); + await Assert.That(second.Text).Contains("\"old 1\""); + } + + /// + /// One write carries every edit, so when it fails none of them happened and each patch has to + /// say so: an entry reported as applied is dropped from its queue, and these are not in the + /// source. A patch judged before the first edit keeps its answer, which was about the file as + /// it is. One judged after does not, since what it was judged against was never written - the + /// duplicate here was only already applied because of the patch before it. + /// + [Test] + public async Task AWriteThatFailsFailsEveryPatchItCarried() + { + using var file = new TempSource(Members(3)); + var before = file.Text; + + var results = InlineApplier.ApplyAll( + [ + Set(file.FullName, 2, anchor: "\"not in the source\""), + Set(file.FullName, 0), + Set(file.FullName, 0), + Set(file.FullName, 1) + ], + (_, _) => throw new IOException("The process cannot access the file.")); + + await Assert.That(Statuses(results)).IsEqualTo("NotFound, Failed, Failed, Failed"); + await Assert.That(results[1].Message!).Contains("Failed to write"); + await Assert.That(results[1].Exception).IsTypeOf(); + await Assert.That(file.Text).IsEqualTo(before); + await Assert.That(Directory.GetFileSystemEntries(file.Root)).IsEquivalentTo([file.FullName]); + } + + /// + /// A batch in which nothing applies writes nothing, as a single patch that is already applied + /// writes nothing: the file an editor has open is not touched for no reason. + /// + [Test] + public async Task NothingToApplyIsNothingWritten() + { + using var file = new TempSource(Members(2)); + var swaps = 0; + + var results = InlineApplier.ApplyAll( + [ + Set(file.FullName, 0, anchor: "\"not in the source\""), + Set(file.FullName, 1, content: "old 1") + ], + (_, _) => swaps++); + + await Assert.That(Statuses(results)).IsEqualTo("NotFound, AlreadyApplied"); + await Assert.That(swaps).IsEqualTo(0); + } + + /// + /// A patch that names no file, or a file that is not there, is that patch's failure. The ones + /// around it are for other files and are applied as if it had not been in the batch. + /// + [Test] + public async Task APatchWithNoFileToPatchDoesNotStopTheRest() + { + using var file = new TempSource(Members(2)); + var missing = Path.Combine(file.Root, "NotThere.cs"); + + var results = InlineApplier.ApplyAll( + [ + Set(file.FullName, 0), + Set("", 0), + Set(missing, 0), + // No line to look near, which is a patch nothing can be done with + new(file.FullName, 0, "\"old 1\"", "new 1") + { + TestName = null + }, + Set(file.FullName, 1) + ]); + + await Assert.That(Statuses(results)).IsEqualTo("Applied, Failed, Failed, Failed, Applied"); + await Assert.That(results[1].Message!).Contains("SourceFile is empty"); + await Assert.That(results[2].Message!).Contains("does not exist"); + await Assert.That(results[3].Message!).Contains("LineHint"); + await Assert.That(file.Text).Contains("new 0"); + await Assert.That(file.Text).Contains("new 1"); + } + + /// + /// The file is decoded once and encoded once for the whole batch, and comes back as it was + /// read: the same encoding, the same byte order mark, the same line endings. + /// + [Test] + public async Task EncodingAndLineEndingsSurviveABatch() + { + var encoding = new UnicodeEncoding(false, true); + using var file = new TempSource(Members(3, "\r\n"), encoding); + + var results = InlineApplier.ApplyAll([Set(file.FullName, 0), Set(file.FullName, 1), Set(file.FullName, 2)]); + + await Assert.That(Statuses(results)).IsEqualTo("Applied, Applied, Applied"); + var bytes = await File.ReadAllBytesAsync(file.FullName); + await Assert.That(bytes[0]).IsEqualTo((byte) 0xFF); + await Assert.That(bytes[1]).IsEqualTo((byte) 0xFE); + var text = encoding.GetString(bytes, 2, bytes.Length - 2); + await Assert.That(text).Contains("new 2"); + await Assert.That(text.Replace("\r\n", "")).DoesNotContain("\n"); + } + + [Test] + public async Task AnEmptyBatchIsNoResults() => + await Assert.That(InlineApplier.ApplyAll([])).IsEmpty(); + + // Joined, because the order is the point and the collection assertions do not check it + static string Statuses(IEnumerable results) => + string.Join(", ", results.Select(_ => _.Status)); + + static string Messages(IEnumerable results) => + string.Join("\n", results.Select(_ => _.Message ?? "-")); + + /// + /// A member a line, each with a snapshot of its own. The content a patch writes is two lines, + /// so it is rendered as a literal on lines of its own and every member below it moves. + /// + static string Members(int count, string eol = "\n") + { + var builder = new StringBuilder(); + builder.Append($"class C{eol}{{{eol}"); + for (var member = 0; member < count; member++) + { + builder.Append($" void M{member}() => Verify(value{member}).Snapshot(\"old {member}\");{eol}"); + } + + builder.Append('}'); + return builder.ToString(); + } + + /// + /// The patch a failing run would have produced for a member of : its line + /// as it was when the file was written, the literal it held, and the member it is in. + /// + static InlinePatch Set(string path, int member, string? anchor = null, string? content = null) => + new(path, member + 3, anchor ?? $"\"old {member}\"", content ?? $"new {member}\nsecond line") + { + TestName = null, + MemberName = $"M{member}" + }; + + /// + /// A source file in a directory of its own, so what is left beside it can be asserted. Root + /// and FullName rather than Directory and Path, both of which are types the tests use. + /// + sealed class TempSource : IDisposable + { + readonly Encoding encoding; + + public TempSource(string text, Encoding? encoding = null) + { + this.encoding = encoding ?? new UTF8Encoding(false); + Root = Path.Combine(Path.GetTempPath(), $"InlineApplierBatchTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(Root); + FullName = Path.Combine(Root, "Sample.cs"); + File.WriteAllBytes(FullName, [.. this.encoding.GetPreamble(), .. this.encoding.GetBytes(text)]); + } + + public string Root { get; } + + public string FullName { get; } + + public string Text + { + get + { + var bytes = File.ReadAllBytes(FullName); + var preamble = encoding.GetPreamble().Length; + return encoding.GetString(bytes, preamble, bytes.Length - preamble); + } + } + + public void Dispose() + { + try + { + Directory.Delete(Root, true); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + // Best effort cleanup of the temp directory + } + } + } +} diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index acfd6948f..309e56af8 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -408,6 +408,24 @@ await Assert.That(newSource).IsEqualTo( """)); } + // ToTask is not the only way off a SettingsTask, and a test that blocks instead of awaiting + // leaves by another: the Snapshot call goes in front of whichever comes first + [Test] + public async Task AppendGoesInFrontOfGetAwaiter() + { + var source = Test(" Verifier.Verify(15).GetAwaiter().GetResult() |> ignore"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + """ + Verifier.Verify(15) + .Snapshot("new").GetAwaiter().GetResult() |> ignore + """)); + } + [Test] public async Task AppendToAMultiLineChain() { @@ -459,7 +477,9 @@ await Assert.That(newSource).IsEqualTo( } // Awaited in a task expression instead, so there is no ToTask and the chain end is the - // insertion point + // insertion point. One level in from where the expression starts, which after do! is not where + // the line does: this used to expect the call under Verifier, and F# reads that as the next + // statement [Test] public async Task AppendWithNoToTask() { @@ -478,11 +498,203 @@ await Assert.That(newSource).IsEqualTo( """ task { do! Verifier.Verify(15) - .Snapshot("new") + .Snapshot("new") + } + """)); + } + + /// + /// The shape of Verify's own Expecto sample. The offside line is the column the expression + /// starts at, and after do! that is four past the line's indentation, so one level in + /// from the line put the call exactly on it: FS0010, unexpected symbol '.' in expression. + /// + [Test] + public async Task AppendAfterDoBangClearsTheExpression() + { + var source = Test( + """ + testTask "findPerson" { + do! Verifier.Verify("findPerson", person).ToTask() + } + """); + + var status = TryApply(source, 6, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " testTask \"findPerson\" {\n" + + " do! Verifier.Verify(\"findPerson\", person)\n" + + " .Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask()\n" + + " }")); + } + + /// + /// A literal on its own line has the same line to clear. After let! _ = the expression + /// starts nine columns past the indentation, and the literal one level in from the line was + /// left of it. + /// + [Test] + public async Task SetAfterLetBangClearsTheExpression() + { + var source = Test( + """ + testTask "findPerson" { + let! _ = Verifier.Verify(x).Snapshot("old").ToTask() + return () + } + """); + + var status = TryApply(source, 6, InlinePatchMode.Set, null, "a\nb", out var newSource, out _, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " testTask \"findPerson\" {\n" + + " let! _ = Verifier.Verify(x).Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask()\n" + + " return ()\n" + + " }")); + } + + // A binding written on one line: the expression starts after the equals sign + [Test] + public async Task AppendToABindingOnOneLineClearsTheExpression() + { + var source = Source( + """ + module Tests + + let MyTest () = Verifier.Verify(15).ToTask() + + """); + + var status = TryApply(source, 3, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Source( + """ + module Tests + + let MyTest () = Verifier.Verify(15) + .Snapshot("new").ToTask() + + """)); + } + + // The receiver is part of the expression, however long: the column is where the first name + // of it is, with a namespace in front of the class as much as without + [Test] + public async Task TheExpressionStartsAtItsOutermostReceiver() + { + var source = Test(" let result = VerifyXunit.Verifier.Verify(person).UseDirectory(\"x\").Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " let result = VerifyXunit.Verifier.Verify(person).UseDirectory(\"x\").Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask()")); + } + + /// + /// A closing paren on a line of its own, where a formatter puts it: at the column the call + /// starts at. Lining the appended call up with the line the chain ended on was right for a + /// chain and put this one on the offside line, in a call that does start its line. + /// + [Test] + public async Task AppendUnderAClosingParenClearsTheExpression() + { + var source = Test( + """ + Verifier.Verify( + value + ).ToTask() + """); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + """ + Verifier.Verify( + value + ) + .Snapshot("new").ToTask() + """)); + } + + // A chain already across lines is somewhere the compiler accepted, so the call joins it + [Test] + public async Task AppendAfterDoBangLinesUpWithAnExistingChain() + { + var source = Test( + """ + task { + do! Verifier.Verify(15) + .UseMethodName("customName") + .ToTask() + } + """); + + var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + """ + task { + do! Verifier.Verify(15) + .UseMethodName("customName") + .Snapshot("new") + .ToTask() } """)); } + // Where the Snapshot call is on a line of the chain's own, that line is what the literal is + // one level in from, as it always was + [Test] + public async Task SetOnAChainedLineIsMeasuredFromThatLine() + { + var source = Test( + """ + task { + do! Verifier.Verify(15) + .Snapshot("old") + .ToTask() + } + """); + + var status = TryApply(source, 7, InlinePatchMode.Set, null, "a\nb", out var newSource, out _, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " task {\n" + + " do! Verifier.Verify(15)\n" + + " .Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\")\n" + + " .ToTask()\n" + + " }")); + } + [Test] public async Task AppendMultiLineContent() { @@ -525,6 +737,31 @@ public async Task AppendOntoTheSameContentIsAlreadyApplied() await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } + // The first call in the test was accepted, which is what left the hint stale, so the walk from + // the member's declaration meets it before the call the patch is for + [Test] + public async Task AppendPassesOverACallThatAlreadyHasASnapshot() + { + var source = Test( + """ + Verifier.Verify(a).Snapshot("A").ToTask() |> ignore + Verifier.Verify(b).ToTask() |> ignore + printfn "done" + """); + + var status = TryApply(source, 7, InlinePatchMode.Append, null, "B", out var newSource, out var reason, memberName: "MyTest"); + + await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, "")); + await Assert.That(newSource).IsEqualTo( + Test( + """ + Verifier.Verify(a).Snapshot("A").ToTask() |> ignore + Verifier.Verify(b) + .Snapshot("B").ToTask() |> ignore + printfn "done" + """)); + } + [Test] public async Task AppendingTheSameContentTwiceIsAlreadyApplied() { @@ -577,6 +814,70 @@ public async Task RemoveFromASingleLineChain() await Assert.That(newSource).IsEqualTo(Test(" Verifier.Verify(15).ToTask()")); } + /// + /// The call on a VerifySettings rather than on a verify call. Taken off the end it left the + /// variable standing as an expression of its own, which F# warns about (FS0020) and a project + /// that treats warnings as errors refuses. There is no semicolon to say where the statement + /// ends, so it is the line, and the statement under it is what says the block still has one. + /// + [Test] + public async Task RemoveOfACallOnAVariableTakesItsLine() + { + var source = Test( + """ + let settings = VerifySettings() + settings.Snapshot("old") + Verifier.Verify(15, settings).ToTask() + """); + + var status = TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out var reason, originalValue: "old"); + + await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, "")); + await Assert.That(newSource).IsEqualTo( + Test( + """ + let settings = VerifySettings() + Verifier.Verify(15, settings).ToTask() + """)); + } + + /// + /// Bound or passed on the same line, what the call was on is still a value with the call gone, + /// so the call alone is taken. The = a line above is another matter: a whole body hangs + /// off that one, and the line under it is the body's first statement. + /// + [Test] + [Arguments(" let kept = task.Snapshot(\"old\")\n kept.ToTask()", " let kept = task\n kept.ToTask()")] + [Arguments(" run (task.Snapshot(\"old\"))", " run (task)")] + public async Task RemoveOfACallWhoseValueIsTakenLeavesWhatItWasOn(string body, string expected) + { + var source = Test(body); + + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out var newSource, out var reason, originalValue: "old"); + + await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, "")); + await Assert.That(newSource).IsEqualTo(Test(expected)); + } + + /// + /// The last line of its block is the block's value, and taking it away leaves a binding with + /// nothing under it. So is a line the next one carries on, or one something else shares. + /// + [Test] + [Arguments(" let settings = VerifySettings()\n settings.Snapshot(\"old\")")] + [Arguments(" task.Snapshot(\"old\")\n |> ignore")] + [Arguments(" if flag then\n settings.Snapshot(\"old\")\n Verifier.Verify(15, settings).ToTask()")] + [Arguments(" settings.Snapshot(\"old\"); Verifier.Verify(15, settings).ToTask()")] + public async Task RemoveReportsACallItCannotTakeWithItsLine(string body) + { + var source = Test(body); + + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out var reason, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Remove the statement by hand"); + } + [Test] public async Task LineCommentedOutCallIsSkipped() { diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 7ca3c1cd2..df92b9d47 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -786,6 +786,71 @@ await Assert.That(newSource).Contains( """); } + /// + /// The three calls that hand back something other than a SettingsTask. A Snapshot call after + /// one of them is a call on a ConfiguredTaskAwaitable, a Task or a TaskAwaiter, none of which + /// has one, so the append goes in front. The end of the chain used to be the only place C# + /// appended, which wrote source that did not compile into a call site the anchor probe had + /// already said could host a snapshot. + /// + [Test] + [Arguments(".ConfigureAwait(false)")] + [Arguments(".ToTask()")] + [Arguments(".GetAwaiter().GetResult()")] + public async Task AppendGoesInFrontOfWhatEndsTheChain(string ending) + { + var source = Method($" await Verify(value){ending};"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Method( + $""" + await Verify(value) + .Snapshot("new"){ending}; + """)); + } + + // A chain already across lines keeps its shape: the call takes a line of its own, in front of + // the one that ends the chain + [Test] + public async Task AppendToAMultiLineChainGoesInFrontOfWhatEndsIt() + { + var source = Method( + """ + await Verify(value) + .UseDirectory("snapshots") + .ConfigureAwait(false); + """); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Method( + """ + await Verify(value) + .UseDirectory("snapshots") + .Snapshot("new") + .ConfigureAwait(false); + """)); + } + + // What the append wrote is what the second framework's identical append then finds, in front + // of the call that ends the chain rather than at the end of it + [Test] + public async Task AppendingTwiceInFrontOfWhatEndsTheChainIsAlreadyApplied() + { + var source = Method(" await Verify(value).ConfigureAwait(false);"); + + TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var applied, out _); + var status = TryApply(applied, 5, InlinePatchMode.Append, null, "a\nb", out _, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + await Assert.That(applied).Contains("\"\"\").ConfigureAwait(false);"); + } + [Test] public async Task AppendToAnEntryPointOverload() { @@ -868,6 +933,152 @@ public async Task AppendingTheSameContentTwiceIsAlreadyApplied() await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } + const string acceptedThenNew = + """ + class Tests + { + async Task Test() + { + await Verify(a) + .Snapshot("A"); + await Verify(b); + } + } + + """; + + /// + /// Two verify calls in one test, the first already accepted, and a hint an accept higher in + /// the file left pointing past the member. The walk starts over from the declaration and meets + /// the accepted call first. Stopping there refused the patch over a Snapshot call that was + /// never in its way, with the call it was for two lines below. + /// + [Test] + public async Task AppendPassesOverACallThatAlreadyHasASnapshot() + { + var status = TryApply(Source(acceptedThenNew), 12, InlinePatchMode.Append, null, "B", out var newSource, out var reason, memberName: "Test"); + + await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, "")); + await Assert.That(newSource).IsEqualTo( + Source( + """ + class Tests + { + async Task Test() + { + await Verify(a) + .Snapshot("A"); + await Verify(b) + .Snapshot("B"); + } + } + + """)); + } + + /// + /// The hint lands on the accepted call this time, which is what a second framework's patch for + /// that same call site looks like once the first has been accepted. It names that call, so the + /// call after it is not somewhere else to put the snapshot. + /// + [Test] + public async Task AppendStopsAtACallOnTheRecordedLineThatAlreadyHasASnapshot() + { + var status = TryApply(Source(acceptedThenNew), 5, InlinePatchMode.Append, null, "B", out _, out var reason, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("already has a Snapshot call"); + } + + /// + /// With no member there is nothing to say where the test ends, so the call nearest the hint + /// still decides: the next one without a Snapshot call could be anybody's. + /// + [Test] + public async Task AppendWithNoMemberStopsAtTheNearestCall() + { + var status = TryApply(Source(acceptedThenNew), 4, InlinePatchMode.Append, null, "B", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("already has a Snapshot call"); + } + + // Nothing left to take it, and nothing holding it either + [Test] + public async Task AppendIsRefusedWhenEveryCallInTheMemberHasASnapshot() + { + var source = Source( + """ + class Tests + { + async Task Test() + { + await Verify(a).Snapshot("A"); + await Verify(b).Snapshot("other"); + } + } + + """); + + var status = TryApply(source, 12, InlinePatchMode.Append, null, "B", out _, out var reason, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("already has a Snapshot call"); + } + + /// + /// The second framework's append again, but with a hint gone stale: the call that holds the + /// content is no longer the first one the walk meets, and the first one holding something + /// else used to make this a refusal over source that was already right. + /// + [Test] + public async Task AppendFindsItsContentAlreadyOnACallPastTheFirst() + { + var source = Source( + """ + class Tests + { + async Task Test() + { + await Verify(a).Snapshot("A"); + await Verify(b).Snapshot("B"); + } + } + + """); + + var status = TryApply(source, 12, InlinePatchMode.Append, null, "B", out _, out _, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + /// + /// An entry point inside the arguments of the call that was passed over. It has no Snapshot + /// call chained onto it, and reads as the next candidate, but it is part of a call that has. + /// + [Test] + public async Task AppendDoesNotLandInsideACallItPassedOver() + { + var source = Source( + """ + class Tests + { + async Task Test() + { + await Throws(() => Verify(a)).Snapshot("A"); + await Verify(b); + } + } + + """); + + var status = TryApply(source, 12, InlinePatchMode.Append, null, "B", out var newSource, out _, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("await Throws(() => Verify(a)).Snapshot(\"A\");\n"); + await Assert.That(newSource).Contains("await Verify(b)\n .Snapshot(\"B\");"); + } + [Test] public async Task AppendWithNoVerifyCall() { @@ -1182,6 +1393,152 @@ public async Task RemoveWhenTheCallIsNotChained() await Assert.That(reason).Contains("not a chained call"); } + /// + /// Snapshot is public on VerifySettings, so the call can hang off a variable rather than off a + /// verify call. Taking it off the end left settings;, which is no statement at all + /// (CS0201). The whole statement is what has stopped being wanted, so its line goes. + /// + [Test] + public async Task RemoveOfACallOnAVariableTakesItsStatement() + { + var source = Method( + """ + var settings = new VerifySettings(); + settings.Snapshot("old"); + await Verify(value, settings); + """); + + var status = TryApply(source, 6, InlinePatchMode.Remove, "\"old\"", "", out var newSource, out var reason, memberName: "Test"); + + await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, "")); + await Assert.That(newSource).IsEqualTo( + Method( + """ + var settings = new VerifySettings(); + await Verify(value, settings); + """)); + } + + // Every line of it, and the comment that trailed it + [Test] + public async Task RemoveOfAStatementTakesAllOfItsLines() + { + var source = Method( + " var settings = new VerifySettings();\n" + + " fixture.Settings\n" + + " .Snapshot(\n" + + " \"\"\"\n" + + " old\n" + + " \"\"\"); // inline for now\n" + + " await Verify(value, settings);"); + + var status = TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Method( + """ + var settings = new VerifySettings(); + await Verify(value, settings); + """)); + } + + [Test] + public async Task RemoveOfAStatementWithCrlf() + { + var source = Method( + """ + var settings = new VerifySettings(); + settings.Snapshot("old"); + await Verify(value, settings); + """); + var expected = Method( + """ + var settings = new VerifySettings(); + await Verify(value, settings); + """); + + var status = TryApply(source.Replace("\n", "\r\n"), 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo(expected.Replace("\n", "\r\n")); + } + + /// + /// The second framework's Remove, which finds the line it named holding the verify call that + /// used to follow the statement, with nothing chained onto it. + /// + [Test] + public async Task ReapplyingARemoveOfAStatementIsAlreadyApplied() + { + var source = Method( + """ + var settings = new VerifySettings(); + settings.Snapshot("old"); + await Verify(value, settings); + """); + + TryApply(source, 6, InlinePatchMode.Remove, "\"old\"", "", out var removed, out _, memberName: "Test"); + var status = TryApply(removed, 6, InlinePatchMode.Remove, "\"old\"", "", out _, out _, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + /// + /// Shapes where the call is all its statement does and the statement cannot simply be lifted + /// out: an if with no braces would take the next statement for its body, a line shared + /// with another statement is not the call's to remove, and a lambda's body is not a statement + /// of its own at all. Reported, where it used to leave the receiver behind. + /// + [Test] + [Arguments(" if (flag)\n settings.Snapshot(\"old\");")] + [Arguments(" var settings = new VerifySettings(); settings.Snapshot(\"old\");")] + [Arguments(" Configure(_ => _.Snapshot(\"old\"));")] + public async Task RemoveReportsACallItCannotTakeWithItsStatement(string body) + { + var source = Method(body); + + var status = TryApply(source, 5, InlinePatchMode.Remove, "\"old\"", "", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Remove the statement by hand"); + } + + /// + /// Awaited, assigned, returned or passed, what the call was on is still a value with the call + /// gone, so the call alone is taken and the statement reads as it would have run without the + /// snapshot. Only a statement that was nothing but the call leaves a name standing by itself. + /// + [Test] + [Arguments(" await task.Snapshot(\"old\");", " await task;")] + [Arguments(" var kept = task.Snapshot(\"old\");", " var kept = task;")] + [Arguments(" kept = task.Snapshot(\"old\");", " kept = task;")] + [Arguments(" return task.Snapshot(\"old\");", " return task;")] + [Arguments(" Run(task.Snapshot(\"old\"));", " Run(task);")] + [Arguments(" Run(first, task.Snapshot(\"old\"));", " Run(first, task);")] + [Arguments(" var same = other == task.Snapshot(\"old\");", " var same = other == task;")] + public async Task RemoveOfACallWhoseValueIsTakenLeavesWhatItWasOn(string body, string expected) + { + var source = Method(body); + + var status = TryApply(source, 5, InlinePatchMode.Remove, "\"old\"", "", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo(Method(expected)); + } + + // With more of the chain to come, the rest hangs off the variable as it hung off the call + [Test] + public async Task RemoveFromTheMiddleOfAChainOnAVariable() + { + var source = Method(" await task.Snapshot(\"old\").UseDirectory(\"snapshots\");"); + + var status = TryApply(source, 5, InlinePatchMode.Remove, "\"old\"", "", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo(Method(" await task.UseDirectory(\"snapshots\");")); + } + /// /// A verify call with no Snapshot left is what a Remove leaves behind, and what the same /// Remove finds when a second framework's test process applies it. It is done, and saying so diff --git a/src/DiffEngine.Tests/InlineQueueTests.cs b/src/DiffEngine.Tests/InlineQueueTests.cs index 3b2e0b60c..140591a5f 100644 --- a/src/DiffEngine.Tests/InlineQueueTests.cs +++ b/src/DiffEngine.Tests/InlineQueueTests.cs @@ -11,8 +11,9 @@ static InlinePatch Patch( string content = "new", string? framework = null, string? testName = null, - string? member = null) => - new(source, line, "\"old\"", content) + string? member = null, + string expression = "\"old\"") => + new(source, line, expression, content) { Framework = framework, TestName = testName, @@ -76,6 +77,183 @@ public async Task EnqueueKeepsPathCaseApartWhereTheFilesDo() await Assert.That(queue.Count).IsEqualTo(2); } + /// + /// Three tests in one file with a snapshot pending each. Accepting the first writes its + /// literal into the source, and the re-run reports the other two from five lines further down. + /// Their keys name nothing by then, so each was queued a second time beside the entry it + /// should have updated: four entries for two snapshots, the stale one of each first in line + /// for a bulk accept. + /// + [Test] + public async Task ARerunFromWhereItsCallSiteMovedToUpdatesItsEntry() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 10, content: "a", member: "TestA")) + .Enqueue(Patch(line: 20, content: "b", member: "TestB")) + .Enqueue(Patch(line: 30, content: "c", member: "TestC")) + .Accept(InlineKey.For("Sample.cs", 10), _ => InlineApplyResult.Applied, out _) + .Enqueue(Patch(line: 25, content: "b, as it is now", member: "TestB")) + .Enqueue(Patch(line: 35, content: "c", member: "TestC")); + + await Assert.That(queue.Items.Select(_ => $"{_.Name} {_.Patch.NewContent}")).IsEquivalentTo( + [ + "Sample.cs:25 b, as it is now", + "Sample.cs:35 c" + ]); + } + + /// + /// Every variant goes to the new line, not only the one the re-run came from. Left where they + /// were, the same content from two frameworks no longer matched - a patch is compared line and + /// all - and an entry that had agreed with itself turned into a conflict. + /// + [Test] + public async Task AMovedEntryTakesEveryFrameworksVariantWithIt() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 20, content: "eight", framework: "net8.0", member: "TestB")) + .Enqueue(Patch(line: 20, content: "nine", framework: "net9.0", member: "TestB")) + .Enqueue(Patch(line: 25, content: "nine", framework: "net8.0", member: "TestB")); + + var entry = queue.Items.Single(); + await Assert.That(entry.Key).IsEqualTo(InlineKey.For("Sample.cs", 25)); + await Assert.That(entry.Conflicted).IsFalse(); + await Assert.That(entry.Variants[0].Origins).IsEquivalentTo(["net9.0", "net8.0"]); + await Assert.That(entry.Variants[0].Patch.LineHint).IsEqualTo(25); + } + + /// + /// The patches a queue holds are shared with whatever is displaying them, so an entry that + /// moves is given copies and the ones it held are left saying what they said. + /// + [Test] + public async Task AMovedEntryLeavesThePatchItHeldAlone() + { + var first = Patch(line: 20, content: "b", member: "TestB"); + + var queue = InlineQueue.Empty + .Enqueue(first) + .Enqueue(Patch(line: 25, content: "b", member: "TestB")); + + await Assert.That(queue.Items.Single().Patch.LineHint).IsEqualTo(25); + await Assert.That(first.LineHint).IsEqualTo(20); + } + + /// + /// Two entries that read alike from here: one member, the same literal in the source at both. + /// Nothing says which of them a patch from a third line is, and folding into the wrong one + /// replaces a snapshot that is still pending, so it is queued beside them, as it always was. + /// + [Test] + public async Task ARerunFromAnotherLineIsQueuedBesideEntriesItCannotTellApart() + { + var queue = InlineQueue + .From( + [ + new(Patch(line: 20, content: "first", member: "MyTest")), + new(Patch(line: 30, content: "second", member: "MyTest")) + ]) + .Enqueue(Patch(line: 25, content: "first", member: "MyTest")); + + await Assert.That(queue.Count).IsEqualTo(3); + } + + /// + /// One framework stopped at the first call of a test, and another passed that one and stopped + /// at the second, which holds the same literal. Two call sites, and from here they differ only + /// by line and by who reported them: taking the second for the first one moved would put the + /// first framework's snapshot on the second call. + /// + [Test] + public async Task ARerunDoesNotMoveAnEntryAnotherFrameworkQueued() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 20, content: "eight", framework: "net8.0", member: "MyTest")) + .Enqueue(Patch(line: 25, content: "nine", framework: "net9.0", member: "MyTest")); + + await Assert.That(queue.Items.Select(_ => $"{_.Name} {_.OriginsLabel}")).IsEquivalentTo( + [ + "Sample.cs:20 net8.0", + "Sample.cs:25 net9.0" + ]); + } + + /// + /// A member is a name, and one file can declare it twice: a class per scenario, each with its + /// own Works. The test name is what says the second patch is not the first one moved. + /// + [Test] + public async Task ARerunDoesNotTakeTheEntryOfTheSameMemberInAnotherClass() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 10, content: "a", testName: "First.Works", member: "Works")) + .Enqueue(Patch(line: 40, content: "b", testName: "Second.Works", member: "Works")); + + await Assert.That(queue.Items.Select(_ => _.Name)).IsEquivalentTo(["Sample.cs:10", "Sample.cs:40"]); + } + + // Nor of the same member in another file, which a base class and its partial make ordinary + [Test] + public async Task ARerunDoesNotTakeTheEntryOfTheSameMemberInAnotherFile() + { + var queue = InlineQueue.Empty + .Enqueue(Patch("A.cs", 10, content: "a", member: "Works")) + .Enqueue(Patch("B.cs", 40, content: "b", member: "Works")); + + await Assert.That(queue.Items.Select(_ => _.Name)).IsEquivalentTo(["A.cs:10", "B.cs:40"]); + } + + /// + /// The move that put one call site on the line another was queued under. Both entries are + /// stale by the same accept, and the first re-run to arrive lands on the other one's key. + /// Folded into it, the other test's snapshot was replaced by this one's, and this one's own + /// entry was left behind stale. It updates its own instead, and keeps the key it had until the + /// other entry has moved off the line. + /// + [Test] + public async Task ARerunOntoALineAnotherMembersEntryIsUnderLeavesThatEntryAlone() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 20, content: "b", member: "TestB")) + .Enqueue(Patch(line: 30, content: "c", member: "TestC")) + .Enqueue(Patch(line: 30, content: "b, as it is now", member: "TestB")); + + await Assert.That(queue.Items.Select(_ => $"{_.Name} {_.Patch.MemberName} {_.Patch.NewContent}")).IsEquivalentTo( + [ + "Sample.cs:20 TestB b, as it is now", + "Sample.cs:30 TestC c" + ]); + + // The other test's re-run moves its entry off the line, and the next run of this one + // finds the line free + queue = queue + .Enqueue(Patch(line: 40, content: "c", member: "TestC")) + .Enqueue(Patch(line: 30, content: "b, as it is now", member: "TestB")); + + await Assert.That(queue.Items.Select(_ => $"{_.Name} {_.Patch.MemberName}")).IsEquivalentTo( + [ + "Sample.cs:30 TestB", + "Sample.cs:40 TestC" + ]); + } + + /// + /// A patch with no entry of its own, arriving at a line another member's entry is under. The + /// two are not one call site, and folding them said they were: a second framework's patch for + /// one test became a conflicting variant of another test's snapshot. + /// + [Test] + public async Task APatchFromAnotherMemberTakesTheLineRatherThanJoiningTheEntry() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 30, content: "c", framework: "net8.0", member: "TestC")) + .Enqueue(Patch(line: 30, content: "x", framework: "net9.0", member: "TestX")); + + var entry = queue.Items.Single(); + await Assert.That(entry.Conflicted).IsFalse(); + await Assert.That(entry.Patch.MemberName).IsEqualTo("TestX"); + } + [Test] public async Task SettleRemoves() { @@ -128,6 +306,51 @@ public async Task SettleFromAPassingSiblingKeepsTheFailingSiblingsEntry() await Assert.That(settled).IsSameReferenceAs(queue); } + /// + /// After an accept higher in the file, a passing call sits on the line a later test's entry + /// was queued under. Its settle names that entry by key and nothing checked whose it was, so a + /// snapshot that was still failing left the queue. + /// + [Test] + public async Task SettleFromAnotherMemberAtAnEntrysLineLeavesTheEntry() + { + var queue = InlineQueue.Empty.Enqueue(Patch(line: 20, framework: "net10.0", member: "TestB")); + + var settled = queue.Settle(InlineKey.For("Sample.cs", 20), "net10.0", "TestA", "what the passing call holds"); + + await Assert.That(settled).IsSameReferenceAs(queue); + } + + /// + /// And the entry the settle was for is still found, by its member, under the line it was + /// queued at before the move. + /// + [Test] + public async Task SettleFromAnotherMemberAtAnEntrysLineStillSettlesItsOwn() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 15, content: "a", member: "TestA", expression: "\"was a\"")) + .Enqueue(Patch(line: 20, content: "b", member: "TestB")); + + var settled = queue.Settle(InlineKey.For("Sample.cs", 20), null, "TestA", "was a"); + + await Assert.That(settled.Items.Single().Patch.MemberName).IsEqualTo("TestB"); + } + + /// + /// A test renamed while its snapshot was pending: the member no longer matches, the line does, + /// and the call passes holding what the entry was waiting to become. + /// + [Test] + public async Task SettleFromARenamedMemberTakesTheEntryItsValueSettles() + { + var queue = InlineQueue.Empty + .Enqueue(Patch(line: 20, content: "new", member: "OldName")) + .Settle(InlineKey.For("Sample.cs", 20), null, "NewName", "new"); + + await Assert.That(queue.Count).IsEqualTo(0); + } + /// /// The moved line again, with the value the settle now carries. Passing with the value it was /// anchored to is the code under test producing that again. @@ -162,9 +385,11 @@ public async Task SettleByMemberTakesAnEntryWaitingForTheValue() [Test] public async Task SettleByMemberWithAValueChoosesAmongSeveral() { + // Two literals, since two calls in one member anchored to the same one are a single call + // site to the queue: see ARerunFromWhereItsCallSiteMovedToUpdatesItsEntry var queue = InlineQueue.Empty - .Enqueue(Patch(line: 42, content: "first", member: "MyTest")) - .Enqueue(Patch(line: 48, content: "second", member: "MyTest")) + .Enqueue(Patch(line: 42, content: "first", member: "MyTest", expression: "\"one\"")) + .Enqueue(Patch(line: 48, content: "second", member: "MyTest", expression: "\"two\"")) .Settle(InlineKey.For("Sample.cs", 807), null, "MyTest", "second"); await Assert.That(queue.Items.Single().Patch.NewContent).IsEqualTo("first"); @@ -190,8 +415,8 @@ public async Task ASettleValueRoundTrips() public async Task SettleLeavesAnAmbiguousMemberAlone() { var queue = InlineQueue.Empty - .Enqueue(Patch(line: 42, member: "MyTest")) - .Enqueue(Patch(line: 48, member: "MyTest")); + .Enqueue(Patch(line: 42, member: "MyTest", expression: "\"one\"")) + .Enqueue(Patch(line: 48, member: "MyTest", expression: "\"two\"")); await Assert.That(queue.Settle(InlineKey.For("Sample.cs", 807), null, "MyTest")) .IsSameReferenceAs(queue); @@ -362,6 +587,50 @@ await Assert.That(queue.Items.Select(_ => _.Status!)).IsEquivalentTo( ]); } + /// + /// The same bulk accept with the patches handed over in one call, which is what lets an + /// applier write a file once for all the snapshots in it. Nothing else about it differs: the + /// conflict is left out, the patches go over in queue order, and each outcome lands on its own + /// entry. + /// + [Test] + public async Task AcceptAllCanHandThePatchesOverTogether() + { + IReadOnlyList handed = []; + var queue = InlineQueue.Empty + .Enqueue(Patch("A.cs", 1)) + .Enqueue(Patch("B.cs", 2, content: "eight", framework: "net8.0")) + .Enqueue(Patch("B.cs", 2, content: "nine", framework: "net9.0")) + .Enqueue(Patch("C.cs", 3)) + .AcceptAll( + _ => + { + handed = _; + return [InlineApplyResult.Applied, InlineApplyResult.NotFound("no Verify or Throws call")]; + }, + out var message); + + await Assert.That(string.Join(", ", handed.Select(_ => $"{_.SourceFile}:{_.LineHint}"))).IsEqualTo("A.cs:1, C.cs:3"); + await Assert.That(string.Join(", ", queue.Items.Select(_ => _.Name))).IsEqualTo("B.cs:2, C.cs:3"); + await Assert.That(queue.Items[1].Status).IsEqualTo("C.cs:3 not written. no Verify or Throws call"); + await Assert.That(message).IsEqualTo("Accepted 1, 1 not written, 1 conflict needs review. C.cs:3 not written. no Verify or Throws call"); + } + + /// + /// An applier that answers for fewer patches than it was given has no outcome for some entry, + /// and guessing which would mark a snapshot accepted that nothing wrote. + /// + [Test] + public void AcceptAllRefusesResultsThatDoNotMatchThePatches() + { + var queue = InlineQueue.Empty + .Enqueue(Patch("A.cs", 1)) + .Enqueue(Patch("B.cs", 2)); + + Assert.Throws( + () => queue.AcceptAll(_ => [InlineApplyResult.Applied], out var message)); + } + /// /// The two phase form: find, apply outside the host's lock, complete. A re-run that replaced /// the patch while it was applying keeps its new entry, because the outcome describes the old diff --git a/src/DiffEngine.Tests/InlineStagingTests.cs b/src/DiffEngine.Tests/InlineStagingTests.cs index aff0b5e4c..f3e1e98b4 100644 --- a/src/DiffEngine.Tests/InlineStagingTests.cs +++ b/src/DiffEngine.Tests/InlineStagingTests.cs @@ -186,6 +186,75 @@ public async Task ClearScopedToAnOriginLeavesTheOtherFrameworkStaged() await Assert.That(project.StagedFiles().Count).IsEqualTo(3); } + /// + /// What a test run stages for itself goes through InlinePatchFile.Write, and its patch names no + /// framework: only the send to a queue owner ever stamped one. Every such trio was unlabeled, + /// and an unlabeled trio is cleared whichever framework asks, so scoping a clear to an origin + /// did nothing for the files a run with no viewer actually leaves. + /// + [Test] + public async Task ATrioAProcessStagesIsLabelledWithItsFramework() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + var patchFile = project.Stage("ThisFramework", Patch(source, "content")); + + await Assert.That(InlinePatchFile.TryRead(patchFile, out var read)).IsTrue(); + await Assert.That(read!.Framework).IsEqualTo(RuntimeMoniker.Current); + + // Another framework of the same project, passing where this one failed + var cleared = InlineStaging.Clear(source, 42, null, origin: "net0.0"); + + await Assert.That(cleared).IsEqualTo(0); + await Assert.That(File.Exists(patchFile)).IsTrue(); + } + + // A patch that says where it came from is staged as it says: a queue owner writing out what + // other processes sent it is not the framework those snapshots belong to + [Test] + public async Task ATrioStagedWithAFrameworkKeepsIt() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + var patchFile = project.Stage("OtherFramework", Patch(source, "content", framework: "net0.0")); + + await Assert.That(InlinePatchFile.TryRead(patchFile, out var read)).IsTrue(); + await Assert.That(read!.Framework).IsEqualTo("net0.0"); + } + + /// + /// The clear a passing run makes: its own framework's trio, and nobody else's. A caller has no + /// way to name its framework as DiffEngine labels it, so the one clear it could make was for + /// every framework, and the one that passed took the snapshot of the one still failing. + /// + [Test] + public async Task SettleTakesThisFrameworksTrioAndLeavesAnothers() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + var mine = project.Stage("ThisFramework", Patch(source, "from this one")); + var theirs = project.Stage("OtherFramework", Patch(source, "from another", framework: "net0.0")); + + var cleared = InlineStaging.Settle(source, 42, null); + + await Assert.That(cleared).IsEqualTo(1); + await Assert.That(File.Exists(mine)).IsFalse(); + await Assert.That(File.Exists(theirs)).IsTrue(); + } + + // The member and the value narrow a settle on disk as they narrow a clear + [Test] + public async Task SettleFindsACallSiteWhoseLineHasMovedByMember() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + var mine = project.Stage("ThisFramework", Patch(source, "new", line: 42, member: "MyTest")); + + await Assert.That(InlineStaging.Settle(source, 807, "MyTest", value: "what a sibling holds")).IsEqualTo(0); + await Assert.That(InlineStaging.Settle(source, 807, "MyTest", value: "new")).IsEqualTo(1); + await Assert.That(File.Exists(mine)).IsFalse(); + } + [Test] public async Task ClearFindsACallSiteWhoseLineHasMovedByMember() { @@ -217,6 +286,38 @@ public async Task ClearFromAPassingSiblingKeepsTheFailingSiblingsStagedTrio() await Assert.That(project.StagedFiles().Count).IsEqualTo(3); } + /// + /// The queue's rule for an entry under the key, on disk. A passing call that an accept above + /// it moved onto a line another test staged at is not that test's call site, and its clear + /// used to take the trio for being at the line. + /// + [Test] + public async Task ClearFromAnotherMemberAtATriosLineLeavesTheTrio() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + InlineStaging.Persist([new(Patch(source, "new", line: 42, member: "TestB"))]); + + var cleared = InlineStaging.Clear(source, 42, "TestA", value: "what the passing call holds"); + + await Assert.That(cleared).IsEqualTo(0); + await Assert.That(project.StagedFiles().Count).IsEqualTo(3); + } + + // A test renamed since it staged: the line still names it, and the value says it settled + [Test] + public async Task ClearFromARenamedMemberTakesTheTrioItsValueSettles() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + InlineStaging.Persist([new(Patch(source, "new", line: 42, member: "OldName"))]); + + var cleared = InlineStaging.Clear(source, 42, "NewName", value: "new"); + + await Assert.That(cleared).IsEqualTo(1); + await Assert.That(project.StagedFiles()).IsEmpty(); + } + [Test] public async Task ClearByMemberTakesACallSiteTheValueSettles() { @@ -271,6 +372,55 @@ public async Task ClearFindsStagingCreatedAfterAnEarlierLook() await Assert.That(project.StagedFiles()).IsEmpty(); } + /// + /// The same for what a test run stages for itself, which does not go through Persist. The walk + /// for staging directories is kept between clears, and a write through InlinePatchFile is one + /// of the two things that say it no longer stands. + /// + [Test] + public async Task ClearFindsWhatThisProcessStagedAfterAnEarlierLook() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + + await Assert.That(InlineStaging.Clear(source, 42, null)).IsEqualTo(0); + + var patchFile = project.Stage("ThisFramework", Patch(source, "content")); + + await Assert.That(InlineStaging.Clear(source, 42, null)).IsEqualTo(1); + await Assert.That(File.Exists(patchFile)).IsFalse(); + } + + /// + /// Another process can stage under the same obj, and nothing tells this one that it has: a + /// second framework of the same run, or a queue owner writing its queue out as it exits. A + /// directory it creates is found once the walk this process kept has had its life, which is + /// why that life is short. + /// + [Test] + public async Task ClearFindsWhatAnotherProcessStagedOnceTheLookIsStale() + { + var previous = InlineStaging.RecheckStagingAfter; + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + try + { + await Assert.That(InlineStaging.Clear(source, 42, null)).IsEqualTo(0); + + var patchFile = project.StageFromOutside("OtherProcess", Patch(source, "content")); + + // No life at all, which is every walk stale. Other tests may be clearing while this is + // set, and all it changes for them is that they walk too + InlineStaging.RecheckStagingAfter = TimeSpan.Zero; + await Assert.That(InlineStaging.Clear(source, 42, null)).IsEqualTo(1); + await Assert.That(File.Exists(patchFile)).IsFalse(); + } + finally + { + InlineStaging.RecheckStagingAfter = previous; + } + } + [Test] public async Task ClearLeavesAnotherSourceFileAlone() { @@ -358,6 +508,31 @@ public string Source(string name) return path; } + /// + /// Stages a patch the way a test run with no viewer does, through InlinePatchFile.Write, + /// rather than the way an exiting owner does. Returns the patch file. + /// + public string Stage(string name, InlinePatch patch) + { + var path = Path.Combine(directory, "obj", InlineStaging.DirectoryName, $"{name}.inlinepatch"); + InlinePatchFile.Write(path, patch); + return path; + } + + /// + /// The same file written with nothing of InlineStaging's or InlinePatchFile's involved in + /// the write, which is all this process ever knows of what another one staged. In a + /// framework's own intermediate directory, where a test run stages. + /// + public string StageFromOutside(string name, InlinePatch patch) + { + var staging = Path.Combine(directory, "obj", "Debug", "net0.0", InlineStaging.DirectoryName); + Directory.CreateDirectory(staging); + var path = Path.Combine(staging, $"{name}.inlinepatch"); + File.WriteAllText(path, InlinePatchFile.Build(patch)); + return path; + } + public IReadOnlyList StagedFiles() { var staging = Path.Combine(directory, "obj", InlineStaging.DirectoryName); diff --git a/src/DiffEngine.Tests/SourceScanTests.cs b/src/DiffEngine.Tests/SourceScanTests.cs new file mode 100644 index 000000000..dda0073d4 --- /dev/null +++ b/src/DiffEngine.Tests/SourceScanTests.cs @@ -0,0 +1,177 @@ +/// +/// The map every search in the patcher reads. What it has to be is one consistent account of a +/// file: each offset is code or is inside exactly one comment or literal, and that span is found +/// from its start by a search stepping forwards and from its end by one stepping back. The spans +/// are found by searching sorted lists behind a map of which offsets are code, so the three have +/// to agree at every offset, and these walk every offset to see that they do. +/// +public class SourceScanTests +{ + const string csharp = + """" + // a line comment + #warning said "like this" + class C /* a block comment */ + { + char c = '"'; + string s = "text // not a comment"; + string v = @"verbatim ""quoted"" \"; + string r = """ + raw "quoted" text + """; + string i = $"hole {Call("inner")} end"; + void M() => Verify(value) // trailing + .Snapshot("old"); + } + """"; + + const string fsharp = + """" + // a line comment + #if DEBUG + (* a block (* nested *) comment *) + let c = 'x' + let f (x: 'T) = x + let s = "text (* not a comment *)" + let v = @"verbatim ""quoted""" + let r = """triple "quoted" """ + let m () = Verify(value).Snapshot("old") // trailing + """"; + + [Test] + public async Task CSharpSpansAreFoundFromBothEnds() + { + var (spans, comments, problems) = Walk(SourceLanguage.CSharp, csharp); + + await Assert.That(problems).IsEmpty(); + // Two comments, a directive and a trailing comment, and six literals: the hole and the + // literal inside it belong to the string they are in + await Assert.That(comments).IsEqualTo(4); + await Assert.That(spans).IsEqualTo(10); + } + + [Test] + public async Task FSharpSpansAreFoundFromBothEnds() + { + var (spans, comments, problems) = Walk(SourceLanguage.FSharp, fsharp); + + await Assert.That(problems).IsEmpty(); + // The tick of a type parameter is code, so five literals and four comments + await Assert.That(comments).IsEqualTo(4); + await Assert.That(spans).IsEqualTo(9); + } + + /// + /// Source nobody arranged: the patcher's own tests, which are thousands of lines of literals + /// holding code, comments holding literals, and raw strings holding both. + /// + [Test] + [Arguments("InlinePatcherTests.cs")] + [Arguments("InlinePatcherFsTests.cs")] + [Arguments("CsStringLiteralTests.cs")] + [Arguments("FsCompilerRoundTripTests.cs")] + public async Task TheSuitesOwnSourceIsOneConsistentMap(string file) + { + var source = await File.ReadAllTextAsync(Path.Combine(SourceDirectory, file)); + + var (spans, _, problems) = Walk(SourceLanguage.CSharp, source); + + await Assert.That(problems).IsEmpty(); + await Assert.That(spans).IsGreaterThan(10); + } + + static string SourceDirectory { get; } = Path.GetDirectoryName(GetSourceFile())!; + + static string GetSourceFile([CallerFilePath] string path = "") => path; + + /// + /// Steps through the source the way a search does, over spans and one offset at a time across + /// code, and asks every question the scan answers at each place it stops. + /// + static (int spans, int comments, List problems) Walk(SourceLanguage language, string source) + { + using var scan = language.Scan(source); + var problems = new List(); + var ends = new HashSet(); + var spans = 0; + var comments = 0; + var index = 0; + while (index < source.Length) + { + if (!scan.TryGetSkip(index, out var end)) + { + if (!scan.IsCode(index)) + { + problems.Add($"{index} is not code and no span starts there"); + } + + if (scan.TryGetCommentSkip(index, out _)) + { + problems.Add($"{index} starts a comment and no span"); + } + + index++; + continue; + } + + spans++; + ends.Add(end); + if (end <= index || + end > source.Length) + { + problems.Add($"the span at {index} ends at {end}"); + break; + } + + for (var offset = index; offset < end; offset++) + { + if (scan.IsCode(offset)) + { + problems.Add($"{offset} is code inside the span at {index}"); + } + + if (offset > index && + scan.TryGetSkip(offset, out _)) + { + problems.Add($"a span starts at {offset} inside the span at {index}"); + } + } + + if (!scan.TryGetSkipEndingAt(end, out var start) || + start != index) + { + problems.Add($"the span at {index} is not found from its end at {end}"); + } + + var comment = scan.TryGetCommentSkip(index, out var commentEnd); + if (comment) + { + comments++; + if (commentEnd != end) + { + problems.Add($"the comment at {index} ends at {commentEnd} and the span at {end}"); + } + } + + if (scan.TryGetCommentEndingAt(end, out var commentStart) != comment || + comment && commentStart != index) + { + problems.Add($"the span at {index} is a comment from one end and not from the other"); + } + + index = end; + } + + // And nothing ends where the walk found no span ending + for (var offset = 0; offset <= source.Length + 1; offset++) + { + if (!ends.Contains(offset) && + scan.TryGetSkipEndingAt(offset, out _)) + { + problems.Add($"a span ends at {offset} that no span was found to"); + } + } + + return (spans, comments, problems); + } +} diff --git a/src/DiffEngine.Tests/TextDiffTests.cs b/src/DiffEngine.Tests/TextDiffTests.cs index c2aac0b71..d3ed7c638 100644 --- a/src/DiffEngine.Tests/TextDiffTests.cs +++ b/src/DiffEngine.Tests/TextDiffTests.cs @@ -304,6 +304,253 @@ public async Task LargeWithFewEdits() await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Added)).IsEqualTo(2); } + /// + /// A larger alphabet than the lines drawn from it, so most lines are on one side only. Those + /// are taken out before Myers runs, and what comes back has to be the same minimal diff, with + /// every flag back on the line it was for. + /// + [Test] + public void MatchesLongestCommonSubsequenceWhenMostLinesAreOnOneSide() + { + var random = new Random(3); + for (var iteration = 0; iteration < 3000; iteration++) + { + var alphabet = 2 + iteration % 60; + var expected = RandomLines(random, random.Next(0, 40), alphabet); + var received = RandomLines(random, random.Next(0, 40), alphabet); + AssertMinimal(expected, received); + } + } + + /// + /// No line survives a change of indentation, which made this the slowest input there was: the + /// search went through every line as an edit, and 40,000 lines a side took four seconds. + /// + [Test] + public async Task LargeWithNothingInCommon() + { + var expected = Enumerable.Range(0, 30000).Select(_ => $" \"property{_}\": {_},").ToList(); + var received = expected.Select(_ => " " + _).ToList(); + + var lines = TextDiff.Compute(string.Join("\n", expected), string.Join("\n", received)); + + await Assert.That(lines.Count).IsEqualTo(60000); + await Assert.That(lines.Take(30000).All(_ => _.Kind == DiffLineKind.Removed)).IsTrue(); + await Assert.That(lines.Skip(30000).All(_ => _.Kind == DiffLineKind.Added)).IsTrue(); + } + + /// + /// Every line on both sides and none where it was, which leaves nothing to take out first and + /// is long enough for the search to settle rather than finish. What comes back need not be + /// minimal; it has to be a diff, each side its own lines in order. + /// + [Test] + public async Task LargeInAnotherOrder() + { + var random = new Random(4); + var expected = Enumerable.Range(0, 30000).Select(_ => $"line {_}").ToList(); + var received = expected.OrderBy(_ => random.Next()).ToList(); + + var lines = TextDiff.Compute(string.Join("\n", expected), string.Join("\n", received)); + + await Assert.That(lines.Where(_ => _.Kind != DiffLineKind.Added).Select(_ => _.Text).SequenceEqual(expected)).IsTrue(); + await Assert.That(lines.Where(_ => _.Kind != DiffLineKind.Removed).Select(_ => _.Text).SequenceEqual(received)).IsTrue(); + } + + /// + /// The worst a text of a few thousand lines can be, every line in another place, is within + /// what a diff may spend, so it is still minimal. Every line being on both sides once, the + /// most that can stay unchanged is the longest run of them still in their old order. + /// + [Test] + public async Task AFewThousandLinesInAnotherOrderAreStillDiffedMinimally() + { + var random = new Random(9); + var order = Enumerable.Range(0, 5000).OrderBy(_ => random.Next()).ToArray(); + var expected = Enumerable.Range(0, 5000).Select(_ => $"line {_}").ToList(); + var received = order.Select(_ => expected[_]).ToList(); + + var lines = TextDiff.Compute(string.Join("\n", expected), string.Join("\n", received)); + + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Unchanged)).IsEqualTo(LongestIncreasingRun(order)); + await Assert.That(lines.Where(_ => _.Kind != DiffLineKind.Added).Select(_ => _.Text).SequenceEqual(expected)).IsTrue(); + await Assert.That(lines.Where(_ => _.Kind != DiffLineKind.Removed).Select(_ => _.Text).SequenceEqual(received)).IsTrue(); + } + + // Patience sorting: the piles' tops stay sorted, and there are as many piles as the longest + // increasing run is long. + static int LongestIncreasingRun(int[] values) + { + var tops = new List(); + foreach (var value in values) + { + var pile = tops.BinarySearch(value); + if (pile < 0) + { + pile = ~pile; + } + + if (pile == tops.Count) + { + tops.Add(value); + } + else + { + tops[pile] = value; + } + } + + return tops.Count; + } + + /// + /// And so is a long text with a large block moved, which is one deep search and little else: + /// the block is what changes, and the rest of the text is found where it now is. What a diff + /// may spend is counted in work rather than in lines so that this is not given up on for + /// being long. + /// + [Test] + public async Task ABlockMovedInALongTextIsStillFound() + { + var expected = Enumerable.Range(0, 60000).Select(_ => $"line {_}").ToList(); + var received = expected.Skip(5000).Concat(expected.Take(5000)).ToList(); + + var lines = TextDiff.Compute(string.Join("\n", expected), string.Join("\n", received)); + + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Unchanged)).IsEqualTo(55000); + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Removed)).IsEqualTo(5000); + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Added)).IsEqualTo(5000); + } + + /// + /// A block moved further than a search may go deep is on a diagonal the search never reaches, + /// so the search settles having passed nothing. Where it splits then is where the start of one + /// text is in the other, and the rest of the text is found unchanged whichever way the block + /// went. Split at the furthest point reached instead, this came out as the 88,000 lines that + /// had not moved being removed and added again. + /// + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task ABlockMovedFurtherThanASearchGoesIsStillFound(bool toTheEnd) + { + var expected = Enumerable.Range(0, 100000).Select(_ => $"line {_}").ToList(); + var moved = toTheEnd ? 12000 : 88000; + var received = expected.Skip(moved).Concat(expected.Take(moved)).ToList(); + + var lines = TextDiff.Compute(string.Join("\n", expected), string.Join("\n", received)); + + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Unchanged)).IsEqualTo(88000); + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Removed)).IsEqualTo(12000); + await Assert.That(lines.Count(_ => _.Kind == DiffLineKind.Added)).IsEqualTo(12000); + await Assert.That(lines.Where(_ => _.Kind != DiffLineKind.Added).Select(_ => _.Text).SequenceEqual(expected)).IsTrue(); + await Assert.That(lines.Where(_ => _.Kind != DiffLineKind.Removed).Select(_ => _.Text).SequenceEqual(received)).IsTrue(); + } + + /// + /// The same on sequences small enough to check in bulk: runs of elements, the runs in another + /// order on the other side, and searches with nothing to spend, so every one settles and + /// looks for where the sequences line up. Whatever it splits at, what is left unchanged on + /// each side has to be the same elements in the same order. + /// + [Test] + public void ASearchThatSettlesOnMovedRunsStillDiffsCorrectly() + { + var random = new Random(10); + for (var iteration = 0; iteration < 3000; iteration++) + { + var runs = Enumerable.Range(0, random.Next(2, 7)) + .Select(_ => Enumerable.Range(_ * 1000, random.Next(1, 40)).ToArray()) + .ToList(); + var a = runs.SelectMany(_ => _).ToArray(); + var b = runs.OrderBy(_ => random.Next()).SelectMany(_ => _).ToArray(); + // Some edits as well, so a run is not always whole on both sides + for (var edit = random.Next(0, 4); edit > 0 && b.Length > 0; edit--) + { + b[random.Next(b.Length)] = a[random.Next(a.Length)]; + } + + var minimumDepth = 1 + iteration % 5; + var changedA = new bool[a.Length]; + var changedB = new bool[b.Length]; + + MyersDiff.Diff(a, b, changedA, changedB, 0, minimumDepth); + + var unchangedA = a.Where((_, index) => !changedA[index]); + var unchangedB = b.Where((_, index) => !changedB[index]); + if (!unchangedA.SequenceEqual(unchangedB)) + { + Assert.Fail($"minimum: {minimumDepth} a: {string.Join(",", a)} b: {string.Join(",", b)}"); + } + } + } + + /// + /// Searches made to settle after an edit or a few, on sequences small enough to check. The + /// elements left unchanged on each side have to be the same elements in the same order, which + /// is all that makes a diff correct; that there are as many as there could be is what settling + /// gives up. + /// + [Test] + public void ASearchThatSettlesStillDiffsCorrectly() + { + var random = new Random(5); + for (var iteration = 0; iteration < 20000; iteration++) + { + var alphabet = 1 + iteration % 6; + var a = RandomIds(random, random.Next(0, 60), alphabet); + var b = RandomIds(random, random.Next(0, 60), alphabet); + // From nothing to spend, where every search settles at its minimum, to enough for the + // first few searches and not the rest + var budget = iteration % 40; + var minimumDepth = 1 + iteration % 5; + var changedA = new bool[a.Length]; + var changedB = new bool[b.Length]; + + MyersDiff.Diff(a, b, changedA, changedB, budget, minimumDepth); + + var unchangedA = a.Where((_, index) => !changedA[index]); + var unchangedB = b.Where((_, index) => !changedB[index]); + if (!unchangedA.SequenceEqual(unchangedB)) + { + Assert.Fail($"budget: {budget} minimum: {minimumDepth} a: {string.Join(",", a)} b: {string.Join(",", b)}"); + } + } + } + + /// + /// The same sequences with more to spend than any of them needs are diffed as they always + /// were, so the budget costs nothing where it does not apply. + /// + [Test] + public void ABudgetNoDiffSpendsChangesNothing() + { + var random = new Random(6); + for (var iteration = 0; iteration < 5000; iteration++) + { + var alphabet = 1 + iteration % 6; + var a = RandomIds(random, random.Next(0, 60), alphabet); + var b = RandomIds(random, random.Next(0, 60), alphabet); + var changedA = new bool[a.Length]; + var changedB = new bool[b.Length]; + + MyersDiff.Diff(a, b, changedA, changedB, long.MaxValue, 1); + + var unchanged = changedA.Count(_ => !_); + var longest = LongestCommonSubsequence( + a.Select(_ => _.ToString()).ToList(), + b.Select(_ => _.ToString()).ToList()); + if (unchanged != longest || + changedB.Count(_ => !_) != longest) + { + Assert.Fail($"a: {string.Join(",", a)} b: {string.Join(",", b)}"); + } + } + } + + static int[] RandomIds(Random random, int count, int alphabet) => + Enumerable.Range(0, count).Select(_ => random.Next(alphabet)).ToArray(); + // Never an empty line: a list of one empty line joins to empty text, which is no lines. static List RandomLines(Random random, int count, int alphabet) => Enumerable.Range(0, count).Select(_ => "l" + random.Next(alphabet)).ToList(); diff --git a/src/DiffEngine.Tests/ToolProcessTests.cs b/src/DiffEngine.Tests/ToolProcessTests.cs new file mode 100644 index 000000000..606ebe0fb --- /dev/null +++ b/src/DiffEngine.Tests/ToolProcessTests.cs @@ -0,0 +1,224 @@ +using System.IO.Pipes; + +/// +/// What a diff tool takes from the test host that starts it. +/// +/// A test host's output goes to a pipe that dotnet test reads until every writer has closed +/// it. A tool started through ShellExecute inherits nothing, and most tools are declared that way. +/// The ones declared without it - the Word and Excel comparers, VS Code, Cursor - were handed every +/// inheritable handle the host had, the write end of that pipe among them. So a run that opened +/// one of them did not return until the process it had started was gone, and the Word comparer's +/// stays until Word is closed. +/// +/// +/// The real tools are never started here. A console program, a windowed one and a script stand in +/// for them: ping, FakeDiffTool, and a script that runs ping. +/// +/// +[NotInParallel] +[RunOn(TUnit.Core.Enums.OS.Windows)] +public class ToolProcessTests +{ + /// + /// The pipe is the one dotnet test reads a host's output from: its write end can be + /// inherited, and reading it only ends once every copy of that end is closed. This process + /// closes its own, so a read that does not end is a tool holding one. + /// + [Test] + [Arguments("console")] + [Arguments("windowed")] + [Arguments("script")] + public async Task AToolDeclaredWithoutShellExecuteInheritsNothing(string kind) + { + var (exe, arguments) = StandIn(kind); + using var pipe = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable); + + var processId = DiffRunner.LaunchProcess(Tool(exe), arguments); + + using var process = Process.GetProcessById(processId); + var read = Task.Run(() => pipe.ReadByte()); + try + { + pipe.DisposeLocalCopyOfClientHandle(); + var ended = await Task.WhenAny(read, Task.Delay(TimeSpan.FromSeconds(2))) == read; + + // Still running, or the pipe ending says nothing about what a running tool holds + await Assert.That(process.HasExited).IsFalse(); + await Assert.That(ended).IsTrue(); + } + finally + { + // Before the pipe is disposed, so a read that a tool is holding open ends first + KillTree(processId); + await Task.WhenAny(read, Task.Delay(TimeSpan.FromSeconds(10))); + } + } + + /// + /// A tool is found again, to be refreshed or killed, by the command line it was started with: + /// the quoted path and then the arguments, as a tool started any other way has. + /// + [Test] + public async Task AToolDeclaredWithoutShellExecuteIsStillFoundByItsCommand() + { + var (exe, arguments) = StandIn("windowed"); + + var processId = DiffRunner.LaunchProcess(Tool(exe), arguments); + + try + { + var command = ProcessCleanup.FindAll().Single(_ => _.Process == processId).Command; + + await Assert.That(command).IsEqualTo($"\"{exe}\" {arguments}"); + } + finally + { + KillTree(processId); + } + } + + /// + /// VS Code's launcher is a script in a folder with spaces in its name, and it is handed two + /// quoted paths, which is the arrangement the command interpreter is known for mangling. The + /// command line is the one that was always built, so each path arrives whole. + /// + [Test] + public async Task AScriptInAFolderWithSpacesIsGivenItsArguments() + { + var (exe, arguments) = StandIn("script"); + var folder = Path.GetDirectoryName(exe)!; + var written = Path.Combine(folder, "arguments.txt"); + File.Delete(written); + + var processId = DiffRunner.LaunchProcess(Tool(exe), arguments); + + try + { + for (var attempt = 0; attempt < 40 && !File.Exists(written); attempt++) + { + await Task.Delay(250); + } + + // Once more, for a file that exists a moment before its line is in it + await Task.Delay(250); + await Assert.That(File.ReadAllText(written).Trim()) + .IsEqualTo($"{Path.Combine(folder, "Sample received.txt")}|{Path.Combine(folder, "Sample verified.txt")}"); + } + finally + { + KillTree(processId); + } + } + + [Test] + public async Task TheCommandLineIsTheQuotedPathThenTheArguments() + { + await Assert.That(WindowsProcess.CommandLine(@"C:\Program Files\Tool\tool.exe", "\"a.txt\" \"b.txt\"")) + .IsEqualTo("\"C:\\Program Files\\Tool\\tool.exe\" \"a.txt\" \"b.txt\""); + // Nothing trailing, which would be an argument to some programs + await Assert.That(WindowsProcess.CommandLine(@"C:\Tool\tool.exe", "")) + .IsEqualTo("\"C:\\Tool\\tool.exe\""); + await Assert.That(WindowsProcess.CommandLine("\"C:\\Tool\\tool.exe\"", "a")) + .IsEqualTo("\"C:\\Tool\\tool.exe\" a"); + } + + /// + /// A tool that cannot be started is still reported the way it was, with the command that + /// failed, rather than as whatever Windows said about it. + /// + [Test] + public async Task AToolThatCannotBeStartedSaysWhichOne() + { + var exe = Path.Combine(TempDirectory, "not-an-executable.exe"); + File.WriteAllText(exe, "not an executable"); + + var exception = Assert.Throws(() => DiffRunner.LaunchProcess(Tool(exe), "\"a.txt\" \"b.txt\"")); + + await Assert.That(exception.Message).Contains("Failed to launch diff tool."); + await Assert.That(exception.Message).Contains(exe); + } + + /// + /// As the Word comparer is declared: no ShellExecute, and no window. + /// + static ResolvedTool Tool(string exe) => + new( + name: "StandIn", + exePath: exe, + launchArguments: new( + Left: (temp, target) => $"\"{target}\" \"{temp}\"", + Right: (temp, target) => $"\"{temp}\" \"{target}\""), + isMdi: false, + autoRefresh: false, + binaryExtensions: [], + requiresTarget: false, + supportsText: true, + useShellExecute: false, + createNoWindow: true); + + /// + /// Something of each kind that outlives the two seconds a test waits, and the arguments that + /// make it do so. + /// + static (string Exe, string Arguments) StandIn(string kind) + { + const string wait = "-n 8 127.0.0.1"; + var ping = Path.Combine(Environment.SystemDirectory, "PING.EXE"); + switch (kind) + { + case "console": + return (ping, wait); + case "windowed": + // It sleeps for five seconds whatever it is given. Two paths, because that is the + // shape of command line ProcessCleanup lists + return (FakeDiffTool.Exe, $"\"{Path.Combine(TempDirectory, "Sample.received.txt")}\" \"{Path.Combine(TempDirectory, "Sample.verified.txt")}\""); + case "script": + // In a folder with a space in its name, as VS Code's launcher is, and it writes + // down the two paths it was given before it waits + var folder = Path.Combine(TempDirectory, "with a space"); + Directory.CreateDirectory(folder); + var script = Path.Combine(folder, "stand-in.cmd"); + File.WriteAllText(script, $"@echo %~1^|%~2> \"%~dp0arguments.txt\"\r\n@\"{ping}\" {wait} > nul\r\n"); + return (script, $"\"{Path.Combine(folder, "Sample received.txt")}\" \"{Path.Combine(folder, "Sample verified.txt")}\""); + default: + throw new($"Unknown kind: {kind}"); + } + } + + /// + /// The process and whatever it started, since the script's wait is a child of its own. + /// + static void KillTree(int processId) + { + using var kill = Process.Start( + new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "taskkill.exe"), $"/PID {processId} /T /F") + { + UseShellExecute = false, + CreateNoWindow = true + })!; + kill.WaitForExit(10000); + } + + static string TempDirectory { get; } = CreateDirectory(); + + static string CreateDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), $"DiffEngine.ToolProcess.{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + return directory; + } + + [After(Class)] + public static void DeleteDirectory() + { + try + { + Directory.Delete(TempDirectory, true); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + // A stand-in on its way out holds the script it was started from for a moment + } + } +} diff --git a/src/DiffEngine.Tests/ViewerClientUnownedTests.cs b/src/DiffEngine.Tests/ViewerClientUnownedTests.cs index 32bff60dd..8aa105661 100644 --- a/src/DiffEngine.Tests/ViewerClientUnownedTests.cs +++ b/src/DiffEngine.Tests/ViewerClientUnownedTests.cs @@ -157,6 +157,147 @@ public async Task AskingIsTheDefault() await Assert.That(owner.Heard.Count).IsEqualTo(2); } + /// + /// The first telling send of a process meets the refusal with nothing remembered, and paid for + /// it: two seconds, once per test process, and again each time the memory ran out. The + /// operating system knows nobody is listening without anything being connected, so where that + /// refusal is slow it is asked first. + /// + /// A bound of half what the connect takes to be refused, around something that takes well + /// under a millisecond. + /// + /// + [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] + public async Task APortNobodyHoldsIsNotWaitedOn() + { + var port = FreePort(); + + var elapsed = Stopwatch.StartNew(); + var sent = ViewerClient.TrySend(settle, out _, port, skipIfUnowned: true); + elapsed.Stop(); + + await Assert.That(sent).IsFalse(); + await Assert.That(elapsed.Elapsed).IsLessThan(TimeSpan.FromSeconds(1)); + await Assert.That(ViewerClient.FoundUnowned(port)).IsTrue(); + } + + /// + [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] + public async Task APortNobodyHoldsIsNotWaitedOnAsync() + { + var port = FreePort(); + + var elapsed = Stopwatch.StartNew(); + var outcome = await ViewerClient.SendAsync(settle, Cancel.None, port, skipIfUnowned: true); + elapsed.Stop(); + + await Assert.That(outcome).IsEqualTo(SendOutcome.NoOwner); + await Assert.That(elapsed.Elapsed).IsLessThan(TimeSpan.FromSeconds(1)); + await Assert.That(ViewerClient.FoundUnowned(port)).IsTrue(); + } + + /// + /// The table is a way of not waiting, never the authority. Where it cannot be read the + /// connect answers, as it did before anything asked the table: an owner is still found, and + /// a port with nobody on it is still reported that way. + /// + [Test] + public async Task ATableThatCannotBeReadLeavesTheConnectToAnswer() + { + using var owner = new Owner(); + var dead = FreePort(); + using var unreadable = new Lookup(owner.Port, dead) + { + Unreadable = true + }; + + await Assert.That(ViewerClient.IsOwned(owner.Port)).IsTrue(); + await Assert.That(ViewerClient.TrySend(settle, out _, owner.Port, skipIfUnowned: true)).IsTrue(); + await Assert.That(ViewerClient.IsOwned(dead)).IsFalse(); + } + + /// + /// Reading the table costs more than the connect it stands in front of, and a green run with a + /// tray answering settles thousands of times. So a port that accepted a connection a moment + /// ago is connected to again without asking, and only one that has been quiet is looked up. + /// + [Test] + [RunOn(TUnit.Core.Enums.OS.Windows)] + public async Task AnOwnerThatJustAnsweredIsNotLookedUpAgain() + { + var trustOwnerFor = ViewerClient.TrustOwnerFor; + using var owner = new Owner(); + using var lookup = new Lookup(owner.Port); + try + { + // Long enough that nothing this machine is doing can run it out between two sends + ViewerClient.TrustOwnerFor = TimeSpan.FromMinutes(1); + + // Nothing known about the port yet, so this one asks + await Assert.That(ViewerClient.TrySend(settle, out _, owner.Port, skipIfUnowned: true)).IsTrue(); + for (var index = 0; index < 3; index++) + { + await Assert.That(ViewerClient.TrySend(settle, out _, owner.Port, skipIfUnowned: true)).IsTrue(); + } + + await Assert.That(ViewerClient.IsOwned(owner.Port)).IsTrue(); + await Assert.That(lookup.Asked).IsEqualTo(1); + + ViewerClient.TrustOwnerFor = TimeSpan.Zero; + await Assert.That(ViewerClient.TrySend(settle, out _, owner.Port, skipIfUnowned: true)).IsTrue(); + await Assert.That(lookup.Asked).IsEqualTo(2); + await Assert.That(owner.Heard.Count).IsEqualTo(5); + } + finally + { + ViewerClient.TrustOwnerFor = trustOwnerFor; + } + } + + /// + /// Stands in front of the listener table for the ports a test names, counting how often each + /// was asked about and, when told to, failing the way a table that cannot be read does. Every + /// other port goes through untouched, since the tests beside this one are asking about theirs + /// at the same time. + /// + sealed class Lookup : IDisposable + { + readonly Func previous = ListenerTable.Lookup; + readonly int[] ports; + int asked; + + public Lookup(params int[] ports) + { + this.ports = ports; + ListenerTable.Lookup = Answer; + } + + public bool Unreadable { get; init; } + + public int Asked => Volatile.Read(ref asked); + + bool Answer(int port) + { + if (!ports.Contains(port)) + { + return previous(port); + } + + Interlocked.Increment(ref asked); + if (Unreadable) + { + throw new System.Net.NetworkInformation.NetworkInformationException(); + } + + return previous(port); + } + + public void Dispose() => + ListenerTable.Lookup = previous; + } + /// /// A port that is free right now, found by binding and releasing it. /// @@ -272,7 +413,7 @@ public async Task ANonViewerOnThePortIsReportedRatherThanTakenForAnOwner() () => { launches++; - return Task.FromResult(true); + return Task.FromResult(null); }, Cancel.None, isOwned: () => ViewerClient.IsOwned(port)); diff --git a/src/DiffEngine.Tests/ViewerContractTests.cs b/src/DiffEngine.Tests/ViewerContractTests.cs new file mode 100644 index 000000000..b1bea7d6a --- /dev/null +++ b/src/DiffEngine.Tests/ViewerContractTests.cs @@ -0,0 +1,158 @@ +/// +/// Which installed copy of the viewer resolves when there is more than one. +/// +/// A globally installed tool and the copy shipped with a tray are looked for ahead of the bundled +/// one, and either can be older than the library about to launch it. A viewer from before 20.5.0 +/// exits on the --payload an inline launch names, so with one of those first in the order +/// no inline snapshot reached a viewer, whatever newer copy sat behind it. +/// +/// +/// The stand-ins are an empty file for the executable and, beside it, an assembly whose version +/// is the one read: an executable with no version of its own is every apphost off Windows, so +/// the same arrangement reads the same way on all three platforms. +/// +/// +public class ViewerContractTests : + IDisposable +{ + [Test] + [Arguments("20.5.0+de0e3be5177093d6e915f2fa27970455f29dfc53")] + [Arguments("20.5")] + [Arguments("20.6.0")] + [Arguments("21.0.0-beta.1+0cb4f830cb45e9a87aabfd16928ac27eb0fced94")] + public async Task ACopyFromTwentyFiveOnMeetsIt(string productVersion) => + await Assert.That(ViewerContract.IsMetBy(productVersion)).IsTrue(); + + [Test] + [Arguments("20.4.0+80a966e0b1add0f6f65c16f42db594a7eda972d1")] + [Arguments("20.3.1+65e8e790c676ae4cf6d98783f35345db2a926e47")] + [Arguments("20.0.0-beta.30+5c6d11e917702eb170a505647a4ce6cda8576b21")] + [Arguments("19.3.3")] + public async Task ACopyFromBeforeItDoesNot(string productVersion) => + await Assert.That(ViewerContract.IsMetBy(productVersion)).IsFalse(); + + /// + /// A copy that does not say what it is is not passed over on a guess. + /// + [Test] + public async Task ACopyWithNoVersionIsTakenToMeetIt() + { + await Assert.That(ViewerContract.IsMetBy(null)).IsTrue(); + await Assert.That(ViewerContract.IsMetBy("")).IsTrue(); + await Assert.That(ViewerContract.IsMetBy("a local build")).IsTrue(); + } + + /// + /// The read itself, off an assembly this build produced, so the version is one known here. + /// + [Test] + public async Task TheVersionIsReadFromTheAssemblyBesideAnExecutableThatHasNone() + { + var viewer = Copy("current", typeof(DiffRunner)); + + var version = ViewerContract.ProductVersion(viewer); + + await Assert.That(version).IsNotNull(); + await Assert.That(version!).StartsWith(FallbackViewerDirectories.LibraryVersion()!, StringComparison.OrdinalIgnoreCase); + } + + [Test] + public async Task AnOlderCopyIsPassedOverForANewerOneBehindIt() + { + Copy("tray", typeof(AllFiles)); + var bundled = Copy("bundled", typeof(DiffRunner)); + + await Assert.That(Resolve("tray", "bundled")).IsEqualTo(bundled); + } + + /// + /// The order is otherwise the one written: a copy that meets the contract is not passed over + /// for a later one that also does. + /// + [Test] + public async Task TheFirstCopyThatMeetsItIsTheOneTaken() + { + var global = Copy("global", typeof(DiffRunner)); + Copy("bundled", typeof(DiffRunner)); + + await Assert.That(Resolve("global", "bundled")).IsEqualTo(global); + } + + /// + /// Passed over, not refused. An older viewer still takes a pair and a delete, which is better + /// than no viewer, and the launch gate reports the inline launch it cannot take. + /// + [Test] + public async Task AnOlderCopyIsStillFoundWhenItIsTheOnlyOne() + { + var tray = Copy("tray", typeof(AllFiles)); + + await Assert.That(Resolve("tray", "bundled")).IsEqualTo(tray); + } + + /// + /// Only the viewer is chosen between this way. Every other tool is started with the two + /// paths its own definition gives it, and which copy of it is found is none of this + /// library's business. + /// + [Test] + public async Task OnlyTheViewerIsChosenBetween() + { + await Assert.That(DiffTools.PreferredCopy(DiffTool.DiffEngineViewer)).IsNotNull(); + await Assert.That(DiffTools.PreferredCopy(DiffTool.BeyondCompare)).IsNull(); + await Assert.That(DiffTools.PreferredCopy(null)).IsNull(); + } + + /// + /// Through the resolution DiffTools runs for the viewer, over search directories of the test's + /// own in the order given. + /// + string? Resolve(params string[] directories) + { + var launchArguments = new LaunchArguments( + Left: (temp, target) => $"\"{target}\" \"{temp}\"", + Right: (temp, target) => $"\"{temp}\" \"{target}\""); + var settings = new OsSettings( + viewerName, + launchArguments, + directories.Select(_ => Path.Combine(root, _)).ToArray()); + + OsSettingsResolver.Resolve( + "ViewerContractTests", + new(settings, settings, settings), + out var path, + out _, + DiffTools.PreferredCopy(DiffTool.DiffEngineViewer)); + return path; + } + + /// + /// A copy of the viewer whose version is that of the assembly + /// is in. EmptyFiles stands in for an older one: it is a dependency of the library, so it is + /// always beside the tests, and it is a long way short of version 20. + /// + string Copy(string directory, Type versioned) + { + var full = Path.Combine(root, directory); + Directory.CreateDirectory(full); + var viewer = Path.Combine(full, viewerName); + File.WriteAllText(viewer, ""); + File.Copy(versioned.Assembly.Location, Path.ChangeExtension(viewer, ".dll")); + return viewer; + } + + // Not the viewer's own name, which PATH is searched for after the directories: on a machine + // with the viewer installed as a tool that copy would join the ones under test + static readonly string viewerName = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ViewerContractStandIn.exe" : "ViewerContractStandIn"; + + readonly string root = Path.Combine(Path.GetTempPath(), $"DiffEngine.ViewerContract.{Guid.NewGuid():N}"); + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, true); + } + } +} diff --git a/src/DiffEngine.Tests/ViewerLaunchGateTests.cs b/src/DiffEngine.Tests/ViewerLaunchGateTests.cs index 8d109119a..5c0b526e3 100644 --- a/src/DiffEngine.Tests/ViewerLaunchGateTests.cs +++ b/src/DiffEngine.Tests/ViewerLaunchGateTests.cs @@ -74,7 +74,7 @@ public async Task AViewerThatNeverAnswersDoesNotHoldTheGateForever() launch: () => { Interlocked.Increment(ref starts); - return true; + return Running(); }, isOwned: () => false, canLaunch: () => true)))); @@ -98,7 +98,7 @@ public async Task ALaunchThatCouldNotStartIsReportedRatherThanWaitedOn() { var outcome = ViewerLaunchGate.Launch( retry: () => true, - launch: () => false, + launch: () => null, isOwned: () => false, canLaunch: () => true); @@ -110,6 +110,142 @@ public async Task ALaunchThatCouldNotStartIsReportedRatherThanWaitedOn() } } + /// + /// A viewer that starts and cannot take the launch is no more a launch than one that never + /// started. A copy from before 20.5.0 exits with 2 on the --payload it does not know, and an + /// apphost with no runtime to run on exits before any of the viewer's code does. Either was + /// waited on for the whole of BindWait with the gate held, and then reported as launched - so + /// an inline snapshot was called queued, and the caller staged nothing, when no process had it. + /// + [Test] + public async Task AViewerThatExitsWithAFailureIsReportedRatherThanWaitedOn() + { + var previous = ViewerLaunchGate.BindWait; + // Long enough that waiting it out would show in this test's duration. + ViewerLaunchGate.BindWait = TimeSpan.FromSeconds(20); + try + { + var elapsed = Stopwatch.StartNew(); + + var outcome = ViewerLaunchGate.Launch( + retry: () => true, + launch: () => Exited(2), + isOwned: () => false, + canLaunch: () => true); + + await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Failed); + await Assert.That(elapsed.Elapsed).IsLessThan(TimeSpan.FromSeconds(10)); + } + finally + { + ViewerLaunchGate.BindWait = previous; + } + } + + /// + [Test] + public async Task AViewerThatExitsWithAFailureIsReportedRatherThanWaitedOnAsync() + { + var previous = ViewerLaunchGate.BindWait; + ViewerLaunchGate.BindWait = TimeSpan.FromSeconds(20); + try + { + var elapsed = Stopwatch.StartNew(); + + var outcome = await ViewerLaunchGate.LaunchAsync( + retry: () => Task.FromResult(true), + launch: () => Task.FromResult(Exited(2)), + Cancel.None, + isOwned: () => false, + canLaunch: () => true); + + await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Failed); + await Assert.That(elapsed.Elapsed).IsLessThan(TimeSpan.FromSeconds(10)); + } + finally + { + ViewerLaunchGate.BindWait = previous; + } + } + + /// + /// An owner that turns up while a failed viewer is being waited on is somebody else's, and was + /// never handed this caller's work. Read as the viewer this call started, it had the caller + /// told its snapshot was queued by a process that had never heard of it. + /// + [Test] + public async Task AnOwnerArrivingBehindAFailedViewerIsNotTakenForIt() + { + var probes = 0; + + var outcome = ViewerLaunchGate.Launch( + retry: () => true, + launch: () => Exited(2), + // Nobody when the gate decides to launch, and somebody by the time it waits + isOwned: () => Interlocked.Increment(ref probes) > 1, + canLaunch: () => true); + + await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Failed); + } + + /// + /// A clean exit is not a failure to start. A viewer that finds the port already bound hands + /// its work to whoever bound it and exits with zero, so the owner the next probe finds does + /// have the work, and the launch is reported as one. + /// + [Test] + public async Task AViewerThatHandedItsWorkOverAndExitedIsStillALaunch() + { + var previous = ViewerLaunchGate.BindWait; + // Long enough that reaching the end of it would show, so the answer is the owner's + ViewerLaunchGate.BindWait = TimeSpan.FromSeconds(20); + try + { + var probes = 0; + var elapsed = Stopwatch.StartNew(); + + var outcome = ViewerLaunchGate.Launch( + retry: () => true, + launch: () => Exited(0), + // Nobody when the gate decides to launch or first waits, and then the owner + isOwned: () => Interlocked.Increment(ref probes) > 2, + canLaunch: () => true); + + await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Launched); + await Assert.That(elapsed.Elapsed).IsLessThan(TimeSpan.FromSeconds(10)); + } + finally + { + ViewerLaunchGate.BindWait = previous; + } + } + + /// + /// Nor is a clean exit with nobody on the port, which is a viewer that opened, was dealt with + /// and closed before a probe caught it. It is waited on as a running one is, and reported the + /// same way: what it held when it closed, it staged. + /// + [Test] + public async Task AViewerThatExitedCleanlyIsNotAFailure() + { + var previous = ViewerLaunchGate.BindWait; + ViewerLaunchGate.BindWait = TimeSpan.FromMilliseconds(200); + try + { + var outcome = ViewerLaunchGate.Launch( + retry: () => true, + launch: () => Exited(0), + isOwned: () => false, + canLaunch: () => true); + + await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Launched); + } + finally + { + ViewerLaunchGate.BindWait = previous; + } + } + /// /// An owner that is there and refuses the payload is not answered by launching another, which /// would bind nothing and be refused in its turn. @@ -124,7 +260,7 @@ public async Task ARefusingOwnerIsNotLaunchedOver() launch: () => { launches++; - return Task.FromResult(true); + return Task.FromResult(Running()); }, Cancel.None, isOwned: () => true); @@ -263,7 +399,7 @@ public async Task TheDefaultProbeReadsTheRealPort() launch: () => { launches++; - return true; + return Running(); }); await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Taken); @@ -290,17 +426,45 @@ sealed class FakeViewer public int Starts => starts; - public bool Start() + public Process? Start() { Interlocked.Increment(ref starts); Interlocked.Exchange(ref upAt, (elapsed.Elapsed + BindDelay).Ticks); - return true; + return Running(); } public bool IsUp() => elapsed.Elapsed.Ticks >= Interlocked.Read(ref upAt); } + /// + /// The process of a viewer that is still running, which is all the gate asks of one it has + /// started. This process, since it is. What the gate disposes when its wait is over is the + /// object, which holds a handle of its own and nothing of the process. + /// + static Process Running() => + Process.GetCurrentProcess(); + + /// + /// A process that has exited with : the command interpreter, told to + /// do that and nothing else. A real one, because what the gate reads is Process.HasExited and + /// Process.ExitCode, and how those behave is the part a stand-in would only be guessing at. + /// + static Process Exited(int code) + { + var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var process = Process.Start( + new ProcessStartInfo( + windows ? "cmd.exe" : "/bin/sh", + windows ? $"/c exit {code}" : $"-c \"exit {code}\"") + { + UseShellExecute = false, + CreateNoWindow = true + })!; + process.WaitForExit(); + return process; + } + /// /// DiffRunner.AddDeleteAsync's shape and then DiffRunner.AddDelete's, on the only thread a /// single threaded context has, which is where xUnit v2 puts a second test once the first is @@ -309,7 +473,7 @@ public bool IsUp() => /// [Test] public Task SyncLaunchBehindAnAsyncDeleteOnTheSameContextFinishes() => - SyncBehindAsync(() => Task.FromResult(true)); + SyncBehindAsync(() => Task.FromResult(Running())); /// /// AddInlineAsync's shape: the launch is ViewerLauncher.LaunchAsync, whose stdin write and @@ -322,10 +486,10 @@ public Task SyncLaunchBehindAnAsyncInlineOnTheSameContextFinishes() => SyncBehindAsync(async () => { await Task.Delay(10); - return true; + return Running(); }); - static async Task SyncBehindAsync(Func> launch) + static async Task SyncBehindAsync(Func> launch) { var previous = ViewerLaunchGate.BindWait; ViewerLaunchGate.BindWait = TimeSpan.FromMilliseconds(300); @@ -343,7 +507,7 @@ static async Task SyncBehindAsync(Func> launch) canLaunch: () => true); syncOutcome = ViewerLaunchGate.Launch( retry: () => true, - launch: () => true, + launch: Running, isOwned: () => false, canLaunch: () => true); }) diff --git a/src/DiffEngine.Tests/ViewerLauncherTests.cs b/src/DiffEngine.Tests/ViewerLauncherTests.cs index e15d36643..d95e9b1bd 100644 --- a/src/DiffEngine.Tests/ViewerLauncherTests.cs +++ b/src/DiffEngine.Tests/ViewerLauncherTests.cs @@ -59,6 +59,112 @@ public async Task ElsewhereTheViewerHasStandardStreamsOfItsOwn() await Assert.That(info.RedirectStandardError).IsTrue(); } + /// + /// A viewer took its working directory from the test host, which is usually the test project's + /// output folder, and held it for as long as it lived. It is started in its own folder, which + /// it holds by running from it whatever its working directory is. + /// + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task TheViewerStartsInItsOwnFolder(bool windows) + { + var directory = Path.Combine(Path.GetTempPath(), "viewer folder"); + + var info = ViewerLauncher.StartInfo(Path.Combine(directory, "DiffEngineViewer.exe"), "--attach", windows); + + await Assert.That(info.WorkingDirectory).IsEqualTo(directory); + } + + /// + /// The same thing as Windows sees it, which is where it mattered: the directory the host was in + /// when it started a viewer could not be deleted until that viewer exited, so + /// git clean -xdf failed behind a viewer nobody could see. + /// + /// The one test that moves this process's current directory, so it runs alone and puts it back + /// before anything else is asked. + /// + /// + [Test] + [NotInParallel] + [RunOn(TUnit.Core.Enums.OS.Windows)] + public async Task ARunningViewerDoesNotHoldTheHostsDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), $"DiffEngine.HostDirectory.{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var previous = Environment.CurrentDirectory; + Process? viewer; + Environment.CurrentDirectory = directory; + try + { + // FakeDiffTool stands in for the viewer: no window, and gone by itself in five seconds + viewer = ViewerLauncher.Start(FakeDiffTool.Exe, ""); + } + finally + { + Environment.CurrentDirectory = previous; + } + + try + { + await Assert.That(viewer).IsNotNull(); + // Still running, or the delete below says nothing about what a running one holds + await Assert.That(viewer!.HasExited).IsFalse(); + await Assert.That(() => Directory.Delete(directory)).ThrowsNothing(); + } + finally + { + if (viewer is not null) + { + using (viewer) + { + try + { + viewer.Kill(); + } + catch (InvalidOperationException) + { + // Already gone, which is all the kill was for + } + + viewer.WaitForExit(5000); + } + } + + if (Directory.Exists(directory)) + { + Directory.Delete(directory); + } + } + } + + /// + /// A relative path meant relative to the host's directory, which is no longer where the viewer + /// starts. + /// + [Test] + public async Task ARelativePathIsHandedOverRooted() + { + var rooted = ViewerLauncher.Rooted("Sample.received.txt"); + + // Not compared with a path built from the current directory here, which the test above + // moves for a moment + await Assert.That(Path.IsPathRooted(rooted)).IsTrue(); + await Assert.That(Path.GetFileName(rooted)).IsEqualTo("Sample.received.txt"); + } + + /// + /// And one that is already rooted goes over as the caller spelt it, tidied or not: the row is + /// settled later by a key built from that spelling. + /// + [Test] + public async Task ARootedPathIsHandedOverAsGiven() + { + var path = Path.Combine(Path.GetTempPath(), "one", "..", "Sample.received.txt"); + + await Assert.That(ViewerLauncher.Rooted(path)).IsEqualTo(path); + } + /// /// The patch goes in a file named on the command line, because a launch that redirects stdin /// cannot use ShellExecute. diff --git a/src/DiffEngine.Tests/ViewerProtocolTests.cs b/src/DiffEngine.Tests/ViewerProtocolTests.cs index c9cc4ade5..6d9c556de 100644 --- a/src/DiffEngine.Tests/ViewerProtocolTests.cs +++ b/src/DiffEngine.Tests/ViewerProtocolTests.cs @@ -1112,6 +1112,11 @@ await Assert.That(await ViewerClient.SendAsync(new(ViewerVerb.List), default, po /// listening on - Windows lets it hang, others refuse it, both only after the wait has returned. /// Serialised with the other tests in this class, since the event is process wide. /// + /// + /// The listener table is made to say the port may be held, which is what it says when it + /// cannot be read. Left to answer, it rules the port out on Windows before anything connects, + /// and then there is no connect to give up on. + /// /// [Test] [NotInParallel] @@ -1131,6 +1136,8 @@ void Record(object? sender, UnobservedTaskExceptionEventArgs args) } } + var lookup = ListenerTable.Lookup; + ListenerTable.Lookup = _ => _ == port || lookup(_); TaskScheduler.UnobservedTaskException += Record; try { @@ -1151,6 +1158,7 @@ void Record(object? sender, UnobservedTaskExceptionEventArgs args) finally { TaskScheduler.UnobservedTaskException -= Record; + ListenerTable.Lookup = lookup; ViewerClient.ForgetUnowned(); } diff --git a/src/DiffEngine.Tests/ViewerThatCannotStartTests.cs b/src/DiffEngine.Tests/ViewerThatCannotStartTests.cs new file mode 100644 index 000000000..a7d7d6688 --- /dev/null +++ b/src/DiffEngine.Tests/ViewerThatCannotStartTests.cs @@ -0,0 +1,135 @@ +// DiffEngineTray is the obsolete public shim, but its IsRunning is still where the tray check +// lives, and this test has to hold it down. +#pragma warning disable CS0618 + +/// +/// A viewer that resolves and cannot take the launch, from the public entry point down: the real +/// resolution, the real ShellExecute launch and the real probe of the port, with only the +/// executable stood in for. +/// +/// The copy that resolves is not always the one this library was built beside. A globally +/// installed tool and the copy shipped with a tray are both looked for ahead of the bundled one, +/// and either can be older than the arguments it is about to be given: --payload first +/// shipped in 20.5.0, and a viewer from before it exits with 2 on seeing it. The launch was +/// reported as made all the same, so the snapshot was called queued, in a queue no process held, +/// and a caller told that stages nothing. +/// +/// +/// FakeDiffTool copied to the viewer's name is that older copy: it exits with 2 on +/// --payload. Windows only, as everything else that starts FakeDiffTool is. +/// +/// +[NotInParallel] +[RunOn(TUnit.Core.Enums.OS.Windows)] +public class ViewerThatCannotStartTests +{ + [Test] + public async Task AnInlineSnapshotIsNotCalledQueued() + { + using var viewer = new OlderViewer(); + var patch = new InlinePatch(Path.Combine(viewer.Folder, "Tests.cs"), 42, "\"old\"", "new") + { + TestName = "Tests.Method" + }; + var before = PayloadFiles(); + + var result = await DiffRunner.AddInlineAsync(patch); + + await Assert.That(result).IsEqualTo(InlineResult.NoViewerFound); + // The viewer reads its payload file and deletes it, and this one never got that far, so + // the file is the launcher's to take back + await Assert.That(PayloadFiles().Except(before)).IsEmpty(); + } + + static List PayloadFiles() => + Directory.GetFiles(Path.GetTempPath(), "DiffEngineViewer_*.inlinepatch").ToList(); + + /// + /// The stand-in, resolved the way a real copy is - through the variable that overrides where + /// the viewer is looked for - with no tray and nothing on the port, so the launch is the only + /// way left to hand the patch over. + /// + sealed class OlderViewer : + IDisposable + { + const string variable = "DiffEngine_DiffEngineViewer"; + readonly string? previousPort; + readonly bool previousRunning; + readonly bool previousDisabled; + readonly TimeSpan previousBindWait; + + public string Folder { get; } = Path.Combine(Path.GetTempPath(), $"DiffEngine.OlderViewer.{Guid.NewGuid():N}"); + + public OlderViewer() + { + // The apphost under the viewer's name. Everything it loads keeps its own, since the + // apphost looks for the assembly it was built for rather than one named after itself + Directory.CreateDirectory(Folder); + var apphost = Path.GetFileName(FakeDiffTool.Exe); + foreach (var file in Directory.GetFiles(Path.GetDirectoryName(FakeDiffTool.Exe)!)) + { + var name = Path.GetFileName(file); + File.Copy(file, Path.Combine(Folder, name == apphost ? "DiffEngineViewer.exe" : name)); + } + + if (!ViewerServer.TryBind(0, out var bound)) + { + throw new("Could not bind an ephemeral port."); + } + + var port = bound.Port; + bound.Dispose(); + previousPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable); + previousRunning = DiffEngineTray.IsRunning; + previousDisabled = DiffRunner.Disabled; + previousBindWait = ViewerLaunchGate.BindWait; + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString()); + DiffEngineTray.IsRunning = false; + DiffRunner.Disabled = false; + // Well past how long a freshly copied executable can take to start with a virus + // scanner reading it first. The stand-in is noticed when it exits, not when this ends + ViewerLaunchGate.BindWait = TimeSpan.FromSeconds(30); + ViewerClient.ForgetUnowned(); + MaxInstance.ResetCount(); + + try + { + Environment.SetEnvironmentVariable(variable, Folder); + DiffTools.UseOrder(DiffTool.DiffEngineViewer); + // Checked before anything is launched, because the alternative to the stand-in is + // whichever real viewer this machine has installed + if (!DiffTools.TryFindByName(DiffTool.DiffEngineViewer, out var tool) || + !tool.ExePath.StartsWith(Folder, StringComparison.OrdinalIgnoreCase)) + { + throw new($"The stand-in viewer did not resolve. Resolved: {tool?.ExePath}"); + } + } + catch + { + Dispose(); + throw; + } + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(variable, null); + DiffTools.Reset(); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousPort); + DiffEngineTray.IsRunning = previousRunning; + DiffRunner.Disabled = previousDisabled; + ViewerLaunchGate.BindWait = previousBindWait; + ViewerClient.ForgetUnowned(); + MaxInstance.ResetCount(); + try + { + Directory.Delete(Folder, true); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + // A stand-in that is still on its way out holds its own files for a moment + } + } + } +} diff --git a/src/DiffEngine.Tests/WildcardFileFinderTests.cs b/src/DiffEngine.Tests/WildcardFileFinderTests.cs index e78b7b601..bf0f43daf 100644 --- a/src/DiffEngine.Tests/WildcardFileFinderTests.cs +++ b/src/DiffEngine.Tests/WildcardFileFinderTests.cs @@ -28,6 +28,74 @@ public async Task MultiMatchDir_order2() await Assert.That(File.Exists(result)).IsTrue(); } + /// + /// The NuGet cache holds a folder for each version of DiffEngine any project on the machine + /// has referenced, and the viewer bundled in each. Taken most recently written first, the one + /// found was whichever had been restored last, so restoring an old project put a viewer from + /// before the arguments this library passes ahead of every newer one. As numbers, too: 20.10.0 + /// is above 20.6.0. + /// + [Test] + public async Task VersionFoldersAreTakenHighestFirst() => + await Assert.That(First("20.10.0", "20.6.0", "20.3.1")).IsEqualTo("20.10.0"); + + /// + /// A release is above every prerelease of it, whichever was written last. + /// + [Test] + public async Task AReleaseIsAboveItsPrereleases() => + await Assert.That(First("21.0.0", "21.0.0-beta.10", "21.0.0-beta.2")).IsEqualTo("21.0.0"); + + /// + /// And prereleases part by part, numbers as numbers: beta.10 is after beta.2, and both are + /// after any alpha. + /// + [Test] + public async Task PrereleasesAreOrderedByTheirLabels() => + await Assert.That(First("21.0.0-beta.10", "21.0.0-beta.2", "21.0.0-alpha.11")).IsEqualTo("21.0.0-beta.10"); + + /// + /// Everything else is still taken most recently written first. A tool's install folder is + /// named for the tool, and a number on its own is not a version: Visual Studio 2022 installs + /// to 2022 and its successor to 18. + /// + [Test] + [Arguments("Beyond Compare 5", "Beyond Compare 4")] + [Arguments("2022", "18")] + [Arguments("20.6.0", "current")] + public async Task OtherFoldersAreTakenMostRecentlyWrittenFirst(string older, string newer) => + await Assert.That(First(older, newer)).IsEqualTo(newer); + + /// + /// The folder a wildcard resolves to first, of ones holding the same file and written in the + /// order given, a day apart, so the last named is the most recently written. + /// + static string First(params string[] folders) + { + var root = Path.Combine(Path.GetTempPath(), $"DiffEngine.Wildcard.{Guid.NewGuid():N}"); + try + { + for (var index = 0; index < folders.Length; index++) + { + var directory = Path.Combine(root, folders[index]); + Directory.CreateDirectory(Path.Combine(directory, "tools")); + File.WriteAllText(Path.Combine(directory, "tools", "viewer.txt"), ""); + Directory.SetLastWriteTime(directory, DateTime.Now.AddDays(index - folders.Length)); + } + + if (!WildcardFileFinder.TryFind(Path.Combine(root, "*", "tools", "viewer.txt"), out var found)) + { + throw new("Nothing was found."); + } + + return Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(found))!); + } + finally + { + Directory.Delete(root, true); + } + } + [Test] public async Task FullFilePath() { diff --git a/src/DiffEngine.slnx b/src/DiffEngine.slnx index fffe5d319..fff903ef4 100644 --- a/src/DiffEngine.slnx +++ b/src/DiffEngine.slnx @@ -36,6 +36,9 @@ + + + @@ -50,6 +53,11 @@ --> + + + + + diff --git a/src/DiffEngine/DiffRunner.cs b/src/DiffEngine/DiffRunner.cs index 4341526fa..1b9634155 100644 --- a/src/DiffEngine/DiffRunner.cs +++ b/src/DiffEngine/DiffRunner.cs @@ -368,18 +368,33 @@ static bool TryCreate(ResolvedTool tool, string targetFile, Encoding? encoding) return true; } - static int LaunchProcess(ResolvedTool tool, string arguments) + internal static int LaunchProcess(ResolvedTool tool, string arguments) { - var startInfo = new ProcessStartInfo(tool.ExePath, arguments) - { - // Given the full exe path is known we dont need UseShellExecute https://stackoverflow.com/a/5255335 - // however UseShellExecute allows the test running to not block when the difftool is launched - // https://github.com/VerifyTests/Verify/issues/1229 - UseShellExecute = tool.UseShellExecute, - CreateNoWindow = tool.CreateNoWindow - }; try { + // A tool declared without ShellExecute held the test run open for as long as the tool + // was, which is the problem the comment further down records being solved for the + // tools declared with it. On Windows it is started so that it cannot: see + // WindowsProcess.StartInheritingNothing. + // + // Elsewhere it is started as declared, as every tool is. A child there takes the + // host's standard streams however it is started, and nothing in a definition says + // which tools could do without them: Neovim runs in the terminal and needs all three, + // and is declared the same as the tools that open a window + if (!tool.UseShellExecute && + RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return WindowsProcess.StartInheritingNothing(tool.ExePath, arguments); + } + + var startInfo = new ProcessStartInfo(tool.ExePath, arguments) + { + // Given the full exe path is known we dont need UseShellExecute https://stackoverflow.com/a/5255335 + // however UseShellExecute allows the test running to not block when the difftool is launched + // https://github.com/VerifyTests/Verify/issues/1229 + UseShellExecute = tool.UseShellExecute, + CreateNoWindow = tool.CreateNoWindow + }; using var process = Process.Start(startInfo); if (process != null) { diff --git a/src/DiffEngine/DiffRunner_Inline.cs b/src/DiffEngine/DiffRunner_Inline.cs index 180d2e60c..4b1357359 100644 --- a/src/DiffEngine/DiffRunner_Inline.cs +++ b/src/DiffEngine/DiffRunner_Inline.cs @@ -14,9 +14,10 @@ public enum InlineResult Disabled, /// - /// Nothing has the snapshot. No DiffEngineViewer could be resolved, or the owner of the queue - /// declined the payload - which is the same thing from the caller's side, since in both cases - /// the snapshot is pending nowhere. Callers that want a fallback should use it here. + /// Nothing has the snapshot. No DiffEngineViewer could be resolved, the one that was started + /// exited with a failure, or the owner of the queue declined the payload - which is the same + /// thing from the caller's side, since in each case the snapshot is pending nowhere. Callers + /// that want a fallback should use it here. /// NoViewerFound } @@ -83,10 +84,19 @@ public static async Task AddInlineAsync(InlinePatch patch, Cancel // Through the gate, because a parallel run reaches here once per failing snapshot with // nothing owning the port, and every one of them used to start a viewer of its own. + var file = ViewerLauncher.PayloadFile(); var launched = await ViewerLaunchGate.LaunchAsync( async () => await ViewerClient.SendAsync(new(ViewerVerb.Inline, Body: payload), cancel) == SendOutcome.Accepted, - () => ViewerLauncher.LaunchAsync(patch, payload, cancel), + () => ViewerLauncher.LaunchAsync(patch, payload, file, cancel), cancel); + if (launched == ViewerLaunchOutcome.Failed) + { + // Nothing is left that could read it: no viewer was started, or the one that was has + // exited. One that got as far as reading it also deleted it, and then this finds + // nothing + ViewerLauncher.Discard(file); + } + return InlineResultFor(launched); } @@ -100,6 +110,11 @@ public static async Task AddInlineAsync(InlinePatch patch, Cancel /// Capped existed and wrong from then on: with no tray running, every inline snapshot failing /// after the fifth diff tool of a run was reported as handed over and staged by nobody. /// + /// + /// A viewer that was started and exited with a failure is Failed too, for the same reason: it + /// took nothing. That is a copy too old for the arguments this library gives it, or one with + /// no runtime to run on. + /// /// internal static InlineResult InlineResultFor(ViewerLaunchOutcome outcome) => outcome switch diff --git a/src/DiffEngine/DiffTools_Add.cs b/src/DiffEngine/DiffTools_Add.cs index 9f77209d9..eaad1819d 100644 --- a/src/DiffEngine/DiffTools_Add.cs +++ b/src/DiffEngine/DiffTools_Add.cs @@ -46,7 +46,7 @@ public static partial class DiffTools static ResolvedTool? AddTool(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false) { - if (!OsSettingsResolver.Resolve(name, osSupport, out var exePath, out var launchArguments)) + if (!OsSettingsResolver.Resolve(name, osSupport, out var exePath, out var launchArguments, PreferredCopy(diffTool))) { return null; } @@ -54,6 +54,21 @@ public static partial class DiffTools return AddInner(name, diffTool, autoRefresh, isMdi, supportsText, requiresTarget, binaryExtensions, exePath, launchArguments, useShellExecute, createNoWindow, killLockingProcess); } + /// + /// Which of several installed copies of a tool to take, for the one tool where that is this + /// library's business: the viewer, which it starts with arguments of its own rather than only + /// the two paths every tool is given. See . + /// + internal static Func? PreferredCopy(DiffTool? tool) + { + if (tool == DiffTool.DiffEngineViewer) + { + return ViewerContract.IsMet; + } + + return null; + } + static ResolvedTool? AddInner(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaries, string exePath, LaunchArguments launchArguments, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false) { Guard.AgainstEmpty(name, nameof(name)); diff --git a/src/DiffEngine/Implementation/DiffEngineViewer.cs b/src/DiffEngine/Implementation/DiffEngineViewer.cs index 2e05ce8d2..7b8529e2a 100644 --- a/src/DiffEngine/Implementation/DiffEngineViewer.cs +++ b/src/DiffEngine/Implementation/DiffEngineViewer.cs @@ -12,7 +12,12 @@ public static Definition DiffEngineViewer() AutoRefresh: false, IsMdi: false, SupportsText: true, - RequiresTarget: true, + // A new snapshot has no verified file, and the viewer says so: it reads a missing + // target as an empty side. Asking for one meant EmptyFiles wrote a placeholder first, + // which the viewer then compared against as though it were the expected file - an + // empty PDF it could not open, a blank page - and for the maps, which EmptyFiles has + // no file for, that the pair never reached the viewer at all. + RequiresTarget: false, BinaryExtensions: ImageExtensions.All, Cost: "Free", OsSupport: new( @@ -29,7 +34,8 @@ public static Definition DiffEngineViewer() launchArguments, SearchDirectories("%HOME%/.dotnet/tools/", [], FallbackViewerDirectories.Osx()))), UseShellExecute: false, - // Console subsystem, so without this a window flashes on every launch. + // A copy from before 20.5 is a console executable, so without this a console window + // flashes when one is started by these flags, which the tray's Open diff tool does. CreateNoWindow: true, Notes: """ * The one tool DiffEngine does not open per pair. Every failing pair joins one @@ -54,6 +60,11 @@ and maps (GeoJSON, TopoJSON, KML, KMZ, GPX, WKT, WKB, FlatGeobuf and /// A globally installed tool is preferred, because installing one is an explicit choice of /// which viewer to run. Then the tray's copy, then the bundled copy, which is version matched to /// the library that is about to launch it, then the NuGet cache's. + /// + /// That is the order copies are looked for in, and not quite the order they are taken in: one + /// from before the way this library starts a viewer is passed over while a newer one is + /// further down. See . + /// /// static string[] SearchDirectories(string toolsDirectory, IEnumerable tray, IEnumerable nuGet) { diff --git a/src/DiffEngine/Inline/CsLanguage.cs b/src/DiffEngine/Inline/CsLanguage.cs index e1129a4c7..455930f52 100644 --- a/src/DiffEngine/Inline/CsLanguage.cs +++ b/src/DiffEngine/Inline/CsLanguage.cs @@ -78,7 +78,7 @@ internal override SourceScan Scan(string source) break; } - scan.MarkCode(index); + // Code, which is whatever the map was not told otherwise about index++; } diff --git a/src/DiffEngine/Inline/FsLanguage.cs b/src/DiffEngine/Inline/FsLanguage.cs index c30adbe69..e1f8fcb36 100644 --- a/src/DiffEngine/Inline/FsLanguage.cs +++ b/src/DiffEngine/Inline/FsLanguage.cs @@ -24,11 +24,13 @@ public override bool TryParse(string expression, [NotNullWhen(true)] out string? internal override char NameSeparator => '='; /// - /// F# does not apply the implicit conversion that lets a SettingsTask be awaited, so an F# - /// test ends the chain with ToTask. Snapshot returns the SettingsTask and ToTask does not, so - /// an appended call goes in front of it rather than after it. + /// The offside rule. A token that starts a line carries on the expression above it only when + /// it sits right of the column that expression started at; at that column it is read as the + /// next statement, and left of it as the end of the block. And that column is where the + /// expression's first token is, which is not the line's own indentation once something comes + /// before it: do!, let! x =, let x =, or a binding written on one line. /// - internal override string ChainTerminator => "ToTask"; + internal override bool IndentationIsSyntax => true; /// /// The F# compiler does not implement - it @@ -110,7 +112,7 @@ internal override SourceScan Scan(string source) break; } - scan.MarkCode(index); + // Code, which is whatever the map was not told otherwise about index++; } diff --git a/src/DiffEngine/Inline/InlineApplier.cs b/src/DiffEngine/Inline/InlineApplier.cs index 322b317e3..b81d32a00 100644 --- a/src/DiffEngine/Inline/InlineApplier.cs +++ b/src/DiffEngine/Inline/InlineApplier.cs @@ -16,6 +16,78 @@ public static class InlineApplier public static InlineApplyResult Apply(InlinePatch patch) => Run(patch, write: true); + /// + /// Applies several patches, reading and writing each source file once however many of them + /// are for it. The results are in the order the patches were given, whichever files they + /// name. + /// + /// Each outcome is the one would have reported had it been called on them + /// in turn. A patch is applied to what the ones before it left of its file, so the second of + /// two for one call site finds the first one's literal there, and a call site that an earlier + /// one moved is found where it now is. + /// + /// + /// What differs is when the file is written: once, after the last of its patches, through the + /// same temporary and the same swap, with the file's lock held from the read to the write. One + /// at a time, the whole file is read, lexed and written again for every patch, and the write + /// is where the time goes. A file that has just been written is scanned by whatever watches + /// the drive before the next thing can open it, and for five hundred snapshots in one ten + /// thousand line file that came to half a minute. The lexing is still once per patch, since + /// each starts from different source. + /// + /// + /// So a write that fails fails every patch it was carrying, and each says so. The patches + /// after the first of those say so too, whatever they were judged to be, because what they + /// were judged against was never written. The file is left as it was. + /// + /// + public static IReadOnlyList ApplyAll(IReadOnlyList patches) => + ApplyAll(patches, Swap); + + /// The patches, in the order they are to be applied. + /// + /// The swap. Supplied by the tests, which count how many there were and make one fail. + /// + internal static IReadOnlyList ApplyAll(IReadOnlyList patches, Action replace) + { + var results = new InlineApplyResult[patches.Count]; + // Each file's patches in the order they were given, and the files in the order they were + // first named + var files = new List<(string FullPath, List Indexes)>(); + var known = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < patches.Count; index++) + { + if (!TryResolve(patches[index], out var fullPath, out var invalid)) + { + results[index] = invalid; + continue; + } + + // Folded as the queue folds a path, so two spellings of a file the file system takes + // for one are one read and one write here as well + var key = InlineKey.FoldPath(fullPath); + if (!known.TryGetValue(key, out var file)) + { + file = files.Count; + known.Add(key, file); + files.Add((fullPath, [])); + } + + files[file].Indexes.Add(index); + } + + foreach (var (fullPath, indexes) in files) + { + var applied = Run(fullPath, indexes.Select(_ => patches[_]).ToList(), write: true, anchorOnly: false, replace); + for (var position = 0; position < indexes.Count; position++) + { + results[indexes[position]] = applied[position]; + } + } + + return results; + } + /// /// What would report, with nothing written. /// @@ -53,31 +125,56 @@ public static InlineApplyResult CanAnchor(InlinePatch patch) => static InlineApplyResult Run(InlinePatch patch, bool write, bool anchorOnly = false) { + if (!TryResolve(patch, out var fullPath, out var invalid)) + { + return invalid; + } + + return Run(fullPath, [patch], write, anchorOnly, Swap)[0]; + } + + /// + /// The file a patch is for, or what is wrong with the patch when it does not name one. + /// + static bool TryResolve(InlinePatch patch, out string fullPath, [NotNullWhen(false)] out InlineApplyResult? invalid) + { + fullPath = ""; + invalid = null; if (string.IsNullOrWhiteSpace(patch.SourceFile)) { - return InlineApplyResult.Failed("InlinePatch.SourceFile is empty"); + invalid = InlineApplyResult.Failed("InlinePatch.SourceFile is empty"); + return false; } if (patch.LineHint < 1) { - return InlineApplyResult.Failed($"InlinePatch.LineHint must be 1 or greater. Value: {patch.LineHint}"); + invalid = InlineApplyResult.Failed($"InlinePatch.LineHint must be 1 or greater. Value: {patch.LineHint}"); + return false; } - string fullPath; try { fullPath = Path.GetFullPath(patch.SourceFile); } catch (Exception exception) { - return InlineApplyResult.Failed($"Invalid InlinePatch.SourceFile: {patch.SourceFile}", exception); + invalid = InlineApplyResult.Failed($"Invalid InlinePatch.SourceFile: {patch.SourceFile}", exception); + return false; } // Followed before anything else, so the lock, the mutex, the read and the swap all name // the file that actually holds the source fullPath = ResolveLink(fullPath); + return true; + } - var newContent = SourceLanguage.NormalizeNewlines(patch.NewContent); + /// + /// The patches of one file, applied in order with the file's locks held throughout, and an + /// outcome for each. A failure that is about the file rather than about a patch - a lock that + /// could not be taken, a file that could not be read - is every patch's outcome. + /// + static InlineApplyResult[] Run(string fullPath, IReadOnlyList patches, bool write, bool anchorOnly, Action replace) + { var normalizedPath = fullPath.ToLowerInvariant(); lock (gates.GetOrAdd(normalizedPath, static _ => new())) { @@ -92,7 +189,7 @@ static InlineApplyResult Run(InlinePatch patch, bool write, bool anchorOnly = fa } catch (Exception exception) { - return InlineApplyResult.Failed($"Could not open the inline patch mutex for: {fullPath}", exception); + return All(patches, InlineApplyResult.Failed($"Could not open the inline patch mutex for: {fullPath}", exception)); } using var mutex = opened; @@ -110,10 +207,10 @@ static InlineApplyResult Run(InlinePatch patch, bool write, bool anchorOnly = fa if (!owned) { - return InlineApplyResult.Failed($"Timed out waiting for the inline patch mutex for: {fullPath}"); + return All(patches, InlineApplyResult.Failed($"Timed out waiting for the inline patch mutex for: {fullPath}")); } - return LockedApply(fullPath, patch, newContent, write, anchorOnly); + return LockedApply(fullPath, patches, write, anchorOnly, replace); } finally { @@ -125,7 +222,18 @@ static InlineApplyResult Run(InlinePatch patch, bool write, bool anchorOnly = fa } } - static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string newContent, bool write, bool anchorOnly) + static InlineApplyResult[] All(IReadOnlyList patches, InlineApplyResult result) + { + var results = new InlineApplyResult[patches.Count]; + for (var index = 0; index < results.Length; index++) + { + results[index] = result; + } + + return results; + } + + static InlineApplyResult[] LockedApply(string fullPath, IReadOnlyList patches, bool write, bool anchorOnly, Action replace) { // Asked here rather than before the lock, because the swap at the end of this method takes // the path away for the instant it takes to rename over it. Asked outside, an applier @@ -133,7 +241,7 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string // reported it, which is neither true nor the sort of thing a retry was going to fix if (!File.Exists(fullPath)) { - return InlineApplyResult.Failed($"Source file does not exist: {fullPath}"); + return All(patches, InlineApplyResult.Failed($"Source file does not exist: {fullPath}")); } byte[] bytes; @@ -143,7 +251,7 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string } catch (Exception exception) { - return InlineApplyResult.Failed($"Failed to read: {fullPath}", exception); + return All(patches, InlineApplyResult.Failed($"Failed to read: {fullPath}", exception)); } var (encoding, bomLength) = DetectEncoding(bytes); @@ -154,59 +262,87 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string } catch (DecoderFallbackException exception) { - return InlineApplyResult.Failed( - $"Could not decode as {encoding.WebName}: {fullPath}. Every byte that failed to decode would be replaced on write, so the file is left alone. Convert it to UTF-8 and re-run the test.", - exception); + return All( + patches, + InlineApplyResult.Failed( + $"Could not decode as {encoding.WebName}: {fullPath}. Every byte that failed to decode would be replaced on write, so the file is left alone. Convert it to UTF-8 and re-run the test.", + exception)); } catch (Exception exception) { - return InlineApplyResult.Failed($"Failed to decode: {fullPath}", exception); + return All(patches, InlineApplyResult.Failed($"Failed to decode: {fullPath}", exception)); } - PatchStatus status; - string newSource; - string failReason; - try - { - status = InlinePatcher.TryApply( - SourceLanguage.ForFile(fullPath), - source, - patch.LineHint, - patch.Mode, - patch.OriginalExpression, - patch.OriginalValue, - patch.MemberName, - patch.EntryPoints, - anchorOnly, - newContent, - out newSource, - out failReason); - } - catch (Exception exception) + var language = SourceLanguage.ForFile(fullPath); + var results = new InlineApplyResult[patches.Count]; + // The first patch whose edit the write below has to carry, or -1 while there is none + var firstToWrite = -1; + for (var index = 0; index < results.Length; index++) { - // A patcher defect on some shape of source, reported against the file it met it in - // rather than thrown at whichever surface was accepting - return InlineApplyResult.Failed($"Failed to patch: {fullPath}", exception); - } + var patch = patches[index]; + PatchStatus status; + string newSource; + string failReason; + try + { + status = InlinePatcher.TryApply( + language, + source, + patch.LineHint, + patch.Mode, + patch.OriginalExpression, + patch.OriginalValue, + patch.MemberName, + patch.EntryPoints, + anchorOnly, + SourceLanguage.NormalizeNewlines(patch.NewContent), + out newSource, + out failReason); + } + catch (Exception exception) + { + // A patcher defect on some shape of source, reported against the file it met it in + // rather than thrown at whichever surface was accepting + results[index] = InlineApplyResult.Failed($"Failed to patch: {fullPath}", exception); + continue; + } - switch (status) - { - case PatchStatus.AlreadyApplied: - return InlineApplyResult.AlreadyApplied; - case PatchStatus.NotFound: - return InlineApplyResult.NotFound(failReason); + switch (status) + { + case PatchStatus.AlreadyApplied: + results[index] = InlineApplyResult.AlreadyApplied; + continue; + case PatchStatus.NotFound: + results[index] = InlineApplyResult.NotFound(failReason); + continue; + } + + // Every reason a patch can be refused for has been asked by this point and none of + // them held. All that remains is the write, which is the one step a dry run may not + // take + results[index] = InlineApplyResult.Applied; + if (!write) + { + continue; + } + + // The next patch is applied to this one's result, as it would have been to the file + // this one had written + source = newSource; + if (firstToWrite < 0) + { + firstToWrite = index; + } } - // Every reason a patch can be refused for has been asked by this point and none of them - // held. All that remains is the write, which is the one step a dry run may not take - if (!write) + if (firstToWrite < 0) { - return InlineApplyResult.Applied; + return results; } try { - var content = encoding.GetBytes(newSource); + var content = encoding.GetBytes(source); byte[] output; if (bomLength > 0) { @@ -220,14 +356,23 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string output = content; } - WriteThroughTemporary(fullPath, output); + WriteThroughTemporary(fullPath, output, replace); } catch (Exception exception) { - return InlineApplyResult.Failed($"Failed to write: {fullPath}", exception); + // Nothing from the first edit on reached the file, and every answer after it was about + // source that held that edit: one already applied only because an earlier patch here + // had written the same literal, one not found only because an earlier patch had taken + // its anchor. So they all report the write, and an entry that reports a failure is + // kept for another try + var failed = InlineApplyResult.Failed($"Failed to write: {fullPath}", exception); + for (var index = firstToWrite; index < results.Length; index++) + { + results[index] = failed; + } } - return InlineApplyResult.Applied; + return results; } /// @@ -314,8 +459,9 @@ static void CopyMode(string destination, string temporary) } #endif - static void WriteThroughTemporary(string fullPath, byte[] output) => - WriteThroughTemporary(fullPath, output, static (temporary, destination) => File.Replace(temporary, destination, null)); + // What replace is for every caller but a test + static void Swap(string temporary, string destination) => + File.Replace(temporary, destination, null); /// The source file, which the patched bytes replace. /// The whole patched file, preamble included. diff --git a/src/DiffEngine/Inline/InlineKey.cs b/src/DiffEngine/Inline/InlineKey.cs index 8b2555f72..fc8c33da3 100644 --- a/src/DiffEngine/Inline/InlineKey.cs +++ b/src/DiffEngine/Inline/InlineKey.cs @@ -31,6 +31,14 @@ public static string For(string sourceFile, int line) => internal static string FoldPath(string path) => caseInsensitivePaths ? path.ToLowerInvariant() : path; + /// + /// Whether two paths are one file to this machine's file system, without folding either: for + /// a caller asking it of every entry in a queue, where a folded copy of each path each time + /// is the queue's length in strings. + /// + internal static bool SamePath(string left, string right) => + string.Equals(left, right, caseInsensitivePaths ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + static bool caseInsensitivePaths = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); diff --git a/src/DiffEngine/Inline/InlinePatch.cs b/src/DiffEngine/Inline/InlinePatch.cs index e23b8c551..10150d338 100644 --- a/src/DiffEngine/Inline/InlinePatch.cs +++ b/src/DiffEngine/Inline/InlinePatch.cs @@ -112,6 +112,58 @@ OriginalExpression is not null && SourceLanguage.NormalizeNewlines(original) == normalized; } + /// + /// Whether this patch came from a member other than : both are known, + /// and they differ. + /// + /// What a line cannot say for itself. A line names a call site only until something above it + /// in the file changes, and accepting a snapshot is exactly that: every call site below it + /// moves down, and the line an entry was queued under becomes the line of whatever call moved + /// onto it. Whoever finds an entry by its line has this to ask before believing it, since a + /// call cannot be in two members. A patch with no member, or a caller with none, leaves the + /// line as all there is. + /// + /// + internal bool IsAnotherMembers(string? member) => + !string.IsNullOrEmpty(member) && + !string.IsNullOrEmpty(MemberName) && + member != MemberName; + + /// + /// Whether is for the call site this patch is for, going by + /// everything but the line: the same member of the same test in the same file, the same kind + /// of edit, and anchored to the same argument. + /// + /// For recognising a call site that has moved. The anchors are what the source held when each + /// run saw it, so they agree for as long as nothing has been accepted there, whatever the two + /// runs produced. The test name is beside the member because a member is only a name, and one + /// file can declare it in two classes. A patch with no member is never recognised: without + /// one there is nothing to say two call sites with nothing accepted yet are different ones. + /// + /// + internal bool IsSameCallSite(InlinePatch other) => + !string.IsNullOrEmpty(MemberName) && + MemberName == other.MemberName && + TestName == other.TestName && + Mode == other.Mode && + OriginalExpression == other.OriginalExpression && + OriginalValue == other.OriginalValue && + InlineKey.FoldPath(SourceFile) == InlineKey.FoldPath(other.SourceFile); + + /// + /// This patch with another line hint. A copy, because a queued patch is shared with whatever + /// is displaying it and with an accept that may be part way through applying it. + /// + internal InlinePatch At(int line) => + new(SourceFile, line, OriginalExpression, NewContent, Mode) + { + OriginalValue = OriginalValue, + MemberName = MemberName, + EntryPoints = EntryPoints, + TestName = TestName, + Framework = Framework + }; + public InlinePatchMode Mode { get; set; } = mode; /// @@ -130,9 +182,10 @@ OriginalExpression is not null && /// /// Short target framework of the test process that produced this patch ("net9.0", "net48"). - /// Stamped by in the sending process, never by a - /// parser or a re-host, so a patch that crosses processes keeps the framework it was born - /// under. Null means unknown origin, which selects last-writer-wins queue semantics. + /// Stamped by in the sending process, and by + /// in the one staging it, never by a parser or a re-host, + /// so a patch that crosses processes keeps the framework it was born under. Null means unknown + /// origin, which selects last-writer-wins queue semantics. /// public string? Framework { get; set; } diff --git a/src/DiffEngine/Inline/InlinePatchFile.cs b/src/DiffEngine/Inline/InlinePatchFile.cs index 74f6719ed..33dfcfd36 100644 --- a/src/DiffEngine/Inline/InlinePatchFile.cs +++ b/src/DiffEngine/Inline/InlinePatchFile.cs @@ -6,6 +6,18 @@ namespace DiffEngine; /// public static class InlinePatchFile { + /// + /// Stages a patch as a file, stamped with the framework this process is running as where the + /// patch carries none, as stamps one it sends. + /// + /// A test run that finds no viewer stages through here, and a multi-targeted one stages a file + /// per framework for the same call site. Written with no framework, none of them could be + /// told from the others: + /// takes an unlabeled trio whichever framework is clearing, so the framework that passed + /// deleted the snapshot of the one still failing. The process writing the file is the one + /// that produced the patch, which is the same reason the send stamps it there. + /// + /// public static void Write(string path, InlinePatch patch) { var directory = Path.GetDirectoryName(path); @@ -14,7 +26,10 @@ public static void Write(string path, InlinePatch patch) Directory.CreateDirectory(directory); } - File.WriteAllText(path, Build(patch), new UTF8Encoding(false)); + File.WriteAllText(path, Build(patch, patch.Framework ?? RuntimeMoniker.Current), new UTF8Encoding(false)); + // The directory may be one no clear has listed yet, and the next clear this process makes + // has to find what is now in it + InlineStaging.Staged(); } /// diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 398e1bd45..85af032a1 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -126,7 +126,9 @@ public static PatchStatus TryApply( failReason = ""; var eol = DetectEol(source); var lineStarts = BuildLineStarts(source); - var scan = language.Scan(source); + // Everything that reads the scan does so before this returns, the searches that are + // enumerated lazily included, so its map goes back to the pool on the way out + using var scan = language.Scan(source); var memberLine = MemberLine(source, scan, lineStarts, lineHint, memberName); if (mode == InlinePatchMode.Remove) @@ -174,7 +176,7 @@ public static PatchStatus TryApply( return PatchStatus.AlreadyApplied; } - var rendered = RenderArgument(language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + var rendered = RenderArgument(source, scan, lineStarts, nameStart, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -220,7 +222,7 @@ public static PatchStatus TryApply( return PatchStatus.AlreadyApplied; } - var rendered = RenderArgument(language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + var rendered = RenderArgument(source, scan, lineStarts, nameStart, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -261,7 +263,7 @@ static PatchStatus InsertOrCheck( ref string newSource, ref string failReason) { - if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, out var openParen)) + if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, snapshotName, false, out var nameStart, out var openParen)) { failReason = $"Could not find a {methodName} call near line {lineHint}. The source may have changed since the test run. Re-run the test."; return PatchStatus.NotFound; @@ -282,7 +284,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var emptyRendered = RenderArgument(scan.Language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + var emptyRendered = RenderArgument(source, scan, lineStarts, nameStart, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.Start, emptyRendered); return PatchStatus.Applied; } @@ -297,7 +299,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var namedIndent = IndentForSpan(source, lineStarts, expected.ListStart, fileUnit); + var namedIndent = IndentForSpan(source, scan, lineStarts, nameStart, expected.ListStart, fileUnit); var namedRendered = scan.Language.Render(newContent, namedIndent, eol); newSource = Splice(source, expected.ListStart, expected.ListStart, $"{scan.Language.NamePrefix(parameterName)}{namedRendered}, "); return PatchStatus.Applied; @@ -315,7 +317,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var rendered = RenderArgument(scan.Language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + var rendered = RenderArgument(source, scan, lineStarts, nameStart, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -333,7 +335,7 @@ static PatchStatus InsertOrCheck( // A differing literal is a snapshot that changed, and this is the only shape a // changed one arrives in from a language with no expression to anchor on. Refusing // it there would mean an inline snapshot could be accepted once and never updated - var rendered = RenderArgument(scan.Language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + var rendered = RenderArgument(source, scan, lineStarts, nameStart, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -436,8 +438,16 @@ static bool TryReadArguments(string source, SourceScan scan, int openParen, out /// Appends a Snapshot call to the verify invocation, for a snapshot that has never been /// accepted. Snapshot terminates the chain, so the insertion point is the end of any calls /// already chained onto the invocation rather than the invocation's own closing paren - except - /// where the language ends its chain with something Snapshot has to precede, which - /// answers. + /// where the chain ends in something Snapshot has to precede, which + /// answers. + /// + /// The call is the first entry point the search yields that has no Snapshot call chained onto + /// it, not the first it yields. A hint goes stale the moment an accept higher in the file + /// inserts a literal, and the walk then starts over from the member's declaration - so the + /// first call it meets is the first in the test, which is the one most likely to have been + /// accepted already. Stopping there answered "already has a Snapshot call" for a patch whose + /// own call sat two lines further down, and a single accept dropped the entry. + /// /// static PatchStatus TryAppend( string source, @@ -453,7 +463,60 @@ static PatchStatus TryAppend( ref string newSource, ref string failReason) { - if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, entryPoints, true, out var nameStart, out var openParen)) + var found = false; + // Whether a call passed over for having a Snapshot call was holding this very content + var held = false; + List<(int Open, int Close)>? passedOver = null; + foreach (var (nameStart, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, entryPoints, true)) + { + // An entry point in the argument list of a call that was passed over is part of that + // call. Throws(() => Verify(value)).Snapshot(...) has its Snapshot, and the Verify + // inside it is not a second place to hang one + if (passedOver is not null && + passedOver.Any(_ => nameStart > _.Open && nameStart < _.Close)) + { + continue; + } + + found = true; + if (!TryScanArguments(source, scan, openParen, out var closeParen, out _)) + { + failReason = $"Could not parse the argument list of the {entryPointDescription} call near line {lineHint}."; + return PatchStatus.NotFound; + } + + // Everything a call site needs to host a snapshot has now been established, which is + // all an anchor probe asked + if (anchorOnly) + { + return PatchStatus.Applied; + } + + var insertAt = WalkChain(source, scan, closeParen + 1, methodName, out var chained); + if (chained < 0) + { + newSource = AppendCall(source, scan, lineStarts, nameStart, insertAt, newContent, eol, fileUnit); + return PatchStatus.Applied; + } + + held |= HoldsContent(source, scan, chained, newContent); + + // Two things end the search at a call that has one. The recorded line: a hint that + // lands on a call names it, and a Snapshot call already there holding other content is + // another framework's accept of the same call site, so carrying on would hang this + // snapshot on the test's next verify call instead. And having no member: nothing bounds + // the walk then, and the next call without one is as likely to be in another test + if (memberLine is null || + IsOnHint(lineStarts, nameStart, lineHint)) + { + break; + } + + passedOver ??= []; + passedOver.Add((openParen, closeParen)); + } + + if (!found) { // Short, because every surface that shows it is one line: a status bar, a balloon, a // menu tooltip. Both clauses earn their place there because they are the two causes a @@ -462,50 +525,54 @@ static PatchStatus TryAppend( return PatchStatus.NotFound; } - if (!TryScanArguments(source, scan, openParen, out var closeParen, out _)) - { - failReason = $"Could not parse the argument list of the {entryPointDescription} call near line {lineHint}."; - return PatchStatus.NotFound; - } - - // Everything a call site needs to host a snapshot has now been established, which is all - // an anchor probe asked - if (anchorOnly) + // Every call that could have taken it has a Snapshot call. Another process may have + // appended one between the run and the accept, and two frameworks failing the same call + // site is the ordinary way that happens: each queues an append, and accepting the first + // leaves the second with nowhere to put a literal that is already there. Only the content + // tells the two apart. The same snapshot is done, and saying so matters - a refusal reads + // as a failure, and the reader who sent two identical snapshots and got one applied and + // one rejected has no way to see that their source is already right. A different one is a + // call site that cannot say what it wants until it has been re-run against the literal it + // now has. + if (held) { - return PatchStatus.Applied; + return PatchStatus.AlreadyApplied; } - var insertAt = WalkChain(source, scan, closeParen + 1, methodName, out var chained); - // Another process may have appended one between the run and the accept, and two - // frameworks failing the same call site is the ordinary way that happens: each queues an - // append, and accepting the first leaves the second with nowhere to put a literal that is - // already there. Only the content tells the two apart. The same snapshot is done, and - // saying so matters - a refusal reads as a failure, and the reader who sent two identical - // snapshots and got one applied and one rejected has no way to see that their source is - // already right. A different one is a call site that cannot say what it wants until it has - // been re-run against the literal it now has. - if (chained >= 0) - { - if (HoldsContent(source, scan, chained, newContent)) - { - return PatchStatus.AlreadyApplied; - } - - failReason = $"The call near line {lineHint} already has a {methodName} call. Re-run the test."; - return PatchStatus.NotFound; - } + failReason = $"The call near line {lineHint} already has a {methodName} call. Re-run the test."; + return PatchStatus.NotFound; + } - var statementIndent = LeadingWhitespace(source, lineStarts, nameStart); + /// + /// The source with a Snapshot call holding spliced in at + /// , on a line of its own under the call at + /// . + /// + static string AppendCall(string source, SourceScan scan, List lineStarts, int nameStart, int insertAt, string newContent, string eol, string fileUnit) + { + var statementIndent = StatementIndent(source, scan, lineStarts, nameStart); var unit = UnitFor(fileUnit, statementIndent); // Line up with the existing chain when there is one, otherwise start it one level in var callIndent = LineOf(lineStarts, insertAt - 1) == LineOf(lineStarts, nameStart) ? statementIndent + unit : LeadingWhitespace(source, lineStarts, insertAt - 1); + if (scan.Language.IndentationIsSyntax) + { + // The line a chain ends on is not always one of its calls. A closing paren on a line + // of its own sits at the column the expression started at, which is where a formatter + // puts it, and an argument's last line may too. Lined up with either, the call is + // read as the start of the next statement rather than as more of this one + var expressionIndent = IndentTo(source, lineStarts, ExpressionStart(source, scan, nameStart)); + if (IndentWidth(callIndent) <= IndentWidth(expressionIndent)) + { + callIndent = expressionIndent + unit; + } + } + var contentIndent = callIndent + unit; var rendered = scan.Language.Render(newContent, contentIndent, eol); var argument = OnOwnLine(rendered, contentIndent, eol); - newSource = Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({argument})"); - return PatchStatus.Applied; + return Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({argument})"); } /// @@ -536,6 +603,19 @@ static bool HoldsContent(string source, SourceScan scan, int openParen, string c /// /// Removes the Snapshot call, along with the whitespace and line break that preceded it so no /// blank line is left behind. + /// + /// What it was called on stays, and that has to still be something once the call has gone. A + /// verify call is. A variable is not: settings.Snapshot("old"); became + /// settings;, which is no statement (CS0201), and Snapshot is as public on a + /// VerifySettings as on what a verify call returns. So there it is the statement that goes, + /// where it can be taken whole (), and the call is reported + /// where it cannot. + /// + /// + /// Only where the variable would be left as the statement, though. Awaited, assigned, returned + /// or passed, it is still a value with the call gone (), and the + /// statement reads as it would have run without the snapshot. + /// /// static PatchStatus TryRemove( SourceLanguage language, @@ -588,6 +668,19 @@ static PatchStatus TryRemove( start--; var dotStart = start; + if (LeavesOnlyItsReceiver(source, scan, dotStart, closeParen) && + !IsTakenAsAValue(source, scan, lineStarts, nameStart)) + { + if (!TryStatementLines(source, scan, lineStarts, nameStart, closeParen, out var from, out var to)) + { + failReason = $"Removing the {methodName} call near line {lineHint} would leave what it is called on as a statement by itself. Remove the statement by hand."; + return PatchStatus.NotFound; + } + + newSource = Splice(source, from, to, ""); + return PatchStatus.Applied; + } + // Then back over the indentation and line break it sat on while (start > 0 && (source[start - 1] == ' ' || source[start - 1] == '\t')) @@ -614,6 +707,166 @@ static PatchStatus TryRemove( return PatchStatus.Applied; } + /// + /// Whether taking a call out would leave nothing of its expression but what it was called on: + /// the call hangs off a name rather than off another call, and nothing is chained on after it. + /// + /// Either of those is enough for what is left to read as it did. A call result is a statement + /// with one call fewer on the end of it, and with more of the chain to follow, the rest hangs + /// off the receiver exactly as it hung off this. + /// + /// + static bool LeavesOnlyItsReceiver(string source, SourceScan scan, int dot, int closeParen) + { + var receiverEnd = PreviousToken(source, scan, dot); + if (receiverEnd >= 0 && + source[receiverEnd] == ')' && + scan.IsCode(receiverEnd)) + { + return false; + } + + var after = closeParen + 1; + scan.SkipTrivia(ref after); + return after >= source.Length || + source[after] != '.'; + } + + /// + /// Whether something takes the value of the expression a call ends: it is awaited, returned, + /// assigned or passed. + /// + /// What the call was called on is then still something with the call gone. + /// await task.Snapshot("old"); reads await task; and + /// var kept = task.Snapshot("old"); reads var kept = task;, each as it would + /// have run without the snapshot. It is a statement that was nothing but the call that leaves + /// a name standing by itself. + /// + /// + /// A lambda's body is left out on purpose. _ => _.Snapshot("old") would be left as + /// _ => _, which is no body for a lambda that returns nothing. And where indentation is + /// syntax, only what takes it sits on the same line: the = a line above is the one a + /// whole body hangs off, and the line under it is that body's first statement. + /// + /// + static bool IsTakenAsAValue(string source, SourceScan scan, List lineStarts, int nameStart) + { + var expressionStart = ExpressionStart(source, scan, nameStart); + var before = PreviousToken(source, scan, expressionStart); + if (before < 0 || + !scan.IsCode(before)) + { + return false; + } + + if (scan.Language.IndentationIsSyntax && + LineOf(lineStarts, before) != LineOf(lineStarts, expressionStart)) + { + return false; + } + + // Passed, or assigned, or compared: an equals sign that ends a comparison takes a value as + // much as one that assigns. The arrow of a lambda ends in the other character + if (source[before] is '(' or ',' or '=') + { + return true; + } + + if (!scan.IsIdentifierChar(source[before])) + { + return false; + } + + var wordStart = scan.WordStart(before); + var word = source.Substring(wordStart, before + 1 - wordStart); + return word is "await" or "return"; + } + + /// + /// The lines of a statement that is one call and nothing else, from the start of its first + /// line to the start of the line after its last, for taking out whole. + /// + /// Only where taking them out cannot change what is around them, which is a narrower thing + /// than being a statement. It has to have its lines to itself, since a line is what goes. In + /// C# it has to sit in a block and end in its own semicolon: the body of an if with no + /// braces is a statement too, and removing that hands the if whatever came next. F# + /// has no semicolon to look for, so the call has to end its line, and something has to follow + /// at the same indentation: the last line of a block is the block's value, and a binding left + /// with nothing under it does not compile. + /// + /// + static bool TryStatementLines(string source, SourceScan scan, List lineStarts, int nameStart, int closeParen, out int start, out int end) + { + start = -1; + end = -1; + var expressionStart = ExpressionStart(source, scan, nameStart); + if (!StartsLine(source, lineStarts, expressionStart)) + { + return false; + } + + var cursor = closeParen + 1; + var byLayout = scan.Language.IndentationIsSyntax; + if (!byLayout) + { + var before = PreviousToken(source, scan, expressionStart); + if (before >= 0 && + !(scan.IsCode(before) && source[before] is ';' or '{' or '}')) + { + return false; + } + + scan.SkipTrivia(ref cursor); + if (cursor >= source.Length || + source[cursor] != ';') + { + return false; + } + + cursor++; + } + + var lastLine = LineOf(lineStarts, cursor - 1); + var lineEnd = lastLine < lineStarts.Count ? lineStarts[lastLine] : source.Length; + while (cursor < lineEnd) + { + if (char.IsWhiteSpace(source[cursor])) + { + cursor++; + continue; + } + + // A comment that runs past the end of the line would be cut in two + if (scan.TryGetCommentSkip(cursor, out var afterComment) && + afterComment <= lineEnd) + { + cursor = afterComment; + continue; + } + + return false; + } + + if (byLayout) + { + // A name at the same indentation is the next statement of the same block. An + // operator there carries this one on, and anything further left ends the block + var next = lineEnd; + scan.SkipTrivia(ref next); + if (next >= source.Length || + !scan.IsIdentifierChar(source[next]) || + !StartsLine(source, lineStarts, next) || + LeadingWhitespace(source, lineStarts, next) != LeadingWhitespace(source, lineStarts, expressionStart)) + { + return false; + } + } + + start = lineStarts[LineOf(lineStarts, expressionStart) - 1]; + end = lineEnd; + return true; + } + /// /// Whether the Snapshot call the recorded line names has already been removed: the line holds /// no Snapshot call, and the verify statement it belongs to has none chained onto it. @@ -678,10 +931,26 @@ static bool RemovedAtHint(string source, SourceScan scan, List lineStarts, return false; } + /// + /// The calls a Snapshot call has to be appended in front of rather than after: each hands back + /// something other than the SettingsTask a Snapshot call is made on, so the chain cannot be + /// carried on past one. + /// + /// The same three in both languages. This used to be ToTask alone, and F#'s alone, because an + /// F# test ends its chain that way: F# does not apply the conversion that lets a SettingsTask + /// be awaited. C# reaches for all three as readily - a library that configures every await, a + /// synchronous test blocking on GetAwaiter - and with nothing to stop at there, an append onto + /// await Verify(value).ConfigureAwait(false) went after the ConfigureAwait, where a + /// ConfiguredTaskAwaitable has no Snapshot to call (CS1061). The anchor probe had already said + /// the call site could host a snapshot, so the verification was inline with nowhere to put one. + /// + /// + static string[] chainTerminators = ["ToTask", "ConfigureAwait", "GetAwaiter"]; + /// /// Walks the calls chained onto an invocation and returns where a call should be appended: - /// the end of the chain, or the point in front of the language's - /// when the chain ends in one. + /// the end of the chain, or the point in front of the first of the + /// when the chain holds one. /// is the open paren of the first call to /// among them, or -1 where there is none. The position rather than the fact of it, because a /// caller deciding what to do about one has to read its argument. @@ -689,7 +958,6 @@ static bool RemovedAtHint(string source, SourceScan scan, List lineStarts, static int WalkChain(string source, SourceScan scan, int index, string name, out int found) { found = -1; - var terminator = scan.Language.ChainTerminator; // Where the chain was before the terminating call, which is where an appended one goes: // in front of the terminator, and behind the whitespace and line break that introduced it var beforeTerminator = -1; @@ -726,9 +994,8 @@ static int WalkChain(string source, SourceScan scan, int index, string name, out found = paren; } - if (terminator != null && - beforeTerminator < 0 && - IsCall(source, nameStart, cursor, terminator)) + if (beforeTerminator < 0 && + IsCall(source, nameStart, cursor, chainTerminators)) { beforeTerminator = index; } @@ -743,6 +1010,19 @@ static bool IsCall(string source, int nameStart, int nameEnd, string name) => nameEnd - nameStart == name.Length && string.CompareOrdinal(source, nameStart, name, 0, name.Length) == 0; + static bool IsCall(string source, int nameStart, int nameEnd, string[] names) + { + foreach (var name in names) + { + if (IsCall(source, nameStart, nameEnd, name)) + { + return true; + } + } + + return false; + } + static string LeadingWhitespace(string source, List lineStarts, int offset) { var lineStart = lineStarts[LineOf(lineStarts, offset) - 1]; @@ -756,6 +1036,228 @@ static string LeadingWhitespace(string source, List lineStarts, int offset) return source.Substring(lineStart, index - lineStart); } + /// + /// The indentation a splice at a call measures one level in from: the leading whitespace of + /// the line the call's name is on, unless the language reads indentation as syntax and the + /// call's expression starts further along that line. + /// + /// One level in from the line is right for C#, where it is only a convention. In F# a new + /// line has to clear the column the expression starts at + /// (): a chained call right of it, and a + /// literal no further left than it. After do! that column is already a level past the + /// line's indentation, and after let! x = it is further. So an appended call landed on + /// the column or left of it, and after anything longer than do! a literal given a line + /// of its own did too: FS0010 either way, in source that compiled until it was accepted into. + /// + /// + /// A call whose expression starts its line, or started on a line above, is measured from the + /// line as before. The line it is on was already somewhere the compiler accepts, and one level + /// further in than that is too. + /// + /// + static string StatementIndent(string source, SourceScan scan, List lineStarts, int nameStart) + { + if (scan.Language.IndentationIsSyntax) + { + var start = ExpressionStart(source, scan, nameStart); + if (LineOf(lineStarts, start) == LineOf(lineStarts, nameStart)) + { + return IndentTo(source, lineStarts, start); + } + } + + return LeadingWhitespace(source, lineStarts, nameStart); + } + + /// + /// Whitespace as wide as the column is at: its line's own + /// indentation, then spaces for whatever stands between that and the offset. The same string + /// as the line's indentation for the first thing on a line. + /// + static string IndentTo(string source, List lineStarts, int offset) + { + var lead = LeadingWhitespace(source, lineStarts, offset); + var lineStart = lineStarts[LineOf(lineStarts, offset) - 1]; + return lead + new string(' ', offset - lineStart - lead.Length); + } + + /// + /// Where the expression a call belongs to starts: back from the call's name over everything it + /// is reached through, one receiver at a time. For the Snapshot call in + /// Verifier.Verify(value).UseDirectory("x").Snapshot() that is Verifier. + /// + /// Whatever cannot be read as a name or a call ends the walk where it has got to, which is + /// right of where the expression really starts. The answer is used as a column to stay clear + /// of, so one too far right costs an indent deeper than it had to be, where one too far left + /// would cost source that does not compile. + /// + /// + static int ExpressionStart(string source, SourceScan scan, int nameStart) + { + var start = nameStart; + while (true) + { + var dot = PreviousToken(source, scan, start); + if (dot < 0 || + source[dot] != '.') + { + return start; + } + + var end = PreviousToken(source, scan, dot); + // A literal is a receiver too, and not one this reads + if (end < 0 || + !scan.IsCode(end)) + { + return start; + } + + if (source[end] == ')') + { + if (!TryFindOpenParen(source, scan, end, out var openParen)) + { + return start; + } + + // The name an argument list belongs to sits against it. With anything between + // them the parens are an expression of their own, and that is where this starts + end = openParen - 1; + if (end >= 0 && + source[end] == '>' && + !TrySkipTypeArgumentsBack(source, scan, ref end)) + { + return start; + } + + if (end < 0 || + !scan.IsIdentifierChar(source[end])) + { + return openParen; + } + } + else if (!scan.IsIdentifierChar(source[end])) + { + return start; + } + + start = scan.WordStart(end); + } + } + + /// + /// The offset of the last character before that is not whitespace and + /// not in a comment, or -1 when there is none. + /// + /// A literal counts, where steps over one. That + /// suits a caller asking what kind of thing precedes a name, and not one asking what a call + /// hangs off: looking past "text" in "text".Verify(value) finds whatever came + /// before the literal and takes it for the receiver. + /// + /// + static int PreviousToken(string source, SourceScan scan, int index) + { + while (index > 0) + { + if (scan.TryGetCommentEndingAt(index, out var commentStart)) + { + index = commentStart; + continue; + } + + if (!char.IsWhiteSpace(source[index - 1])) + { + return index - 1; + } + + index--; + } + + return -1; + } + + /// + /// The open paren that the close paren at closes: the scan + /// does, run backwards, with comments and literals stepped over + /// whole in the same way. + /// + static bool TryFindOpenParen(string source, SourceScan scan, int closeParen, out int openParen) + { + openParen = -1; + var depth = 1; + // Just past what is still to be read, so the character at closeParen itself is not + var index = closeParen; + while (index > 0) + { + if (scan.TryGetSkipEndingAt(index, out var skipStart)) + { + index = skipStart; + continue; + } + + index--; + switch (source[index]) + { + case ')': + case ']': + case '}': + depth++; + continue; + case '(': + case '[': + case '{': + depth--; + if (depth == 0) + { + openParen = index; + return source[index] == '('; + } + + continue; + } + } + + return false; + } + + /// + /// Steps back over the type argument list that ends at , leaving it on + /// the character in front of the list: run + /// backwards, accepting only what a type argument list can hold for the same reason. + /// + static bool TrySkipTypeArgumentsBack(string source, SourceScan scan, ref int end) + { + var depth = 0; + for (var index = end; index >= 0; index--) + { + var ch = source[index]; + if (ch == '>') + { + depth++; + continue; + } + + if (ch == '<') + { + depth--; + if (depth == 0) + { + end = index - 1; + return true; + } + + continue; + } + + if (!scan.IsIdentifierChar(ch) && + !scan.Language.IsTypeArgumentChar(ch)) + { + return false; + } + } + + return false; + } + /// /// The column indentation reaches, with a tab advancing to the next multiple of four. Four /// rather than eight because that is what the tab indented C# this has to measure is written @@ -858,9 +1360,6 @@ static bool TryFindAnchoredCall( return false; } - static bool TryFindCall(string source, SourceScan scan, List lineStarts, int lineHint, int? memberLine, out int openParen) => - TryFindCall(source, scan, lineStarts, lineHint, memberLine, snapshotName, false, out _, out openParen); - static bool TryFindCall( string source, SourceScan scan, @@ -1376,13 +1875,13 @@ static void TrimSpan(string source, SourceScan scan, ref int start, ref int end) } /// - /// Renders the literal for a splice at , indented to suit where it - /// lands. + /// Renders the literal for a splice at , in the argument list of + /// the call at , indented to suit where it lands. /// - static string RenderArgument(SourceLanguage language, string source, List lineStarts, int spanStart, string newContent, string eol, string fileUnit) + static string RenderArgument(string source, SourceScan scan, List lineStarts, int nameStart, int spanStart, string newContent, string eol, string fileUnit) { - var indent = IndentForSpan(source, lineStarts, spanStart, fileUnit); - var rendered = language.Render(newContent, indent, eol); + var indent = IndentForSpan(source, scan, lineStarts, nameStart, spanStart, fileUnit); + var rendered = scan.Language.Render(newContent, indent, eol); if (StartsLine(source, lineStarts, spanStart)) { return rendered; @@ -1416,12 +1915,18 @@ static bool StartsLine(string source, List lineStarts, int offset) return true; } + // A copy of the whole source for every patch, so the only copy where the framework allows it: + // a builder holds the text once itself before it makes the string static string Splice(string source, int start, int end, string replacement) => +#if NET6_0_OR_GREATER + string.Concat(source.AsSpan(0, start), replacement, source.AsSpan(end)); +#else new StringBuilder(source.Length - (end - start) + replacement.Length) .Append(source, 0, start) .Append(replacement) .Append(source, end, source.Length - end) .ToString(); +#endif static string DetectEol(string source) { @@ -1613,8 +2118,13 @@ static int LineOf(List lineStarts, int offset) /// /// The indentation a literal taking a line of its own would sit at: one level in from the /// span's line, or the span's own column when it already starts a line. + /// + /// is the call the span is an argument of. Where the span is on + /// that call's own line, the level is counted from what says the + /// call is measured from rather than from the line. + /// /// - static string IndentForSpan(string source, List lineStarts, int spanStart, string fileUnit) + static string IndentForSpan(string source, SourceScan scan, List lineStarts, int nameStart, int spanStart, string fileUnit) { var line = LineOf(lineStarts, spanStart); var lineStart = lineStarts[line - 1]; @@ -1633,7 +2143,9 @@ static string IndentForSpan(string source, List lineStarts, int spanStart, return source.Substring(lineStart, spanStart - lineStart); } - var leadText = lead.ToString(); + var leadText = LineOf(lineStarts, nameStart) == line + ? StatementIndent(source, scan, lineStarts, nameStart) + : lead.ToString(); return leadText + UnitFor(fileUnit, leadText); } } diff --git a/src/DiffEngine/Inline/InlineQueue.cs b/src/DiffEngine/Inline/InlineQueue.cs index dc477538a..33a8b2d35 100644 --- a/src/DiffEngine/Inline/InlineQueue.cs +++ b/src/DiffEngine/Inline/InlineQueue.cs @@ -40,15 +40,48 @@ public static InlineQueue From(IEnumerable items) => /// An unlabeled patch cannot be told apart from a re-run, so it replaces the whole entry, which /// is also the pre-variant behaviour. /// + /// + /// The key is the line, and a line stops naming a call site as soon as an accept above it + /// inserts a literal: the re-run reports every later call site in that file a few lines + /// further down. Folding by key alone queued each of those a second time beside the entry it + /// should have updated, and where the run's content had changed, a bulk accept applied the + /// stale one and then refused the fresh. So a patch whose key names nothing looks for the + /// entry of its own call site () and takes that one with it to the + /// line it is at now, and a patch whose key names another member's entry does not fold into it. + /// /// public InlineQueue Enqueue(InlinePatch patch) { var key = InlineKey.For(patch.SourceFile, patch.LineHint); var items = Items.ToList(); - var existing = items.FindIndex(_ => _.Key == key); - if (existing >= 0) + var atKey = items.FindIndex(_ => _.Key == key); + if (atKey >= 0 && + !items[atKey].Patch.IsAnotherMembers(patch.MemberName)) { - items[existing] = Fold(items[existing], patch); + items[atKey] = Fold(items[atKey], patch); + return new(items); + } + + var moved = FindMoved(items, patch); + if (moved >= 0) + { + // Where the key is free the entry takes it, and is back to being named by the line + // its call site is on. Where another call site's entry is still sitting there, left + // behind by the same move, this one keeps the key it has: a queue holds one entry to a + // key, and both entries are still found by their members, here and when they settle + items[moved] = atKey < 0 + ? Fold(At(items[moved], patch.LineHint), patch) + : Fold(items[moved], patch.At(items[moved].Patch.LineHint)); + return new(items); + } + + if (atKey >= 0) + { + // Another member's entry, under a line that is this patch's now. Folding the two + // presented one call site's snapshot as a variant of another's, or swapped it in under + // the other's name. Its own call site is elsewhere in the file, and the run that finds + // it still failing queues it again from there + items[atKey] = new(patch); } else { @@ -58,6 +91,69 @@ public InlineQueue Enqueue(InlinePatch patch) return new(items); } + /// + /// The entry for the call site a patch came from, where that entry is queued under another + /// line: the call site has moved since. + /// + /// Recognised by what a call site keeps when it moves (), + /// and only in an entry this patch's framework already has content in. A test stops at the + /// first verification that fails, so a framework reporting a call site has nothing else + /// failing in that member: an entry of its own there under another line is this call site + /// before it moved, or an earlier one it has since passed, which is no loss either. Another + /// framework's entry is not held to that. It can have stopped at an earlier call in the same + /// test, one this framework passed, and two calls holding the same literal read alike. + /// + /// + /// And only when exactly one entry answers to it. With more than one, nothing here says which + /// of them moved, and folding into the wrong one replaces a snapshot that is still pending, + /// so the patch is queued beside them, as it always was. + /// + /// + /// What this gives up is a test that carries on past a failed verification and has two call + /// sites nothing but the line tells apart, both new or both holding the same literal: the + /// second is taken for the first one moved, and the queue holds whichever reported last. + /// + /// + static int FindMoved(List items, InlinePatch patch) + { + var found = -1; + for (var index = 0; index < items.Count; index++) + { + var entry = items[index]; + if (!entry.Patch.IsSameCallSite(patch) || + !HasOrigin(entry, patch.Framework)) + { + continue; + } + + if (found >= 0) + { + return -1; + } + + found = index; + } + + return found; + } + + /// + /// Whether an entry already holds content from . An unlabeled entry + /// or an unlabeled arrival counts, on the reasoning gives: neither can be + /// told apart from a re-run. + /// + static bool HasOrigin(PendingInline entry, string? origin) => + origin is null || + entry.Variants.All(_ => _.Origins.Count == 0) || + entry.Variants.Any(_ => _.Origins.Contains(origin)); + + /// + /// An entry at another line. Every variant goes, since they are one call site, and as copies: + /// see . + /// + static PendingInline At(PendingInline entry, int line) => + new(entry.Variants.Select(_ => _ with { Patch = _.Patch.At(line) }).ToList(), entry.Status); + /// /// What a bulk accept reports. Both surfaces say this - the tray out of its own queue, the /// viewer out of its session - and each used to build the sentence itself, so a change to the @@ -231,8 +327,9 @@ public InlineQueue Settle(string key, string? origin, string? member) => /// The entry's key, naming its source file and recorded line. /// The framework moniker of the run that started passing. /// - /// The member the settled call site sits in, used only when matches - /// nothing. See . + /// The member the settled call site sits in. It finds the entry when + /// matches nothing (), and it is what says the entry the key does + /// match belongs to some other call site. /// /// /// What the settling call's expected argument holds, which narrows @@ -242,6 +339,18 @@ public InlineQueue Settle(string key, string? origin, string? member, string? va { var items = Items.ToList(); var index = items.FindIndex(_ => _.Key == key); + // The entry under the key is not always the settling call's. After an accept higher in + // the file a passing call sits on a line a later call site was queued under, and its + // settle took that entry, a snapshot still failing and then pending nowhere. An entry + // from another member is another call site's, unless the value says otherwise: a test + // renamed since it was queued passes with the content its entry was waiting to become + if (index >= 0 && + items[index].Patch.IsAnotherMembers(member) && + !IsSettledBy(items[index], value)) + { + index = -1; + } + if (index < 0) { index = FindByMember(items, key, member, value); @@ -335,7 +444,7 @@ static int FindByMember(List items, string key, string? member, s } if (value is not null && - !entry.Variants.Any(_ => _.Patch.IsSettledBy(value))) + !IsSettledBy(entry, value)) { continue; } @@ -351,6 +460,14 @@ static int FindByMember(List items, string key, string? member, s return found; } + /// + /// Whether a passing call holding settles any of an entry's + /// variants. Never with no value, which is a producer that sends none. + /// + static bool IsSettledBy(PendingInline entry, string? value) => + value is not null && + entry.Variants.Any(_ => _.Patch.IsSettledBy(value)); + /// /// The file half of a key. Taken off the key rather than off the patch, so both sides are /// case folded the way folds them for this platform. @@ -475,6 +592,38 @@ public InlineQueue AcceptAll(Func apply, out str .ToList(), out message); + /// + /// Applies every un-conflicted patch, handing them to together. + /// + /// For an applier that does better with the whole batch than with a patch at a time, which + /// does: it reads and writes a + /// source file once for all the snapshots in it, where the overload above has no choice but + /// to rewrite the file for each. What comes back is one result for each patch handed over, in + /// the same order. + /// + /// + public InlineQueue AcceptAll( + Func, IReadOnlyList> apply, + out string message) + { + var entries = Items + .Where(_ => !_.Conflicted) + .ToList(); + var results = apply(entries.Select(_ => _.Patch).ToList()); + if (results.Count != entries.Count) + { + throw new ArgumentException($"{entries.Count} patches were handed over and {results.Count} results came back.", nameof(apply)); + } + + var outcomes = new List<(PendingInline Entry, InlineApplyResult Result)>(entries.Count); + for (var index = 0; index < entries.Count; index++) + { + outcomes.Add((entries[index], results[index])); + } + + return AcceptAll(outcomes, out message); + } + /// /// The batch counterpart of . /// An item with no outcome, or that changed while the batch was applying, was not part of this diff --git a/src/DiffEngine/Inline/InlineStaging.cs b/src/DiffEngine/Inline/InlineStaging.cs index 94ce78573..2a30a2f35 100644 --- a/src/DiffEngine/Inline/InlineStaging.cs +++ b/src/DiffEngine/Inline/InlineStaging.cs @@ -54,6 +54,32 @@ public static int Persist(IEnumerable pending) public static int Clear(string sourceFile, int line, string? memberName, string? extraDirectory, string? origin) => Clear(sourceFile, line, memberName, extraDirectory, origin, null); + /// + /// Deletes what this process's framework staged for a call site that now passes, and leaves + /// what any other framework staged for it. Returns how many trios were cleared. + /// + /// + /// The staging half of , + /// and beside for the + /// reason that one is beside : "this framework now + /// passes" and "there is no inline snapshot here for any of them" are different statements. + /// Clear could already be given an origin to say the first, but nothing outside this assembly + /// can name the framework the way and the queue label it, + /// so no caller passed one. Every settle was the second statement, and in a multi-targeted + /// run the framework that passed cleared the trio of the one still failing. + /// + /// A process whose framework cannot be determined clears as Clear does with no origin. It + /// stages without a label for the same reason, so there is nothing to scope by. + /// + /// + /// The source file the settled call site is in. + /// The line the call site was recorded at. + /// The member the call site is in, used where the line has moved. + /// A staging root to clear beside the source project's. + /// What the settling call's expected argument holds. + public static int Settle(string sourceFile, int line, string? memberName, string? extraDirectory = null, string? value = null) => + Clear(sourceFile, line, memberName, extraDirectory, RuntimeMoniker.Current, value); + /// /// Deletes the staged files for a call site, for a run that has just settled or retired it. /// Returns how many trios were cleared. @@ -122,8 +148,14 @@ static int ClearIn(string directory, string sourceFile, int line, string? member return 0; } + // A trio staged at the line is the call site's unless it was staged from another member, + // which is the same test the queue makes of an entry under the key + // (InlineQueue.Settle): the line was that trio's before an accept above it moved this call + // onto it, and the snapshot it holds is still failing var matching = staged - .Where(_ => _.Patch.LineHint == line) + .Where(_ => _.Patch.LineHint == line && + !(_.Patch.IsAnotherMembers(memberName) && + !(value is not null && _.Patch.IsSettledBy(value)))) .ToList(); if (matching.Count == 0 && @@ -306,13 +338,7 @@ static IEnumerable StagingDirectories(string sourceFile, string? extraDi yield break; } - var obj = Path.Combine(project, "obj"); - if (!Directory.Exists(obj)) - { - yield break; - } - - foreach (var directory in FindStaging(obj, 0)) + foreach (var directory in StagingUnder(project)) { if (seen.Add(directory)) { @@ -321,6 +347,72 @@ static IEnumerable StagingDirectories(string sourceFile, string? extraDi } } + /// + /// The VerifyInline directories under a project's obj, from the last walk of it + /// while that walk still stands. + /// + /// runs once per + /// verification, and the walk is a directory listing for every directory in the tree: three + /// milliseconds for an obj of a hundred and fifty, and several times that on a drive where a + /// listing is slower, to find what it found for the verification before. So the walk is kept, + /// on the two conditions that are why it used not to be. + /// + /// + /// Nothing this process has staged since. A run that finds no queue owner stages as it goes, + /// and the call site it stages is one it will be asked to clear, so a walk taken before the + /// last thing this process wrote is never answered from: see . + /// + /// + /// And not for long, because another process can stage under the same obj at any time - the + /// other frameworks of a multi-targeted run, or a queue owner writing its queue out as it + /// exits - and nothing tells this one when it does. A directory one of them creates is found + /// by the first clear after . Only the list of directories + /// is kept, so what is late is a staging directory that did not exist: one already known is + /// read on every clear, as it was. + /// + /// + static string[] StagingUnder(string project) + { + // Read before the walk rather than after it, so a write that lands while the walk is + // under way leaves a count the next clear does not match + var writes = Volatile.Read(ref stagingWrites); + if (stagingUnder.TryGetValue(project, out var kept) && + kept.Writes == writes && + Since(kept.At) < RecheckStagingAfter) + { + return kept.Directories; + } + + var obj = Path.Combine(project, "obj"); + var directories = Directory.Exists(obj) ? FindStaging(obj, 0).ToArray() : []; + stagingUnder[project] = (Stopwatch.GetTimestamp(), writes, directories); + return directories; + } + + static ConcurrentDictionary stagingUnder = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// How long a walk for staging directories is answered from, which is how long a directory + /// another process creates can go unseen by a clear. Short for that reason, and long enough + /// for what it is for: a run making hundreds of clears a second walks once in that second + /// rather than hundreds of times. + /// + internal static TimeSpan RecheckStagingAfter { get; set; } = TimeSpan.FromSeconds(1); + + static int stagingWrites; + + /// + /// Says this process has just staged something, so no walk taken before now is answered from. + /// Called by everything that writes a trio: here, and + /// , which is what a test run stages through. + /// + internal static void Staged() => + Interlocked.Increment(ref stagingWrites); + + static TimeSpan Since(long timestamp) => + TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - timestamp) / (double) Stopwatch.Frequency); + // An intermediate directory sits a handful of levels below the project it belongs to, so the // walk is bounded rather than open ended, the same way ReceivedMaps bounds its own. const int maxDepth = 8; @@ -424,6 +516,10 @@ or ArgumentException { return false; } + finally + { + Staged(); + } } /// @@ -509,9 +605,11 @@ static string Truncate(string value, int bytes, int characters) /// on every one of them, including the overwhelmingly common case where nothing is staged and /// the answer is thrown away. /// - /// Only this half is cached. Which project a source file belongs to cannot change while a run - /// is going, whereas the staging directories under it can: a run that finds no queue owner - /// creates one as it goes, and a cached "nothing here" would then miss what it wrote. + /// Only this half is kept for good. Which project a source file belongs to cannot change while + /// a run is going, whereas the staging directories under it can: a run that finds no queue + /// owner creates one as it goes, and a "nothing here" kept across that would miss what it + /// wrote. That half is , which keeps its answer only until this + /// process next stages something, and briefly even then. /// /// static ConcurrentDictionary projectDirectories = diff --git a/src/DiffEngine/Inline/SourceLanguage.cs b/src/DiffEngine/Inline/SourceLanguage.cs index 207a52328..1ea83a0e2 100644 --- a/src/DiffEngine/Inline/SourceLanguage.cs +++ b/src/DiffEngine/Inline/SourceLanguage.cs @@ -86,10 +86,10 @@ public static SourceLanguage ForFile(string path) internal abstract char NameSeparator { get; } /// - /// A chained call that a Snapshot call has to be appended in front of rather than after, or - /// null when the end of the chain is always the insertion point. + /// Whether the column a line starts at is part of the syntax, so that a splice which starts a + /// new line has to choose its indentation for the compiler as well as for the reader. /// - internal virtual string? ChainTerminator => null; + internal virtual bool IndentationIsSyntax => false; /// /// Whether a patch from this language carries the source text of the expected argument, which diff --git a/src/DiffEngine/Inline/SourceScan.cs b/src/DiffEngine/Inline/SourceScan.cs index b1b15050e..b6e38785c 100644 --- a/src/DiffEngine/Inline/SourceScan.cs +++ b/src/DiffEngine/Inline/SourceScan.cs @@ -1,3 +1,5 @@ +using System.Buffers; + /// /// A one pass lexical map of a source file: where the comments, strings and char literals are, and /// therefore which offsets are code. @@ -14,78 +16,174 @@ /// because nothing that reads the map can do without it: whatever is looking at an offset is about /// to ask what an identifier character is, or how a literal is written. /// +/// +/// Built again for every patch, over the whole file, so what it is made of shows on a large one. A +/// map as long as the source and three hash tables of its comments and literals came to two +/// megabytes a patch for a ten thousand line file, most of it in arrays large enough to be +/// collected only with the oldest generation. So the map is rented, which is why a scan is +/// disposed, and the spans are kept in the order the lexer found them rather than hashed. +/// /// -sealed class SourceScan(SourceLanguage language, string source) +sealed class SourceScan(SourceLanguage language, string source) : + IDisposable { - readonly bool[] code = new bool[source.Length]; - /// - /// Start of a comment or literal to the offset just past it. + /// True at every offset inside a comment or a literal. Rented, and longer than the source. /// - readonly Dictionary skips = new(); + bool[] skipped = Rent(source.Length); /// - /// The same spans keyed the other way round, for a scan working backwards. Ends are unique - /// because the spans cannot overlap. + /// Where each comment or literal starts and the offset just past it, in source order. The + /// spans cannot overlap, so both lists are sorted and either end of a span is found by + /// searching for it. /// - readonly Dictionary skipEnds = new(); + readonly List starts = []; + + readonly List ends = []; /// /// Which of the spans are comments. A literal is content, so the two cannot be treated alike /// where trivia is being stepped over or trimmed off. /// - readonly HashSet comments = []; + readonly List comments = []; public SourceLanguage Language { get; } = language; public string Source { get; } = source; + static bool[] Rent(int length) + { + var rented = ArrayPool.Shared.Rent(length); + Array.Clear(rented, 0, length); + return rented; + } + /// - /// Records a comment or literal spanning to . - /// Called by the lexer on as it fills the map. + /// Hands the map back. A scan asked anything after this throws rather than answer from an + /// array that some other scan may by then be filling. /// - public void AddSkip(int start, int end, bool comment) + public void Dispose() { - skips.Add(start, end); - skipEnds[end] = start; - if (comment) + var rented = skipped; + skipped = []; + if (rented.Length > 0) { - comments.Add(start); + ArrayPool.Shared.Return(rented); } } - public void MarkCode(int index) => code[index] = true; + /// + /// Records a comment or literal spanning to . + /// Called by the lexer on as it fills the map, in the order it + /// meets them, which is the order the searches below rely on. Everything it does not record + /// is code. + /// + public void AddSkip(int start, int end, bool comment) + { + starts.Add(start); + ends.Add(end); + comments.Add(comment); + skipped.AsSpan(start, end - start).Fill(true); + } /// /// True when the offset is outside every comment, string and char literal. /// public bool IsCode(int index) => index >= 0 && - index < code.Length && - code[index]; + index < Source.Length && + !skipped[index]; /// /// When a comment or literal starts at , is the /// offset just past it. Lets a structural scan step over trivia without lexing it again. /// - public bool TryGetSkip(int index, out int end) => - skips.TryGetValue(index, out end); + public bool TryGetSkip(int index, out int end) + { + var span = SpanStartingAt(index); + end = span < 0 ? 0 : ends[span]; + return span >= 0; + } + + /// + /// for a scan working backwards: when a comment or literal ends at + /// , is where it began. + /// + public bool TryGetSkipEndingAt(int end, out int start) + { + var span = SpanEndingAt(end); + start = span < 0 ? 0 : starts[span]; + return span >= 0; + } /// /// As , but only for comments. /// - public bool TryGetCommentSkip(int index, out int end) => - skips.TryGetValue(index, out end) && - comments.Contains(index); + public bool TryGetCommentSkip(int index, out int end) + { + var span = SpanStartingAt(index); + if (span < 0 || + !comments[span]) + { + end = 0; + return false; + } + + end = ends[span]; + return true; + } /// /// True when a comment ends at , with set to /// where it began. Only comments: a literal is content, and trimming one off a span would be /// trimming off the value. /// - public bool TryGetCommentEndingAt(int end, out int start) => - skipEnds.TryGetValue(end, out start) && - comments.Contains(start); + public bool TryGetCommentEndingAt(int end, out int start) + { + var span = SpanEndingAt(end); + if (span < 0 || + !comments[span]) + { + start = 0; + return false; + } + + start = starts[span]; + return true; + } + + /// + /// Which span starts at , or -1. These are asked of nearly every + /// offset a search steps over, and nearly all of those are code, which the map answers + /// without a search: a span cannot start on an offset that is. + /// + int SpanStartingAt(int index) + { + if (index < 0 || + index >= Source.Length || + !skipped[index]) + { + return -1; + } + + return starts.BinarySearch(index); + } + + /// + /// Which span ends just before , or -1. The offset before it is the + /// span's last, so it is not code either. + /// + int SpanEndingAt(int end) + { + if (end <= 0 || + end > Source.Length || + !skipped[end - 1]) + { + return -1; + } + + return ends.BinarySearch(end); + } /// /// Advances past whitespace and comments. @@ -125,7 +223,7 @@ public int PreviousSignificant(int index) { index--; while (index >= 0 && - (char.IsWhiteSpace(Source[index]) || !code[index])) + (char.IsWhiteSpace(Source[index]) || skipped[index])) { index--; } diff --git a/src/DiffEngine/InternalsVisibleTo.cs b/src/DiffEngine/InternalsVisibleTo.cs index b94a22c2e..f378deed7 100644 --- a/src/DiffEngine/InternalsVisibleTo.cs +++ b/src/DiffEngine/InternalsVisibleTo.cs @@ -2,4 +2,5 @@ [assembly: InternalsVisibleTo("VersionTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] [assembly: InternalsVisibleTo("DiffEngineTray, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] [assembly: InternalsVisibleTo("DiffEngineTray.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] -[assembly: InternalsVisibleTo("DiffEngineViewer.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] \ No newline at end of file +[assembly: InternalsVisibleTo("DiffEngineViewer.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] +[assembly: InternalsVisibleTo("DiffEngine.Benchmarks, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] \ No newline at end of file diff --git a/src/DiffEngine/OsSettingsResolver.cs b/src/DiffEngine/OsSettingsResolver.cs index 13b174f41..c3604525e 100644 --- a/src/DiffEngine/OsSettingsResolver.cs +++ b/src/DiffEngine/OsSettingsResolver.cs @@ -50,25 +50,35 @@ internal static string[] ParsePath(string value, char separator) return paths.ToArray(); } + /// + /// The tool's executable and how it is launched, on whichever operating system this is. + /// + /// preferred says which of several installed copies the caller would rather have. The + /// first copy it accepts is the one resolved, wherever that comes in the search order, and the + /// first copy of all when it accepts none - so it reorders what is found and never empties it. + /// Not asked about a copy an environment variable names, which is somebody's explicit choice. + /// + /// public static bool Resolve( string tool, OsSupport osSupport, [NotNullWhen(true)] out string? path, - [NotNullWhen(true)] out LaunchArguments? launchArguments) + [NotNullWhen(true)] out LaunchArguments? launchArguments, + Func? preferred = null) { - if (TryResolveForOs(tool, osSupport.Windows, out path, "WINDOWS")) + if (TryResolveForOs(tool, osSupport.Windows, out path, "WINDOWS", preferred)) { launchArguments = osSupport.Windows.LaunchArguments; return true; } - if (TryResolveForOs(tool, osSupport.Linux, out path, "LINUX")) + if (TryResolveForOs(tool, osSupport.Linux, out path, "LINUX", preferred)) { launchArguments = osSupport.Linux.LaunchArguments; return true; } - if (TryResolveForOs(tool, osSupport.Osx, out path, "OSX")) + if (TryResolveForOs(tool, osSupport.Osx, out path, "OSX", preferred)) { launchArguments = osSupport.Osx.LaunchArguments; return true; @@ -83,7 +93,8 @@ static bool TryResolveForOs( string tool, [NotNullWhen(true)] OsSettings? os, [NotNullWhen(true)] out string? path, - string platform) + string platform, + Func? preferred) { path = null; @@ -99,7 +110,7 @@ static bool TryResolveForOs( return true; } - return TryFindExe(exeName, os.PathCommandName, os.SearchDirectories, out path); + return TryFindExe(exeName, os.PathCommandName, os.SearchDirectories, preferred, out path); } public static bool TryFindForEnvironmentVariable(string tool, string exeName, [NotNullWhen(true)] out string? envPath) @@ -150,40 +161,77 @@ public static IEnumerable ExpandProgramFiles(IEnumerable paths) } } - static bool TryFindExe(string exeName, string pathCommandName, IEnumerable searchDirectories, [NotNullWhen(true)] out string? exePath) + static bool TryFindExe(string exeName, string pathCommandName, IEnumerable searchDirectories, Func? preferred, [NotNullWhen(true)] out string? exePath) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { searchDirectories = ExpandProgramFiles(searchDirectories); } + // With nothing preferred this stops at the first copy, having looked no further than it + // ever did. With a preference it goes on past the copies that are turned down, and comes + // back to the first of them only if nothing better turns up + exePath = null; + foreach (var candidate in Installed(exeName, pathCommandName, searchDirectories)) + { + if (preferred == null || + preferred(candidate)) + { + exePath = candidate; + return true; + } + + exePath ??= candidate; + } + + return exePath != null; + } + + /// + /// Every installed copy, in the order they are looked for: the search directories as written, + /// then PATH. + /// + static IEnumerable Installed(string exeName, string pathCommandName, IEnumerable searchDirectories) + { foreach (var directory in searchDirectories.Distinct()) { var exeSearchPath = Path.Combine(directory, exeName); - if (WildcardFileFinder.TryFind(exeSearchPath, out exePath)) + var found = false; + foreach (var exePath in WildcardFileFinder.FindAll(exeSearchPath)) { - return true; + found = true; + yield return exePath; + } + + if (!found) + { + Logging.Write($"Could not find file: {exeSearchPath}"); } } - return TryFindInEnvPath(pathCommandName, out exePath); + foreach (var commandPath in InEnvPath(pathCommandName)) + { + yield return commandPath; + } } // For each path in PATH, append cliApp and check if it exists. // Return the first one that exists. public static bool TryFindInEnvPath(string pathCommandName, [NotNullWhen(true)] out string? commandPath) + { + commandPath = InEnvPath(pathCommandName).FirstOrDefault(); + return commandPath != null; + } + + static IEnumerable InEnvPath(string pathCommandName) { foreach (var path in envPaths) { var combine = Path.Combine(path, pathCommandName); if (File.Exists(combine)) { - commandPath = combine; - return true; + yield return combine; } } - - commandPath = null; - return false; } } \ No newline at end of file diff --git a/src/DiffEngine/Process/WindowsProcess_Start.cs b/src/DiffEngine/Process/WindowsProcess_Start.cs new file mode 100644 index 000000000..fe98e755a --- /dev/null +++ b/src/DiffEngine/Process/WindowsProcess_Start.cs @@ -0,0 +1,142 @@ +// ReSharper disable PartialTypeWithSinglePart +static partial class WindowsProcess +{ +#if NET7_0_OR_GREATER + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CreateProcessW( + IntPtr applicationName, + IntPtr commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + IntPtr currentDirectory, + ref STARTUPINFOW startupInfo, + out PROCESS_INFORMATION processInformation); +#else + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CreateProcessW( + IntPtr applicationName, + IntPtr commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + bool inheritHandles, + uint creationFlags, + IntPtr environment, + IntPtr currentDirectory, + ref STARTUPINFOW startupInfo, + out PROCESS_INFORMATION processInformation); +#endif + + const uint createNoWindow = 0x08000000; + + [StructLayout(LayoutKind.Sequential)] + struct STARTUPINFOW + { + public int cb; + public IntPtr lpReserved; + public IntPtr lpDesktop; + public IntPtr lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + /// + /// Starts a process that takes nothing from this one, and returns its id. + /// + /// For the diff tools declared UseShellExecute: false. A test host's output goes to a + /// pipe that dotnet test reads until every writer has closed it, and + /// without ShellExecute always asks Windows to hand the child + /// every inheritable handle, the write end of that pipe among them, whatever is or is not + /// redirected. So a run that opened one of those tools did not return until the process it + /// had started was gone, and the Word comparer's stays until Word is closed. That is + /// https://github.com/VerifyTests/Verify/issues/1229 again, for the tools it was not fixed for. + /// + /// + /// Not through ShellExecute, which is how every other tool avoids it. A tool is declared + /// without it because ShellExecute would give it a console window: it is a console program, + /// or a script the command interpreter runs. That window can be asked for hidden, but the + /// request travels to the program as how its first window is to be shown, so a console + /// program that opens a window of its own - as tools built with Go, Rust or Java commonly + /// are - never appears. Nothing in the file says which kind of console program it is. + /// + /// + /// So this is the same call makes, with the one argument it + /// does not expose turned off. With no console as well, whatever the tool declared: a console + /// program that shares this process's console is given its standard handles along with it, + /// inherited or not, and those are the pipe. + /// + /// + public static int StartInheritingNothing(string exePath, string arguments) + { + var startupInfo = new STARTUPINFOW + { + cb = Marshal.SizeOf() + }; + // A buffer rather than a string, because CreateProcess is allowed to write to it + var commandLine = Marshal.StringToHGlobalUni(CommandLine(exePath, arguments)); + try + { + if (!CreateProcessW( + IntPtr.Zero, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + createNoWindow, + IntPtr.Zero, + IntPtr.Zero, + ref startupInfo, + out var process)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return process.dwProcessId; + } + finally + { + Marshal.FreeHGlobal(commandLine); + } + } + + /// + /// The quoted path and then the arguments, which is what builds + /// and what ProcessCleanup looks a running tool up by. + /// + internal static string CommandLine(string exePath, string arguments) + { + var quoted = exePath.StartsWith('"') && exePath.EndsWith('"') ? exePath : $"\"{exePath}\""; + if (arguments.Length == 0) + { + return quoted; + } + + return $"{quoted} {arguments}"; + } +} diff --git a/src/DiffEngine/Protocol/ListenerTable.cs b/src/DiffEngine/Protocol/ListenerTable.cs new file mode 100644 index 000000000..557b9bb5f --- /dev/null +++ b/src/DiffEngine/Protocol/ListenerTable.cs @@ -0,0 +1,63 @@ +using System.Net.NetworkInformation; + +namespace DiffEngine; + +/// +/// The operating system's table of listening TCP sockets, asked whether a port is in it. +/// +/// For a caller about to connect to a local port that may have nobody on it. Such a connect is not +/// refused at once everywhere: where the reset a closed port answers with is dropped, as Windows +/// Firewall's stealth mode drops it, the connect runs for two seconds. Reading the table takes a +/// third of a millisecond on a machine with a few dozen connections open. It is filtered out of +/// every connection the machine has, so that grows with them, and a caller with a cheaper way of +/// knowing somebody is there should use it first. +/// +/// +/// By port alone, whichever address the listener is bound to. The table is only ever believed +/// when it says nobody is there, so a listener on some address a loopback connect does not reach - +/// the other family's loopback, one interface - costs the caller the connect it would have made +/// anyway. A rule about addresses would have to be right for every family and socket option, and +/// where it was wrong it would cost a listener that was there: one bound to every address in dual +/// mode is listed on Windows under both families, and nothing says another platform lists it so. +/// +/// +static class ListenerTable +{ + /// + /// False only when the table was read and has no listener on . A table + /// that cannot be read says true, whatever stopped it being read, which leaves the connect to + /// decide as it did before anything asked here. + /// + public static bool IsHeld(int port) + { + try + { + return Lookup(port); + } + catch (Exception) + { + return true; + } + } + + /// + /// What asks. Replaced by the tests, for the two things about the table + /// that cannot be arranged or seen from outside: that it could not be read, and that it was + /// not asked at all. Given the port, so a test can answer for its own and pass every other + /// through: the tests beside it are asking about theirs at the same time. + /// + internal static Func Lookup { get; set; } = Listed; + + static bool Listed(int port) + { + foreach (var listener in IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners()) + { + if (listener.Port == port) + { + return true; + } + } + + return false; + } +} diff --git a/src/DiffEngine/Protocol/ViewerClient.cs b/src/DiffEngine/Protocol/ViewerClient.cs index 80dc66b1d..1b604377d 100644 --- a/src/DiffEngine/Protocol/ViewerClient.cs +++ b/src/DiffEngine/Protocol/ViewerClient.cs @@ -94,59 +94,102 @@ public static int Port /// connecting to nobody, and the same run took under a second with a tray answering. /// /// - /// Long, because a recheck on such a machine costs those two seconds again and buys almost - /// nothing. What the memory can delay is only a message the owner did not have to receive: - /// an entry to settle in a queue that did not exist when the test failed, or a move to track - /// in a tray that was not there to track it. Anything that has to reach an owner - a patch, a - /// delete, a pair - goes through the launch gate, whose probe always - /// connects and corrects the memory with what it finds. Not the life of the process only for - /// a long lived consumer that is not a test host, launching diff tools all day, where a tray - /// started later would otherwise never see its moves until a restart. + /// Long, because a recheck buys almost nothing and is not free: those two seconds again where + /// it has to connect, and a read of the listener table where the operating system can be asked + /// instead (). What the memory can delay is only a message the + /// owner did not have to receive: an entry to settle in a queue that did not exist when the + /// test failed, or a move to track in a tray that was not there to track it. Anything that has + /// to reach an owner - a patch, a delete, a pair - goes through the launch gate, whose + /// probe always asks and corrects the memory with what it finds. Not the + /// life of the process only for a long lived consumer that is not a test host, launching diff + /// tools all day, where a tray started later would otherwise never see its moves until a + /// restart. /// /// internal static TimeSpan RecheckUnownedAfter { get; set; } = TimeSpan.FromMinutes(10); /// - /// When each port was last found unowned, as a timestamp. Per port - /// because tests talk to ephemeral ports of their own, in parallel, and what happened on + /// How long a port that accepted a connection is connected to again without first asking the + /// operating system whether anyone is there: see . + /// + /// For a run of settles to an owner that is there, which is a connection every third of a + /// millisecond. Each renews this, so none of them reads the listener table, which costs more + /// than the connect does. Short, because a send that comes a while after the last is the one + /// most likely to find its owner gone, and asking is what spares that send its two seconds. + /// + /// + internal static TimeSpan TrustOwnerFor { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// What was last found on each port, and when, as a timestamp. Per + /// port because tests talk to ephemeral ports of their own, in parallel, and what happened on /// those says nothing about the one live port on a developer machine. /// - static ConcurrentDictionary unownedAt = new(); + static ConcurrentDictionary lastFound = new(); - static bool RecentlyUnowned(int port) - { - if (!unownedAt.TryGetValue(port, out var at)) - { - return false; - } + static bool RecentlyUnowned(int port) => + lastFound.TryGetValue(port, out var found) && + !found.Owned && + Since(found.At) < RecheckUnownedAfter; - var elapsed = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - at) / (double) Stopwatch.Frequency); - return elapsed < RecheckUnownedAfter; - } + static bool AnsweredLately(int port) => + lastFound.TryGetValue(port, out var found) && + found.Owned && + Since(found.At) < TrustOwnerFor; + + static TimeSpan Since(long timestamp) => + TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - timestamp) / (double) Stopwatch.Frequency); /// - /// What a connect found, reported by every connect here whether or not its caller consulted - /// the memory first. An owner found by a probe or a listing is one every later send can talk - /// to, and a refusal met by any of them is what the memory is for. + /// What was found on a port, reported by everything here that looks whether or not its caller + /// consulted the memory first. An owner found by a probe or a listing is one every later send + /// can talk to, and a port any of them found empty is what the memory is for. /// - static void Found(int port, bool owned) - { - if (owned) - { - unownedAt.TryRemove(port, out _); - } - else - { - unownedAt[port] = Stopwatch.GetTimestamp(); - } - } + static void Found(int port, bool owned) => + lastFound[port] = (owned, Stopwatch.GetTimestamp()); + + /// + /// Whether a connect to would only be a wait to be refused: the + /// operating system has no listener on the port, so nobody is there to accept one. + /// + /// Asked in front of every connect here. The refusal is what was expensive: two seconds for a + /// send, and half a second each time the launch gate probed, which with nothing to launch was + /// half a second with the gate held, and again for every poll while a viewer it had started + /// was still binding. The memory above spares a process all but the first of its telling + /// sends, and nothing spared it that one, or any probe, or a host that asks. + /// + /// + /// A listener that binds a moment after the table was read is missed, exactly as it is by a + /// connect a moment early, and is found the same way: nothing that asks is answered from the + /// memory, so the gate's next poll reads the table again. + /// + /// + /// Only on Windows, which is where the refusal is slow. Elsewhere it arrives at once, so the + /// connect is the cheaper question as well as the one whose answer cannot be wrong. And not + /// for a port that accepted a connection within , where the table + /// would cost more than the connect it stands in front of. + /// + /// + static bool NothingListening(int port) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + !AnsweredLately(port) && + !ListenerTable.IsHeld(port); + + /// + /// Whether the port stands as unowned: nothing was listening when it was last looked at, or + /// what answered was not a viewer. For a caller whose exchange has just failed and has to tell + /// an owner that did not answer from there being none, which a failed exchange reports the + /// same way. The exchange is what makes this current, since every one records what it found. + /// + public static bool FoundUnowned(int? port = null) => + RecentlyUnowned(port ?? Port); /// /// For tests, which share this process and its memory with every other test's ports. /// internal static void ForgetUnowned() { - unownedAt.Clear(); + lastFound.Clear(); reportedForeign.Clear(); } @@ -203,7 +246,7 @@ static void NotAViewer(int port, string reply) /// started a viewer and wants to know when it can be talked to, which a send cannot answer /// without also handing over work. /// - /// Always connects, and what it finds corrects the memory behind + /// Always asks, and what it finds corrects the memory behind /// : this is the probe the launch gate runs once a viewer /// is started, and the sends queued behind that gate have to reach the viewer it found. /// @@ -211,6 +254,12 @@ static void NotAViewer(int port, string reply) public static bool IsOwned(int? port = null) { var endpointPort = port ?? Port; + if (NothingListening(endpointPort)) + { + Found(endpointPort, false); + return false; + } + bool owned; try { @@ -269,6 +318,12 @@ public static bool TrySend( return false; } + if (NothingListening(endpointPort)) + { + Found(endpointPort, false); + return false; + } + var deadline = wait ?? timeout; var connected = false; try @@ -356,6 +411,15 @@ public static async Task SendAsync( return SendOutcome.NoOwner; } + // A send the caller has already cancelled is left to the connect, which is where each + // framework says so in its own way + if (!cancel.IsCancellationRequested && + NothingListening(endpointPort)) + { + Found(endpointPort, false); + return SendOutcome.NoOwner; + } + var timeToWait = wait ?? asyncTimeout; using var deadline = CancelSource.CreateLinkedTokenSource(cancel); deadline.CancelAfter(timeToWait); diff --git a/src/DiffEngine/TextDiff/LineDiff.cs b/src/DiffEngine/TextDiff/LineDiff.cs index cb3f8af23..fcfc93206 100644 --- a/src/DiffEngine/TextDiff/LineDiff.cs +++ b/src/DiffEngine/TextDiff/LineDiff.cs @@ -66,7 +66,7 @@ public static LineDiff Build(string expected, string received) { changed.Clear(); new LineInterner(expected, expectedLines, received, receivedLines).Intern(ids[..n], ids[n..]); - MyersDiff.Diff(ids[..n], ids[n..], changed[..n], changed[n..]); + DiffShared(ids[..n], ids[n..], changed[..n], changed[n..]); var entries = Walk(changed[..n], changed[n..]); return new(expected, received, expectedLines, receivedLines, entries); } @@ -84,6 +84,146 @@ public static LineDiff Build(string expected, string received) } } + /// + /// Diffs the lines both sides have, having marked every other line as changed without asking. + /// + /// A line only one side has cannot be unchanged, so it is no part of the question Myers + /// answers, and leaving it in was what made the answer expensive. Myers costs by the number of + /// edits, and two texts with no line in common are edits and nothing else: 40,000 lines a side + /// took four seconds. That is not a rare input. A serializer setting that changes the + /// indentation makes it out of any large snapshot. With those lines taken out first there is + /// nothing left to search, and the same goes for the usual failure, a few lines that differ in + /// a text that otherwise matches. + /// + /// + /// The result is no worse for it. The longest run of lines the two sides share is the same + /// with or without the lines that could never be in it, so as many lines are unchanged as + /// before. + /// + /// + /// Which lines those are is already in the ids. gives a line the + /// index of the first line with its content, counting the expected lines first, so a received + /// line whose id is past the expected lines is in none of them, and one pass over the received + /// ids says which expected lines are in neither. + /// + /// + /// The ids are compacted where they are, since they are this diff's own and are not read + /// again. + /// + /// + static void DiffShared(Span expectedIds, Span receivedIds, Span changedExpected, Span changedReceived) + { + var n = expectedIds.Length; + bool[]? rentedShared = null; + var shared = n <= MyersDiff.StackLimit + ? stackalloc bool[n] + : (rentedShared = ArrayPool.Shared.Rent(n)).AsSpan(0, n); + shared.Clear(); + var keptReceived = 0; + foreach (var id in receivedIds) + { + if (id < n) + { + shared[id] = true; + keptReceived++; + } + } + + var keptExpected = 0; + foreach (var id in expectedIds) + { + if (shared[id]) + { + keptExpected++; + } + } + + var kept = keptExpected + keptReceived; + int[]? rentedOrigins = null; + bool[]? rentedFlags = null; + // Where each line that is kept came from, the expected ones and then the received. + var origins = kept <= MyersDiff.StackLimit + ? stackalloc int[kept] + : (rentedOrigins = ArrayPool.Shared.Rent(kept)).AsSpan(0, kept); + var flags = kept <= MyersDiff.StackLimit + ? stackalloc bool[kept] + : (rentedFlags = ArrayPool.Shared.Rent(kept)).AsSpan(0, kept); + try + { + flags.Clear(); + var next = 0; + for (var index = 0; index < n; index++) + { + var id = expectedIds[index]; + if (shared[id]) + { + expectedIds[next] = id; + origins[next] = index; + next++; + } + else + { + changedExpected[index] = true; + } + } + + next = 0; + for (var index = 0; index < receivedIds.Length; index++) + { + var id = receivedIds[index]; + if (id < n) + { + receivedIds[next] = id; + origins[keptExpected + next] = index; + next++; + } + else + { + changedReceived[index] = true; + } + } + + MyersDiff.Diff( + expectedIds[..keptExpected], + receivedIds[..keptReceived], + flags[..keptExpected], + flags[keptExpected..]); + + for (var index = 0; index < keptExpected; index++) + { + if (flags[index]) + { + changedExpected[origins[index]] = true; + } + } + + for (var index = keptExpected; index < kept; index++) + { + if (flags[index]) + { + changedReceived[origins[index]] = true; + } + } + } + finally + { + if (rentedShared != null) + { + ArrayPool.Shared.Return(rentedShared); + } + + if (rentedOrigins != null) + { + ArrayPool.Shared.Return(rentedOrigins); + } + + if (rentedFlags != null) + { + ArrayPool.Shared.Return(rentedFlags); + } + } + } + /// /// Walks both sides together. Lines neither side changed are matched in order, so the /// unchanged lines pair up and everything between them is one changed block. diff --git a/src/DiffEngine/TextDiff/MyersDiff.cs b/src/DiffEngine/TextDiff/MyersDiff.cs index 6d3425367..5d28ea463 100644 --- a/src/DiffEngine/TextDiff/MyersDiff.cs +++ b/src/DiffEngine/TextDiff/MyersDiff.cs @@ -9,6 +9,16 @@ /// Written over spans, so each recursion is a slice of the same id and flag buffers rather than a /// copy, and the two diagonal vectors are allocated once for the whole diff. /// +/// +/// Minimal, for as long as that is affordable. A search for one middle snake costs by the square +/// of the edits it has to look through, and for two long sequences that share their elements in +/// another order that is all of them: 40,000 a side took four seconds. So a diff has a budget of +/// that work (), and a search that would take it past the budget stops and +/// splits at the furthest point either of its paths got to. Any point is a correct place to +/// split, and that one is where the most has been passed. A diff that got that far may leave +/// fewer elements unchanged than it could have. Nothing the search used to do in a tenth of a +/// second gets that far, so what was quick is exactly as it was. +/// /// static class MyersDiff { @@ -17,7 +27,46 @@ static class MyersDiff /// public const int StackLimit = 256; - public static void Diff(ReadOnlySpan a, ReadOnlySpan b, Span changedA, Span changedB) + /// + /// How much searching one diff may do before it settles, counted as each search's depth in + /// edits, squared, which is the diagonals that search looks at. + /// + /// Sixty seven million of them, which is about a sixth of a second. That is one search 8,000 + /// edits deep, so a block of 8,000 elements moved from one end of a sequence to the other is + /// still found, however long the sequence. And it is more than any two sequences of 10,000 + /// elements between them can need, whatever is in them, so those are always diffed minimally. + /// + /// + internal const long Budget = 1L << 26; + + /// + /// The depth every search is allowed once the budget has gone: enough to get past a few + /// hundred scattered edits and split where they have been passed. A search that settles has + /// moved on by at least its depth, so this times the length is all the rest of the diff can + /// cost. + /// + internal const int MinimumDepth = 256; + + public static void Diff(ReadOnlySpan a, ReadOnlySpan b, Span changedA, Span changedB) => + Diff(a, b, changedA, changedB, Budget, MinimumDepth); + + /// One sequence. + /// The other. + /// Set for each element of with no match. + /// Set for each element of with no match. + /// + /// The searching to do before settling. Given by the tests, along with + /// , which is the only way a search that settles can be had on + /// sequences small enough to check by other means. + /// + /// The depth a search is allowed with nothing left of the budget. + internal static void Diff( + ReadOnlySpan a, + ReadOnlySpan b, + Span changedA, + Span changedB, + long budget, + int minimumDepth) { var length = VectorLength(a.Length, b.Length); int[]? rentedForward = null; @@ -30,7 +79,7 @@ public static void Diff(ReadOnlySpan a, ReadOnlySpan b, Span cha : (rentedReverse = ArrayPool.Shared.Rent(length)).AsSpan(0, length); try { - Recurse(a, b, changedA, changedB, forward, reverse); + Recurse(a, b, changedA, changedB, forward, reverse, ref budget, Math.Max(1, minimumDepth)); } finally { @@ -50,8 +99,10 @@ static int VectorLength(int n, int m) => 2 * ((n + m + 1) / 2) + 2; /// - /// Recurses into the half before the middle snake and loops on the half after it, so the - /// stack only grows with the halves still waiting on their second part. + /// Recurses into the smaller of the two halves a split leaves and loops on the larger, so the + /// stack is never deeper than the number of times the problem can be halved. Which half that + /// is no longer follows from where the split is: one a search settled for can leave almost + /// everything on either side of it. /// static void Recurse( ReadOnlySpan a, @@ -59,7 +110,9 @@ static void Recurse( Span changedA, Span changedB, Span forward, - Span reverse) + Span reverse, + ref long budget, + int minimumDepth) { while (true) { @@ -101,18 +154,36 @@ static void Recurse( return; } - if (!TryMiddleSnake(a, b, forward, reverse, out var x, out var y)) + // As deep as what is left of the budget buys, and never less than the minimum. The + // whole of the grid when that is within reach, which is a search that cannot settle. + var deepest = (a.Length + b.Length + 1) / 2; + var affordable = budget <= 0 ? 0 : (long) Math.Sqrt(budget); + var limit = (int) Math.Min(deepest, Math.Max(minimumDepth, affordable)); + var found = TryMiddleSnake(a, b, forward, reverse, limit, out var x, out var y, out var depth); + budget -= (long) depth * depth; + if (!found) { changedA.Fill(true); changedB.Fill(true); return; } - Recurse(a[..x], b[..y], changedA[..x], changedB[..y], forward, reverse); - a = a[x..]; - b = b[y..]; - changedA = changedA[x..]; - changedB = changedB[y..]; + if (x + y <= a.Length - x + b.Length - y) + { + Recurse(a[..x], b[..y], changedA[..x], changedB[..y], forward, reverse, ref budget, minimumDepth); + a = a[x..]; + b = b[y..]; + changedA = changedA[x..]; + changedB = changedB[y..]; + } + else + { + Recurse(a[x..], b[y..], changedA[x..], changedB[y..], forward, reverse, ref budget, minimumDepth); + a = a[..x]; + b = b[..y]; + changedA = changedA[..x]; + changedB = changedB[..y]; + } } } @@ -126,24 +197,37 @@ static void Recurse( /// for the overlap depends on the parity of the length difference, because only then can the /// two land on the same diagonal after the same number of steps. /// + /// + /// After edits without an overlap the search settles for + /// . False when there is nowhere to split, which leaves everything in + /// both sequences changed. is how many edits deep it went either way, + /// which is what it is charged for. + /// /// static bool TryMiddleSnake( ReadOnlySpan a, ReadOnlySpan b, Span forward, Span reverse, + int limit, out int splitX, - out int splitY) + out int splitY, + out int depth) { var n = a.Length; var m = b.Length; var maxD = (n + m + 1) / 2; var offset = maxD; - var length = VectorLength(n, m); - forward = forward[..length]; - reverse = reverse[..length]; - forward.Fill(-1); - reverse.Fill(-1); + // Only the diagonals this search can get to are cleared and read, the ones within its + // deepest edit of the middle and one more either side for the neighbours each step reads. + // Clearing the vectors whole cost by the length for every split, and a diff that settles + // makes a split every few hundred elements: that was the length squared again by another + // road. What lies outside holds whatever an earlier search left, so nothing may read it. + var reach = Math.Min(maxD, limit + 1); + var low = offset - reach; + var high = offset + reach; + forward[low..(high + 1)].Fill(-1); + reverse[low..(high + 1)].Fill(-1); forward[offset + 1] = 0; reverse[offset + 1] = 0; @@ -193,13 +277,14 @@ static bool TryMiddleSnake( else if (front) { var reverseIndex = offset + delta - k; - if (reverseIndex >= 0 && - reverseIndex < length && + if (reverseIndex >= low && + reverseIndex <= high && reverse[reverseIndex] != -1 && x >= n - reverse[reverseIndex]) { splitX = x; splitY = y; + depth = d + 1; return true; } } @@ -240,8 +325,8 @@ static bool TryMiddleSnake( else if (!front) { var forwardIndex = offset + delta - k; - if (forwardIndex >= 0 && - forwardIndex < length && + if (forwardIndex >= low && + forwardIndex <= high && forward[forwardIndex] != -1) { var forwardX = forward[forwardIndex]; @@ -249,15 +334,172 @@ static bool TryMiddleSnake( { splitX = forwardX; splitY = forwardX - (forwardIndex - offset); + depth = d + 1; return true; } } } } + + if (d >= limit) + { + depth = d + 1; + var furthest = TryFurthest(n, m, d, offset, forward, reverse, out splitX, out splitY, out var matched); + if (TryDisplaced(a, b, depth, matched, out var displacedX, out var displacedY)) + { + splitX = displacedX; + splitY = displacedY; + return true; + } + + return furthest; + } } splitX = 0; splitY = 0; + depth = maxD; + return false; + } + + /// + /// A run of this many elements found by is taken as the sequences + /// lining up there rather than as a line that happens to repeat. + /// + const int DisplacedRun = 16; + + /// + /// As far as a run is followed to see how long it is. Past this it is long enough, and the + /// rest of it is walked once, by whoever is handed the split. + /// + const int RunCap = 4096; + + /// + /// A better place to split than the furthest point, when a search settled having found next + /// to nothing: where one sequence's first element is in the other, if a run of matches + /// follows from there. + /// + /// A search only looks within its depth of the diagonal it started on. A block of elements + /// moved further than that, from one end of a sequence to the other, lines up on a diagonal it + /// never reaches, so it passes nothing, every point it got to is as good as every other, and + /// the split it settles for is as likely to walk away from where the sequences line up as + /// towards it. Splitting by the furthest point alone, 20,000 lines moved within 400,000 came + /// out as the 380,000 that had not moved being removed and added again. The start of each + /// sequence is somewhere in the other, since elements only one has were set aside before the + /// diff began, and looking for it is one scan. + /// + /// + /// The nearer of the two when both lead to a run, since that is the smaller block to call + /// changed. Neither when the search itself passed more than the run found, which is edits + /// scattered along a diagonal it was already following. + /// + /// + static bool TryDisplaced(ReadOnlySpan a, ReadOnlySpan b, int depth, int matched, out int splitX, out int splitY) + { + splitX = 0; + splitY = 0; + // No further than the search looked, which was its depth squared, so a scan never costs + // more than the search it follows did + var window = (int) Math.Min(int.MaxValue, (long) depth * depth); + var inA = a[..Math.Min(a.Length, window)].IndexOf(b[0]); + var inB = b[..Math.Min(b.Length, window)].IndexOf(a[0]); + var runA = inA > 0 ? Run(a[inA..], b) : 0; + var runB = inB > 0 ? Run(a, b[inB..]) : 0; + var needed = Math.Max(DisplacedRun, matched + 1); + var fromA = runA >= needed; + var fromB = runB >= needed; + if (fromA && + (!fromB || inA <= inB)) + { + splitX = inA; + return true; + } + + if (fromB) + { + splitY = inB; + return true; + } + return false; } + + static int Run(ReadOnlySpan a, ReadOnlySpan b) + { + var length = Math.Min(RunCap, Math.Min(a.Length, b.Length)); + var run = 0; + while (run < length && + a[run] == b[run]) + { + run++; + } + + return run; + } + + /// + /// Where to split when the search is not to go on: the point furthest along that either path + /// reached, counted from the corner that path set out from. The elements between that corner + /// and the point are a small diff, already known to be a few edits, and the rest is searched + /// again from there. + /// + /// Any point inside the grid splits the problem into two that together are a correct diff, so + /// nothing here can produce a wrong one. What is lost is that the two together need not be the + /// smallest. A corner is the one point that will not do, since one of its halves is the whole + /// problem again. + /// + /// + /// is how many elements the path to that point passed as the same + /// on both sides: what it got to, less the edits it took, on each side. + /// + /// + static bool TryFurthest( + int n, + int m, + int d, + int offset, + ReadOnlySpan forward, + ReadOnlySpan reverse, + out int splitX, + out int splitY, + out int matched) + { + splitX = 0; + splitY = 0; + var best = 0; + for (var k = -d; k <= d; k++) + { + // A diagonal that ran off the grid holds a point outside it, and one never reached + // holds -1. Neither is somewhere to split. + var x = forward[offset + k]; + var y = x - k; + if (x >= 0 && + y >= 0 && + x <= n && + y <= m && + x + y > best) + { + best = x + y; + splitX = x; + splitY = y; + } + + x = reverse[offset + k]; + y = x - k; + if (x >= 0 && + y >= 0 && + x <= n && + y <= m && + x + y > best) + { + best = x + y; + splitX = n - x; + splitY = m - y; + } + } + + matched = Math.Max(0, best - d) / 2; + return best > 0 && + best < n + m; + } } diff --git a/src/DiffEngine/TextDiff/TextDiff.cs b/src/DiffEngine/TextDiff/TextDiff.cs index 610403b56..265bb58d6 100644 --- a/src/DiffEngine/TextDiff/TextDiff.cs +++ b/src/DiffEngine/TextDiff/TextDiff.cs @@ -3,6 +3,14 @@ namespace DiffEngine; /// /// A line diff of an expected and a received text. Lines compare exactly, whitespace and case /// included, and split on \r\n, \r or \n. +/// +/// As few lines as possible are reported as changed, with one exception: two texts of more than +/// 10,000 lines between them that share thousands of lines in a different order, 8,000 or more +/// of them out of place. Finding the fewest there takes from a fraction of a second to minutes, +/// so past about a sixth of a second of looking the diff settles for one that is correct, each +/// side being exactly its lines in order, and may report a line as changed that it could have +/// matched. +/// /// public static class TextDiff { diff --git a/src/DiffEngine/Tray/PendingFiles.cs b/src/DiffEngine/Tray/PendingFiles.cs index 1293cce79..14a01daac 100644 --- a/src/DiffEngine/Tray/PendingFiles.cs +++ b/src/DiffEngine/Tray/PendingFiles.cs @@ -152,6 +152,11 @@ public static LaunchResult AddDiff(ResolvedTool tool, string tempFile, string ta /// A capped one reports what every other tool's does, rather than being folded in with a tool /// that could not be found: the pair has a tool and the cap is why no window opened. /// + /// + /// A viewer that was started and exited with a failure is folded in with it. The copy that + /// resolved cannot be run, which from here is a tool that is not there, and it used to be + /// reported as a new instance with no window behind it. + /// /// static LaunchResult Launched(ViewerLaunchOutcome outcome) => outcome switch diff --git a/src/DiffEngine/Tray/PiperClient.cs b/src/DiffEngine/Tray/PiperClient.cs index 7dfc04c1d..4899c680e 100644 --- a/src/DiffEngine/Tray/PiperClient.cs +++ b/src/DiffEngine/Tray/PiperClient.cs @@ -1,5 +1,4 @@ -using System.Net.NetworkInformation; -static class PiperClient +static class PiperClient { public static int Port = 3492; @@ -221,24 +220,8 @@ static async Task InnerSendAsync(string payload, Cancel cancel) /// is dropped it runs to its timeout, two seconds a send. The listener table answers in well /// under a millisecond. A table that cannot be read leaves the connect to decide. /// - static bool PortIsHeld() - { - try - { - var port = Port; - return IPGlobalProperties.GetIPGlobalProperties() - .GetActiveTcpListeners() - .Any(_ => _.Port == port); - } - catch (NetworkInformationException) - { - return true; - } - catch (PlatformNotSupportedException) - { - return true; - } - } + static bool PortIsHeld() => + ListenerTable.IsHeld(Port); static IPEndPoint GetEndpoint() => new(IPAddress.Loopback, Port); diff --git a/src/DiffEngine/Viewer/FallbackViewerDirectories.cs b/src/DiffEngine/Viewer/FallbackViewerDirectories.cs index 4949b32b8..04f8fc934 100644 --- a/src/DiffEngine/Viewer/FallbackViewerDirectories.cs +++ b/src/DiffEngine/Viewer/FallbackViewerDirectories.cs @@ -7,7 +7,9 @@ namespace DiffEngine; /// The bundled lookup depends on buildTransitive/DiffEngine.targets having stamped the package /// path into the consuming project, which does not happen for every project shape, so the cache is /// searched as well. The cache's copy of this library's own version is tried before any other -/// version, which is as close to version matched as a fallback gets. +/// version, which is as close to version matched as a fallback gets. The others are tried highest +/// first: the wildcard that stands for them is resolved by version where the folders are named +/// for one, rather than by which was restored last. /// /// /// Written with environment variables rather than resolved, like every other search directory, so diff --git a/src/DiffEngine/Viewer/ViewerContract.cs b/src/DiffEngine/Viewer/ViewerContract.cs new file mode 100644 index 000000000..153d19e4a --- /dev/null +++ b/src/DiffEngine/Viewer/ViewerContract.cs @@ -0,0 +1,85 @@ +namespace DiffEngine; + +/// +/// Whether a copy of the viewer is new enough to be started the way +/// starts one. +/// +/// The copy that resolves is not always the one this library was built beside. A globally +/// installed tool and the copy shipped with a tray are both looked for ahead of the bundled one, +/// and either can be older than the library about to launch it. Two things a viewer is launched +/// with arrived together in 20.5.0: the patch in a --payload file, which a viewer from +/// before then exits on as an argument it does not know, and ShellExecute, which gives a viewer +/// from before then - a console executable - a console window to sit beside its own. +/// +/// +/// Resolution passes over a copy known to be older when a newer one is there to be found, and +/// still takes it when it is the only one: for everything but an inline patch an older viewer is +/// better than none, and reports the launch it fails. +/// +/// +/// A copy that does not say what version it is counts as new enough, since passing it over would +/// be acting on nothing. The version read is the one stamped on the executable, or on the assembly +/// beside it where the executable carries none, which is every apphost off Windows. +/// +/// +static class ViewerContract +{ + /// + /// Two parts, so that it is not above a version written with only two. + /// + static readonly Version first = new(20, 5); + + public static bool IsMet(string executable) => + IsMetBy(ProductVersion(executable)); + + /// + /// By the numbers alone. What follows them is a prerelease label or the source revision the + /// SDK appends, and neither changes which side of 20.5 a copy is on. + /// + internal static bool IsMetBy(string? productVersion) + { + if (productVersion is null) + { + return true; + } + + var end = productVersion.IndexOfAny(['+', '-']); + var numbers = end < 0 ? productVersion : productVersion.Substring(0, end); + if (!Version.TryParse(numbers, out var version)) + { + return true; + } + + return version >= first; + } + + /// + /// Null for a copy that carries no version, and for one that cannot be read. This runs while + /// DiffTools initialises, where anything thrown is a TypeInitializationException for every + /// later use of it in the process, so nothing is allowed out. + /// + internal static string? ProductVersion(string executable) + { + try + { + var version = FileVersionInfo.GetVersionInfo(executable).ProductVersion; + if (!string.IsNullOrWhiteSpace(version)) + { + return version; + } + + var assembly = Path.ChangeExtension(executable, ".dll"); + if (!File.Exists(assembly)) + { + return null; + } + + version = FileVersionInfo.GetVersionInfo(assembly).ProductVersion; + return string.IsNullOrWhiteSpace(version) ? null : version; + } + catch + { + return null; + } + } +} diff --git a/src/DiffEngine/Viewer/ViewerLaunchGate.cs b/src/DiffEngine/Viewer/ViewerLaunchGate.cs index d44043800..a1a3735ae 100644 --- a/src/DiffEngine/Viewer/ViewerLaunchGate.cs +++ b/src/DiffEngine/Viewer/ViewerLaunchGate.cs @@ -17,7 +17,8 @@ enum ViewerLaunchOutcome Launched, /// - /// Nothing could be started, and nobody was there to take it. + /// Nothing could be started, or what was started exited with a failure before anything held + /// the queue, and nobody was there to take it. /// Failed, @@ -72,8 +73,9 @@ static class ViewerLaunchGate /// /// How long the caller that launched holds the gate waiting for its viewer to answer. Long - /// enough for a cold start with an antivirus in the way; one that never binds costs a single - /// caller this wait, and then the next tries again. + /// enough for a cold start with an antivirus in the way; one that is running and never binds + /// costs a single caller this wait, and then the next tries again. One that has exited with a + /// failure costs only as long as that takes to notice. /// internal static TimeSpan BindWait { get; set; } = TimeSpan.FromSeconds(5); @@ -82,7 +84,11 @@ static class ViewerLaunchGate /// and takes a round trip: nineteen of those queued behind one another cost more than the /// nineteen processes this exists to avoid. /// - /// Starts a viewer. False when nothing could be started. + /// + /// Starts a viewer and hands back its process, which is how the wait tells one that has given + /// up from one that is only slow. Null when nothing could be started. Disposed here once the + /// wait is over. + /// /// /// How the gate asks whether anyone holds the queue, which is also what it waits on after a /// launch. Defaults to the real port. Supplied by the tests, which otherwise have to arrange @@ -96,7 +102,7 @@ static class ViewerLaunchGate /// public static ViewerLaunchOutcome Launch( Func retry, - Func launch, + Func launch, Func? isOwned = null, Func? canLaunch = null) { @@ -116,12 +122,12 @@ public static ViewerLaunchOutcome Launch( return ViewerLaunchOutcome.Capped; } - if (!launch()) + using var viewer = launch(); + if (viewer is null || + !WaitForBind(viewer, isOwned)) { return ViewerLaunchOutcome.Failed; } - - WaitForBind(isOwned); } } finally @@ -150,7 +156,7 @@ public static ViewerLaunchOutcome Launch( /// public static async Task LaunchAsync( Func> retry, - Func> launch, + Func> launch, Cancel cancel, Func? isOwned = null, Func? canLaunch = null) @@ -169,12 +175,12 @@ public static async Task LaunchAsync( return ViewerLaunchOutcome.Capped; } - if (!await Task.Run(launch, cancel).ConfigureAwait(false)) + using var viewer = await Task.Run(launch, cancel).ConfigureAwait(false); + if (viewer is null || + !await WaitForBindAsync(viewer, isOwned, cancel).ConfigureAwait(false)) { return ViewerLaunchOutcome.Failed; } - - await WaitForBindAsync(isOwned, cancel).ConfigureAwait(false); } } finally @@ -195,34 +201,85 @@ public static async Task LaunchAsync( /// an owner rather than starting another. Gives up after and reports /// the launch all the same, because it did happen: the work went over on the command line or /// in a payload file, and the cost of giving up early is one more viewer, which is where this began. + /// + /// False when the viewer gave up first, which is the one launch that did not happen. It used to + /// be waited on for the whole of with the gate held and then reported + /// like any other, so an inline snapshot was said to be queued when it was nowhere: not in a + /// queue, and not staged either, since a caller stages only what it is told nobody took. + /// /// - static void WaitForBind(Func isOwned) + static bool WaitForBind(Process viewer, Func isOwned) { var elapsed = Stopwatch.StartNew(); while (elapsed.Elapsed < BindWait) { + // Before the probe, so that an owner some other process started is not taken for the + // viewer this one did: that owner was never handed the work + if (GaveUp(viewer)) + { + return false; + } + if (isOwned()) { - return; + return true; } Thread.Sleep(poll); } + + return true; } /// - static async Task WaitForBindAsync(Func isOwned, Cancel cancel) + static async Task WaitForBindAsync(Process viewer, Func isOwned, Cancel cancel) { var elapsed = Stopwatch.StartNew(); while (elapsed.Elapsed < BindWait) { + if (GaveUp(viewer)) + { + return false; + } + if (isOwned()) { - return; + return true; } await Task.Delay(poll, cancel).ConfigureAwait(false); } + + return true; + } + + /// + /// Whether the viewer this call started has exited and said it failed, so it took nothing and + /// never will. A copy from before the arguments it was given exits on the first it does not + /// know, and an apphost with no runtime to run exits before any of the viewer's own code. + /// + /// A clean exit is not this, and is left to the wait. A viewer that finds the port already + /// bound hands its work to whoever holds it and exits with zero, and the next probe finds that + /// owner. One that opened, was dealt with and closed between two probes exits with zero too, + /// having staged whatever it still held. + /// + /// + /// A process that cannot be asked is taken to be running, which is the answer that leaves the + /// wait as it was before there was a process to ask. + /// + /// + static bool GaveUp(Process viewer) + { + try + { + return viewer.HasExited && + viewer.ExitCode != 0; + } + catch (Exception exception) + when (exception is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException) + { + return false; + } } static TimeSpan poll = TimeSpan.FromMilliseconds(50); diff --git a/src/DiffEngine/Viewer/ViewerLauncher.cs b/src/DiffEngine/Viewer/ViewerLauncher.cs index 1079f0067..10059a415 100644 --- a/src/DiffEngine/Viewer/ViewerLauncher.cs +++ b/src/DiffEngine/Viewer/ViewerLauncher.cs @@ -16,17 +16,37 @@ namespace DiffEngine; /// close-on-exec, so only the three standard streams pass to a child, and redirecting all three /// and closing this side of them is enough. /// +/// +/// Nor is the host's working directory, which a child takes unless it is given another. For a +/// test host that is usually the test project's output folder, and on Windows a directory some +/// process is in cannot be deleted. A viewer hidden behind a tray lives for the session, so +/// git clean -xdf, or removing a worktree, failed with nothing on screen to say what was +/// holding it. A viewer is started in its own folder instead, which it holds by running from it +/// whatever its working directory is. +/// /// static class ViewerLauncher { + /// + /// Where the patch for one inline launch goes. Named by the caller rather than inside + /// , because the caller is the one told that the viewer went without + /// reading it, and has to know what to take back: see . + /// + public static string PayloadFile() => + Path.Combine(Path.GetTempPath(), $"DiffEngineViewer_{Guid.NewGuid():N}.inlinepatch"); + /// /// Starts a viewer with a patch, which goes in a file rather than on stdin: a launch that /// redirects stdin cannot use ShellExecute (see the class remarks). The viewer reads the file /// and deletes it. + /// + /// The process is handed back, as it is from every launch here that goes through + /// , which is what lets the gate see a viewer exit rather than + /// wait for one that has already gone. + /// /// - public static async Task LaunchAsync(InlinePatch patch, string payload, Cancel cancel) + public static async Task LaunchAsync(InlinePatch patch, string payload, string file, Cancel cancel) { - var file = Path.Combine(Path.GetTempPath(), $"DiffEngineViewer_{Guid.NewGuid():N}.inlinepatch"); try { // Bytes rather than text, so no preamble: a BOM is exactly what a .NET Framework @@ -44,17 +64,17 @@ public static async Task LaunchAsync(InlinePatch patch, string payload, Ca when (exception is IOException or UnauthorizedAccessException) { Trace.WriteLine($"Failed to write the inline patch for DiffEngineViewer: {exception}"); - TryDelete(file); - return false; + Discard(file); + return null; } - if (Start(PayloadArguments(patch, file)) is null) + var viewer = Start(PayloadArguments(patch, file)); + if (viewer is null) { - TryDelete(file); - return false; + Discard(file); } - return true; + return viewer; } /// @@ -65,7 +85,14 @@ public static async Task LaunchAsync(InlinePatch patch, string payload, Ca internal static string PayloadArguments(InlinePatch patch, string file) => $"--inline --source \"{patch.SourceFile}\" --line {patch.LineHint} --payload \"{file}\""; - static void TryDelete(string file) + /// + /// Removes a payload file that no viewer is going to read: nothing was started, or what was + /// started has exited. A viewer deletes the one it reads, so this only finds a file where the + /// viewer never got that far - a copy that does not know --payload, or one that could + /// not run at all - and each of those used to leave a snapshot's worth of text in the temp + /// directory for good. + /// + public static void Discard(string file) { try { @@ -98,16 +125,44 @@ static void TryDelete(string file) /// the winner and exits, which is the same resolution a second inline viewer reaches. /// /// - public static bool LaunchDelete(string file) => - Start($"--delete \"{file}\"") is not null; + public static Process? LaunchDelete(string file) => + Start($"--delete \"{Rooted(file)}\""); /// /// Starts a viewer holding one failing pair, for when the tool resolved for that pair is the /// viewer itself and nothing owns the queue. The same launch makes, /// for the same reason: the pair joins a queue that later pairs can join too. /// - public static bool LaunchDiff(string temp, string target) => - Start(DiffArguments(temp, target)) is not null; + public static Process? LaunchDiff(string temp, string target) => + Start(DiffArguments(Rooted(temp), Rooted(target))); + + /// + /// A path as the viewer has to be handed it now that it no longer starts in the host's + /// directory, which is what a relative one was relative to. + /// + /// One that is already rooted goes over exactly as it was given, not normalised: the entry is + /// settled later by a key built from the caller's own spelling of the path, and a viewer told + /// a tidier one would hold a row that settle never finds. + /// + /// + internal static string Rooted(string path) + { + try + { + if (Path.IsPathRooted(path)) + { + return path; + } + + return Path.GetFullPath(path); + } + catch (Exception exception) + when (exception is ArgumentException or NotSupportedException or IOException) + { + // Not a path this process can resolve, so not one it can improve on either + return path; + } + } /// /// Built here rather than at each caller, because the tray stores these arguments against the @@ -133,9 +188,18 @@ public static string DiffArguments(string temp, string target) => return null; } + return Start(tool.ExePath, arguments); + } + + /// + /// The launch itself, apart from deciding which copy to start, so a test can hand it a + /// stand-in rather than whichever viewer the machine running it has installed. + /// + internal static Process? Start(string exePath, string arguments) + { try { - return Start(StartInfo(tool.ExePath, arguments, RuntimeInformation.IsOSPlatform(OSPlatform.Windows))); + return Start(StartInfo(exePath, arguments, RuntimeInformation.IsOSPlatform(OSPlatform.Windows))); } catch (Exception exception) { @@ -150,11 +214,15 @@ public static string DiffArguments(string temp, string target) => /// internal static ProcessStartInfo StartInfo(string exePath, string arguments, bool windows) { + // The resolved path is always a full one. A bare file name has no directory to name, and + // an empty working directory is the one a child inherits, which is no worse than before + var directory = Path.GetDirectoryName(exePath) ?? ""; if (windows) { return new(exePath, arguments) { - UseShellExecute = true + UseShellExecute = true, + WorkingDirectory = directory }; } @@ -164,7 +232,8 @@ internal static ProcessStartInfo StartInfo(string exePath, string arguments, boo CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true, - RedirectStandardError = true + RedirectStandardError = true, + WorkingDirectory = directory }; } diff --git a/src/DiffEngine/WildcardFileFinder.cs b/src/DiffEngine/WildcardFileFinder.cs index 5d975133d..e94b717a3 100644 --- a/src/DiffEngine/WildcardFileFinder.cs +++ b/src/DiffEngine/WildcardFileFinder.cs @@ -72,9 +72,7 @@ static IEnumerable Children(string root, string segment) try { - return Directory.EnumerateDirectories(root, segment) - .OrderByDescending(Directory.GetLastWriteTime) - .ToList(); + return NewestFirst(Directory.EnumerateDirectories(root, segment).ToList()); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { @@ -82,22 +80,153 @@ static IEnumerable Children(string root, string segment) } } + /// + /// Newest first, which is what a wildcard in a search directory is for: it stands for the part + /// of an install path that changes from one release of a tool to the next. + /// + /// By version where every name is one, because those are package folders, and when one was + /// written says when it was restored rather than how new it is. The NuGet cache holds a folder + /// for each version of DiffEngine any project on the machine has referenced, so restoring an + /// old project put a viewer from before the arguments this library passes ahead of every + /// newer one. + /// + /// + /// By when it was written otherwise, as it always was. "Beyond Compare 5" and "vim91" are not + /// versions, and neither is a number on its own: Visual Studio 2022 installs to a folder + /// named 2022 and its successor to one named 18. + /// + /// + static List NewestFirst(List directories) + { + var versioned = new List<(string Directory, FolderVersion Version)>(); + foreach (var directory in directories) + { + if (!FolderVersion.TryParse(Path.GetFileName(directory), out var version)) + { + return directories + .OrderByDescending(Directory.GetLastWriteTime) + .ToList(); + } + + versioned.Add((directory, version)); + } + + return versioned + .OrderByDescending(_ => _.Version) + .Select(_ => _.Directory) + .ToList(); + } + + /// + /// A directory name read the way NuGet names a package's folder: two to four numbers, then + /// whatever follows a hyphen as a prerelease label. + /// + readonly struct FolderVersion(Version numbers, string[] prerelease) : + IComparable + { + Version Numbers { get; } = numbers; + + string[] Prerelease { get; } = prerelease; + + public static bool TryParse(string name, out FolderVersion version) + { + var hyphen = name.IndexOf('-'); + var numbers = hyphen < 0 ? name : name.Substring(0, hyphen); + var label = hyphen < 0 ? "" : name.Substring(hyphen + 1); + // Version.TryParse also takes a sign and surrounding space, neither of which is in a + // folder NuGet made + if (numbers.Any(_ => _ != '.' && !char.IsDigit(_)) || + (hyphen >= 0 && label.Length == 0) || + !Version.TryParse(numbers, out var parsed)) + { + version = default; + return false; + } + + version = new(parsed, label.Length == 0 ? [] : label.Split('.')); + return true; + } + + /// + /// Semantic versioning's order: the numbers first, a release above every prerelease of + /// it, and prerelease labels part by part, where a numeric part is compared as a number + /// and sorts below one that is not. + /// + public int CompareTo(FolderVersion other) + { + var byNumbers = Numbers.CompareTo(other.Numbers); + if (byNumbers != 0) + { + return byNumbers; + } + + var others = other.Prerelease; + if (Prerelease.Length == 0 || + others.Length == 0) + { + return others.Length.CompareTo(Prerelease.Length); + } + + for (var index = 0; index < Math.Min(Prerelease.Length, others.Length); index++) + { + var byPart = ComparePart(Prerelease[index], others[index]); + if (byPart != 0) + { + return byPart; + } + } + + return Prerelease.Length.CompareTo(others.Length); + } + + static int ComparePart(string left, string right) + { + var leftIsNumber = ulong.TryParse(left, out var leftNumber); + var rightIsNumber = ulong.TryParse(right, out var rightNumber); + if (leftIsNumber && + rightIsNumber) + { + return leftNumber.CompareTo(rightNumber); + } + + if (leftIsNumber != rightIsNumber) + { + return leftIsNumber ? -1 : 1; + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + } + public static bool TryFind( string path, [NotNullWhen(true)] out string? result) + { + result = FindAll(path).FirstOrDefault(); + if (result != null) + { + return true; + } + + Logging.Write($"Could not find file: {path}"); + return false; + } + + /// + /// Every file the path names, in the order would come to them, for a + /// caller that may not want the first. One path names several only through a wildcard. + /// + public static IEnumerable FindAll(string path) { var expanded = Environment.ExpandEnvironmentVariables(path); if (!path.Contains('*')) { if (File.Exists(expanded)) { - result = expanded; - return true; + yield return expanded; } - Logging.Write($"Could not find file: {path}"); - result = null; - return false; + yield break; } var filePart = Path.GetFileName(expanded); @@ -112,13 +241,8 @@ public static bool TryFind( var filePath = Path.Combine(directory, filePart); if (File.Exists(filePath)) { - result = filePath; - return true; + yield return filePath; } } - - Logging.Write($"Could not find file: {path}"); - result = null; - return false; } } \ No newline at end of file diff --git a/src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs b/src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs index 03fa76b0f..b80e4e25c 100644 --- a/src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs +++ b/src/DiffEngineTray.Tests/DiffRunnerViewerFocusRaceTest.cs @@ -69,9 +69,10 @@ public async Task A_move_the_tray_has_tracked_is_a_focus_and_nothing_more() await Assert.That(result).IsEqualTo(LaunchResult.AlreadyRunningAndSupportsRefresh); await Assert.That(fixture.Launches).IsEqualTo(1); - // Focus raised the window over the entry that was already there, so nothing re-tracked it: - // a Diff would have replaced the move, losing the exe and arguments the piper send carries. - await Assert.That(fixture.TrackedExe()).IsEqualTo(exe); + // Focus raised the window over the entry that was already there, so nothing re-tracked it. + // A Diff would have: it keeps the exe and arguments the piper send carried, but every + // arrival for a tracked pair replaces its entry. + await Assert.That(fixture.IsStillTheMoveTracked()).IsTrue(); } sealed class Fixture : @@ -124,13 +125,15 @@ public Fixture() /// that always worked. /// public void Track() => - tracker.AddMove(Temp, Target, exe, "--diff", false, null); + tracked = tracker.AddMove(Temp, Target, exe, "--diff", false, null); + + TrackedMove? tracked; public bool Tracks(string key) => ((ITrackedFiles) tracker).Has(key); - public string? TrackedExe() => - tracker.Moves.Single().Exe; + public bool IsStillTheMoveTracked() => + ReferenceEquals(tracker.Moves.Single(), tracked); public Task PiperMove() => move.Task.WaitAsync(TimeSpan.FromSeconds(10), piperCancel.Token); diff --git a/src/DiffEngineTray.Tests/FakeViewer.cs b/src/DiffEngineTray.Tests/FakeViewer.cs index 2439f0eae..b6ca3bd2c 100644 --- a/src/DiffEngineTray.Tests/FakeViewer.cs +++ b/src/DiffEngineTray.Tests/FakeViewer.cs @@ -49,6 +49,13 @@ public FakeViewer(params string[] names) /// public bool ListingFails { get; set; } + /// + /// When true, every request is answered in some other protocol, which stands in for a program + /// that is not a viewer holding the port. 3493 is registered to Network UPS Tools, whose + /// daemon answers whatever it does not understand with a line like this one. + /// + public bool AnotherProgram { get; set; } + async Task Listen() { while (!cancel.IsCancellationRequested) @@ -76,6 +83,10 @@ string Respond(string request) var verb = Read(request, "verb"); var key = Decode(Read(request, "key")); Verbs.Add(key == null ? verb : $"{verb}:{key}"); + if (AnotherProgram) + { + return "ERR UNKNOWN-COMMAND\n"; + } var builder = new StringBuilder("version: 1\n"); if (verb == "list" && diff --git a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs b/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs index 18f1851f3..9c049fbb1 100644 --- a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs +++ b/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs @@ -98,6 +98,25 @@ public async Task DisposalSkipsPatchesWhoseSourceIsGone() // an exception out of Dispose is the point. } + /// + /// What is staged as the session ends is the last of the queue anything will see, so a patch + /// taken after it would be acknowledged to its sender and in neither place. It is refused + /// instead, which is what makes the sender stage it, and everything else still answers. + /// + [Test] + public async Task APatchArrivingOnceTheSessionIsEndingIsRefused() + { + using var owner = new Owner(); + owner.Queue(); + + owner.Host.SessionEnding(); + var response = owner.Queue(@"c:\repo\OtherTests.cs", 7); + + await Assert.That(response.Ok).IsFalse(); + await Assert.That(response.Message).IsEqualTo("This tray is going with the session and can take nothing more."); + await Assert.That(owner.Send(new(ViewerVerb.List)).Items.Select(_ => _.Name)).IsEquivalentTo(["SampleTests.cs:42"]); + } + [Test] public async Task AQueuedPatchIsHeldHereAndShown() { @@ -638,8 +657,14 @@ public bool Has(string key) => /// public bool? HeldDeletes { get; private set; } - public (int accepted, int kept) AcceptAll(bool holdDeletes, Action? advanced = null) + /// + /// The deletes the last sweep was told it may carry out, null before there was one. + /// + public IReadOnlyCollection? SweptDeletes { get; private set; } + + public (int accepted, int kept) AcceptAll(IReadOnlyCollection deleteKeys, bool holdDeletes, Action? advanced = null) { + SweptDeletes = deleteKeys; HeldDeletes = holdDeletes; // One step per file the sweep reports, which is what the real tracker calls it for for (var file = 0; file < SweepResult.accepted + SweepResult.kept; file++) @@ -789,6 +814,97 @@ public async Task AListingDuringAnAcceptAllSaysHowFarItHasGot() await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Progress).IsNull(); } + /// + /// The snapshots of one source file are written together, with one read and one write, so + /// they are completed together: while the second of two is applying, neither has left the + /// listing and the count has not moved. One at a time each rewrote the whole file, which for + /// five hundred in one file was half a minute where the drive is scanned. + /// + [Test] + public async Task TheSnapshotsOfOneFileAreCompletedTogether() + { + using var held = new HeldApply(2); + using var owner = new Owner(held.Apply); + owner.Queue(@"c:\repo\SampleTests.cs", 7); + owner.Queue(@"c:\repo\SampleTests.cs", 9); + owner.Queue(@"c:\repo\OtherTests.cs", 3); + + var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); + held.WaitUntilHeld(); + + var partway = owner.Send(new(ViewerVerb.ListFull)); + await Assert.That(partway.Progress).IsEqualTo(new(0, 3)); + await Assert.That(partway.Items.Count).IsEqualTo(3); + + held.Release(); + var response = await accepting; + + await Assert.That(response.Message).IsEqualTo("Accepted 3"); + await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Items).IsEmpty(); + } + + /// + /// And each still has an outcome of its own: the one that was not written stays, saying why, + /// beside the one of the same file that was. + /// + [Test] + public async Task EachSnapshotOfAFileHasItsOwnOutcome() + { + using var owner = new Owner( + _ => _.LineHint == 9 + ? InlineApplyResult.Failed("the file is held") + : InlineApplyResult.Applied); + owner.Queue(@"c:\repo\SampleTests.cs", 7); + owner.Queue(@"c:\repo\SampleTests.cs", 9); + owner.Queue(@"c:\repo\SampleTests.cs", 11); + + var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)); + + await Assert.That(response.Message).IsEqualTo("Accepted 2, 1 failed. the file is held"); + var left = owner.Send(new(ViewerVerb.ListFull)).Items.Single(); + await Assert.That(left.Key).IsEqualTo(InlineKey.For(@"c:\repo\SampleTests.cs", 9)); + } + + /// + /// With the applier a tray really has, several snapshots land in one real file. + /// + [Test] + public async Task TheRealApplierWritesEverySnapshotOfAFile() + { + var directory = Path.Combine(Path.GetTempPath(), $"OwnedInlineHostTest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var source = Path.Combine(directory, "SampleTests.cs"); + await File.WriteAllTextAsync( + source, + """ + class C + { + void One() => Verify(value).Snapshot("old"); + void Two() => Verify(value).Snapshot("old"); + void Three() => Verify(value).Snapshot("old"); + } + """); + using var owner = new Owner(); + owner.Queue(source, 3, content: "one"); + owner.Queue(source, 4, content: "two"); + owner.Queue(source, 5, content: "three"); + + var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)); + + await Assert.That(response.Message).IsEqualTo("Accepted 3"); + var written = await File.ReadAllTextAsync(source); + await Assert.That(written).Contains("One() => Verify(value).Snapshot(\"one\")"); + await Assert.That(written).Contains("Two() => Verify(value).Snapshot(\"two\")"); + await Assert.That(written).Contains("Three() => Verify(value).Snapshot(\"three\")"); + } + finally + { + Directory.Delete(directory, true); + } + } + /// /// The tracked files are swept first, and a move being retried while a diff tool lets go of it /// is as much of the wait as any snapshot, so they count towards the progress too. @@ -861,10 +977,42 @@ public async Task AnAcceptAllWhosePatchesAllLandCarriesOutTheDeletes() await Assert.That(response.Message).IsEqualTo("Accepted 1, plus 1 files"); } + /// + /// The deletes are listed as the batch begins and those are what the sweep is handed once the + /// snapshots are done. They were read at that point instead, so a delete that arrived while a + /// snapshot was applying was swept with a batch its patch was never in. + /// + [Test] + public async Task AnAcceptAllSweepsOnlyTheDeletesPendingWhenItBegan() + { + using var held = new HeldApply(1); + using var owner = new Owner(held.Apply); + var tracked = new FakeTracked + { + DeleteList = [new(@"delete:c:\code\early.verified.txt", "early.verified.txt", null, @"c:\code\early.verified.txt")], + SweepResult = (1, 0) + }; + owner.Host.TrackedFiles = tracked; + owner.Queue(); + + var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); + held.WaitUntilHeld(); + tracked.DeleteList = + [ + ..tracked.DeleteList, + new(@"delete:c:\code\late.verified.txt", "late.verified.txt", null, @"c:\code\late.verified.txt") + ]; + held.Release(); + await accepting; + + await Assert.That(tracked.SweptDeletes!).IsEquivalentTo([@"delete:c:\code\early.verified.txt"]); + } + /// /// An accept-all runs for as long as the queue is long, and the queue moves meanwhile. It /// applied from a copy taken at the start, so an entry discarded while an earlier one was - /// applying was still written into the source. + /// applying was still written into the source. A file's snapshots are looked up when that + /// file's turn comes, so one discarded before then is not written. /// [Test] public async Task AnEntryDiscardedDuringAnAcceptAllIsNotWritten() @@ -881,12 +1029,12 @@ public async Task AnEntryDiscardedDuringAnAcceptAllIsNotWritten() return held.Apply(patch); }); - owner.Queue(line: 1); - owner.Queue(line: 2); + owner.Queue(@"c:\repo\SampleTests.cs", 1); + owner.Queue(@"c:\repo\OtherTests.cs", 2); var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); held.WaitUntilHeld(); - owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\SampleTests.cs", 2))); + owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\OtherTests.cs", 2))); held.Release(); await accepting; @@ -894,6 +1042,41 @@ public async Task AnEntryDiscardedDuringAnAcceptAllIsNotWritten() await Assert.That(applied[0].LineHint).IsEqualTo(1); } + /// + /// The turn is a file's, because a file's snapshots are written together in one write. So one + /// discarded while its own file is being written had already been handed over with the rest + /// of the file, and is written with them. It is gone from the queue as it was asked to be, and + /// is not counted as accepted: the outcome is of an entry that is no longer there. + /// + [Test] + public async Task AnEntryDiscardedWhileItsOwnFileIsWrittenIsNotCounted() + { + using var held = new HeldApply(1); + var applied = new List(); + using var owner = new Owner( + patch => + { + lock (applied) + { + applied.Add(patch); + } + + return held.Apply(patch); + }); + owner.Queue(line: 1); + owner.Queue(line: 2); + + var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); + held.WaitUntilHeld(); + owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\SampleTests.cs", 2))); + held.Release(); + var response = await accepting; + + await Assert.That(applied.Count).IsEqualTo(2); + await Assert.That(response.Message).IsEqualTo("Accepted 1"); + await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Items).IsEmpty(); + } + /// /// The menu's accept-all reaches the queue through the tray's own host rather than the wire, /// and a viewer displaying the queue follows that one the same way. diff --git a/src/DiffEngineTray.Tests/ProgramEntryPointTest.cs b/src/DiffEngineTray.Tests/ProgramEntryPointTest.cs new file mode 100644 index 000000000..607a8e30c --- /dev/null +++ b/src/DiffEngineTray.Tests/ProgramEntryPointTest.cs @@ -0,0 +1,25 @@ +/// +/// The thread the tray starts on is the one its windows live on, and WinForms needs that to be a +/// single threaded apartment for everything it does through OLE: the clipboard, drag and drop, the +/// common dialogs. +/// +/// It was not. Main was async Task, and the entry point the runtime starts for one of those +/// is a method the compiler writes, which carries no attribute. So the thread was MTA, and "Copy" +/// in the debug view threw ThreadStateException out of Clipboard.SetText on every click. +/// +/// +/// Asserted on the assembly's entry point rather than on Program.Main, because that is the method +/// the apartment is read from: an attribute on an async Main would sit on a method the runtime +/// never starts with, and pass a test that looked for it there. +/// +/// +public class ProgramEntryPointTest +{ + [Test] + public async Task TheEntryPointStartsOnAnStaThread() + { + var entryPoint = typeof(Tracker).Assembly.EntryPoint!; + + await Assert.That(entryPoint.GetCustomAttribute()).IsNotNull(); + } +} diff --git a/src/DiffEngineTray.Tests/SessionEndWindowTest.cs b/src/DiffEngineTray.Tests/SessionEndWindowTest.cs new file mode 100644 index 000000000..bee893b62 --- /dev/null +++ b/src/DiffEngineTray.Tests/SessionEndWindowTest.cs @@ -0,0 +1,112 @@ +/// +/// The two messages a logoff or a shutdown sends every top level window, sent to the one the tray +/// keeps for hearing them. +/// +/// Sent from the thread that made the window, which calls its procedure directly, so no message +/// loop is needed and nothing here awaits between making the window and destroying it. +/// +/// +public class SessionEndWindowTest +{ + [Test] + public async Task TheSessionEndingIsHeard() + { + var heard = 0; + IntPtr query; + using (var window = new SessionEndWindow(() => heard++)) + { + query = SendMessage(window.Handle, queryEndSession, IntPtr.Zero, logoff); + SendMessage(window.Handle, endSession, new(1), logoff); + } + + // Agreed to. Refusing would hold up the logoff, and there is nothing here worth that + await Assert.That(query).IsEqualTo(new IntPtr(1)); + await Assert.That(heard).IsEqualTo(1); + } + + /// + /// Another application refused the query, so everything that agreed is told the session goes + /// on. A tray that staged its queue for that would have it both on disk and in memory. + /// + [Test] + public async Task ASessionThatDidNotEndAfterAllIsNotHeard() + { + var heard = 0; + using (var window = new SessionEndWindow(() => heard++)) + { + SendMessage(window.Handle, queryEndSession, IntPtr.Zero, logoff); + SendMessage(window.Handle, endSession, IntPtr.Zero, logoff); + } + + await Assert.That(heard).IsEqualTo(0); + } + + /// + /// Wired the way Program wires it, over a queue this process holds. The session ending never + /// reaches the disposal that stages a queue on a clean exit: the message loop does not return + /// for it, and the process may be ended once the message is answered. So what was pending has + /// to be on disk by then, and it was not - the queue went with the process. + /// + [Test] + public async Task AnOwningTrayStagesItsQueueAsTheSessionEnds() + { + var directory = Path.Combine(Path.GetTempPath(), $"tray-session-end-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var host = OwnedInlineHost.TryOwn(_ => { }, new FakeLauncher(), 0) ?? + throw new("Could not bind an ephemeral port."); + try + { + await File.WriteAllTextAsync(Path.Combine(directory, "Sample.csproj"), ""); + var source = Path.Combine(directory, "SampleTests.cs"); + await File.WriteAllTextAsync(source, "// sample"); + host.Start(); + await Assert.That(Send(host, source).Ok).IsTrue(); + + EndSession(host); + + // Read before the host is disposed, which would stage it too + var staging = Path.Combine(directory, "obj", InlineStaging.DirectoryName); + var patchFile = Directory.GetFiles(staging).Single(_ => _.EndsWith(".inlinepatch")); + await Assert.That(InlinePatchFile.TryRead(patchFile, out var read)).IsTrue(); + await Assert.That(read!.SourceFile).IsEqualTo(source); + await Assert.That(read.NewContent).IsEqualTo("new"); + } + finally + { + await host.DisposeAsync(); + Directory.Delete(directory, recursive: true); + } + } + + static void EndSession(OwnedInlineHost host) + { + using var window = new SessionEndWindow(host.SessionEnding); + SendMessage(window.Handle, queryEndSession, IntPtr.Zero, logoff); + SendMessage(window.Handle, endSession, new(1), logoff); + } + + static ViewerResponse Send(OwnedInlineHost host, string source) + { + var message = new ViewerMessage( + ViewerVerb.Inline, + Body: InlinePatchFile.Build( + new(source, 42, "\"old\"", "new") + { + Framework = "net10.0", + TestName = null + })); + if (!ViewerClient.TrySend(message, out var response, host.Port)) + { + throw new("The owner did not answer."); + } + + return response; + } + + [DllImport("user32.dll")] + static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam); + + const int queryEndSession = 0x0011; + const int endSession = 0x0016; + static readonly IntPtr logoff = new(unchecked((int) 0x80000000)); +} diff --git a/src/DiffEngineTray.Tests/SilentOwner.cs b/src/DiffEngineTray.Tests/SilentOwner.cs new file mode 100644 index 000000000..29174d97a --- /dev/null +++ b/src/DiffEngineTray.Tests/SilentOwner.cs @@ -0,0 +1,31 @@ +/// +/// Holds the viewer port and never answers: a queue owner that is cold starting, or wedged. +/// is the owner that does answer. +/// +/// Listening is all it takes. The system completes a connection to a listening port on its own, so +/// a client connects, sends, and waits out its timeout for a reply nothing here will ever write, +/// which is what a process that is not getting round to its accept loop looks like from outside. +/// +/// +/// Points DiffEngine_ViewerPort at itself, as does. +/// +/// +sealed class SilentOwner : IDisposable +{ + readonly TcpListener listener = new(IPAddress.Loopback, 0); + readonly string? previousPort; + + public SilentOwner() + { + listener.Start(); + var port = ((IPEndPoint) listener.LocalEndpoint).Port; + previousPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString()); + } + + public void Dispose() + { + listener.Stop(); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousPort); + } +} diff --git a/src/DiffEngineTray.Tests/StubInlineHost.cs b/src/DiffEngineTray.Tests/StubInlineHost.cs index d8793ec4a..215281aae 100644 --- a/src/DiffEngineTray.Tests/StubInlineHost.cs +++ b/src/DiffEngineTray.Tests/StubInlineHost.cs @@ -15,6 +15,18 @@ class StubInlineHost(params PendingSnapshot[] snapshots) : public IReadOnlyList List() => snapshots; + /// + /// Whether the queue says what it holds when asked. False stands in for a viewer that holds + /// the port and does not answer, without the half second a real one takes to time out. + /// + public bool Answers { get; init; } = true; + + public bool TryList(out IReadOnlyList pending) + { + pending = Answers ? snapshots : []; + return Answers; + } + #pragma warning disable CA1822 public IReadOnlyList? Queued() => #pragma warning restore CA1822 diff --git a/src/DiffEngineTray.Tests/TrackedFilesExtensions.cs b/src/DiffEngineTray.Tests/TrackedFilesExtensions.cs new file mode 100644 index 000000000..95c387a1a --- /dev/null +++ b/src/DiffEngineTray.Tests/TrackedFilesExtensions.cs @@ -0,0 +1,15 @@ +static class TrackedFilesExtensions +{ + /// + /// An accept-all over everything tracked as it is called, for the tests that are about what a + /// sweep does rather than about when its deletes were listed. The queue owner lists them as its + /// batch begins and sweeps once its snapshots are done; nothing arrives in between here. + /// + public static (int accepted, int kept) AcceptAllTracked(this ITrackedFiles tracked, bool holdDeletes) => + tracked.AcceptAll( + tracked + .Deletes() + .Select(_ => _.Key) + .ToList(), + holdDeletes); +} diff --git a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs b/src/DiffEngineTray.Tests/TrackerDeleteTest.cs index 8e98ab29b..038f90ded 100644 --- a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs +++ b/src/DiffEngineTray.Tests/TrackerDeleteTest.cs @@ -170,6 +170,34 @@ await Assert.That(warnings).IsEquivalentTo( [$"Could not accept the pending snapshots. Accepted 0, 1 not written {Tracker.DeletesHeld}"]); } + /// + /// A queue that could not be asked what it holds is treated as one with a snapshot it could + /// not write: nothing is sent to it, and the deletes wait. It used to read as an empty queue, + /// and the deletes went ahead. + /// + [Test] + public async Task AcceptAllHoldsTheDeletesWhenTheQueueCouldNotBeAsked() + { + var swept = false; + var warnings = new List(); + await using var tracker = new RecordingTracker( + inlineFailed: warnings.Add, + inline: new StubInlineHost(new PendingSnapshot(@"c:\repo\sample.cs|12", "Sample.cs:12", null)) + { + Answers = false, + AcceptingAll = () => swept = true + }); + tracker.AddDelete(file1); + + await tracker.AcceptAll(); + + await Assert.That(swept).IsFalse(); + await Assert.That(File.Exists(file1)).IsTrue(); + await Assert.That(tracker.Deletes).HasSingleItem(); + await Assert.That(warnings).IsEquivalentTo( + [$"Could not accept the pending snapshots. The snapshot viewer did not answer. {Tracker.DeletesHeld}"]); + } + public void Dispose() { File.Delete(file1); diff --git a/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs b/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs new file mode 100644 index 000000000..b7054513f --- /dev/null +++ b/src/DiffEngineTray.Tests/TrackerMoveOntoDeleteTest.cs @@ -0,0 +1,165 @@ +/// +/// A pending delete and a pending move that name the same verified file. +/// +/// The delete says no test produces the file any more, and the move says a later run verified +/// against it and failed, so the delete is the stale one. Nothing withdrew it, and "Accept all" +/// carries out the moves and then the deletes: the received file was moved into place and then +/// deleted, and both were gone with nothing said. +/// +/// +/// DiffRunner.SettleDelete withdraws such a delete, which is why this takes an arrangement to +/// reach: a library from before that existed, a viewer holding the queue the settle is sent to, or +/// a settle skipped because the port was remembered as unowned. +/// +/// +public class TrackerMoveOntoDeleteTest : + IDisposable +{ + [Test] + public async Task AMoveOntoAFileWithdrawsItsPendingDelete() + { + await using var tracker = new RecordingTracker(); + tracker.AddDelete(verified); + + tracker.AddMove(received, verified, "theExe", "theArguments", true, null); + + await Assert.That(tracker.Deletes).IsEmpty(); + await Assert.That(tracker.Moves).HasSingleItem(); + } + + /// + /// The same pair arriving over the viewer port, which is how a move reaches a tray that started + /// after the test process did. + /// + [Test] + public async Task AMoveArrivingOverTheViewerPortWithdrawsItToo() + { + await using var tracker = new RecordingTracker(); + tracker.AddDelete(verified); + + ((ITrackedFiles) tracker).AddMove(received, verified); + + await Assert.That(tracker.Deletes).IsEmpty(); + } + + [Test] + public async Task AcceptAllKeepsTheFileItsMoveJustWrote() + { + await using var tracker = new RecordingTracker(); + tracker.AddDelete(verified); + tracker.AddMove(received, verified, "theExe", "theArguments", true, null); + + await tracker.AcceptAll(); + + await Assert.That(await File.ReadAllTextAsync(verified)).IsEqualTo("received"); + await Assert.That(File.Exists(received)).IsFalse(); + await tracker.AssertEmpty(); + } + + /// + /// The other order, which nothing withdraws: the delete arrived after the move, so it may be + /// the newer of the two. Neither is guessed at. The move is what was asked for, and a delete is + /// never carried out on a file the same sweep has just written, so it stays pending for + /// whoever knows the file is redundant. + /// + [Test] + public async Task AcceptAllHoldsADeleteOfTheFileItsMoveJustWrote() + { + await using var tracker = new RecordingTracker(); + tracker.AddMove(received, verified, "theExe", "theArguments", true, null); + tracker.AddDelete(verified); + + await tracker.AcceptAll(); + + await Assert.That(await File.ReadAllTextAsync(verified)).IsEqualTo("received"); + await Assert.That(tracker.Moves).IsEmpty(); + await Assert.That(tracker.Deletes.Select(_ => _.File)).IsEquivalentTo([verified]); + } + + /// + /// The sweep a viewer displaying the tray's queue asks for, which has the same two halves in + /// the same order. + /// + [Test] + public async Task TheWireSweepHoldsADeleteOfTheFileItsMoveJustWrote() + { + await using var tracker = new RecordingTracker(); + ITrackedFiles tracked = tracker; + tracker.AddMove(received, verified, "theExe", "theArguments", true, null); + tracker.AddDelete(verified); + + var (accepted, kept) = tracked.AcceptAllTracked(holdDeletes: false); + + await Assert.That(accepted).IsEqualTo(1); + await Assert.That(kept).IsEqualTo(1); + await Assert.That(await File.ReadAllTextAsync(verified)).IsEqualTo("received"); + await Assert.That(tracker.Deletes.Select(_ => _.File)).IsEquivalentTo([verified]); + } + + /// + /// A move that could not be carried out is still going to write the file, so the delete waits + /// for it as well. Deleting here left the old snapshot gone and the new one not yet in place. + /// + [Test] + public async Task TheWireSweepHoldsADeleteOfAFileAMoveStillPendingTargets() + { + await using var tracker = new RecordingTracker(); + ITrackedFiles tracked = tracker; + tracker.AddMove(received, verified, "theExe", "theArguments", true, null); + tracker.AddDelete(verified); + + int accepted; + int kept; + // The received file held open, so the move fails and stays pending while nothing stops + // the verified file from being deleted + await using (new FileStream(received, FileMode.Open, FileAccess.Read, FileShare.None)) + { + (accepted, kept) = tracked.AcceptAllTracked(holdDeletes: false); + } + + await Assert.That(accepted).IsEqualTo(0); + await Assert.That(kept).IsEqualTo(2); + await Assert.That(await File.ReadAllTextAsync(verified)).IsEqualTo("verified"); + await Assert.That(tracker.Moves).HasSingleItem(); + await Assert.That(tracker.Deletes).HasSingleItem(); + } + + /// + /// What holds a delete is a move that wrote the file or is still going to, not a move having + /// named it. One whose received file has gone is dropped without writing anything - the test + /// re-ran and cleared it - and the delete beside it is the newer statement about that file. + /// + [Test] + public async Task AMoveWithNothingLeftToMoveDoesNotHoldTheDelete() + { + await using var tracker = new RecordingTracker(); + tracker.AddMove(received, verified, "theExe", "theArguments", true, null); + tracker.AddDelete(verified); + File.Delete(received); + + await tracker.AcceptAll(); + + await Assert.That(File.Exists(verified)).IsFalse(); + await tracker.AssertEmpty(); + } + + public TrackerMoveOntoDeleteTest() + { + // The received file sits in its own directory, the way DiffEngine stages one, because + // accepting a move deletes that directory. + directory = Path.Combine(Path.GetTempPath(), $"MoveOntoDelete_{Guid.NewGuid():N}"); + var staged = Path.Combine(directory, "staged"); + Directory.CreateDirectory(staged); + received = Path.Combine(staged, "Sample.Test.received.txt"); + verified = Path.Combine(directory, "Sample.Test.verified.txt"); + File.WriteAllText(received, "received"); + File.WriteAllText(verified, "verified"); + } + + public void Dispose() => + Directory.Delete(directory, true); + + string directory; + string received; + string verified; +} diff --git a/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs b/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs index 7e0e96734..cf17226b0 100644 --- a/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs +++ b/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs @@ -132,6 +132,147 @@ await Assert.That(warnings).IsEquivalentTo( ["Could not accept the pending snapshots. Accepted 0, 13 not written"]); } + /// + /// A viewer that holds the queue and does not answer is not a viewer with nothing queued. The + /// listing flattens the two, which is right for a menu, and "Accept all" read it the same way: + /// nothing to accept, so the deletes went ahead. One of them may belong to a patch that viewer + /// is holding, and its verified file was deleted with the patch never tried. + /// + [Test] + public async Task AnOwnerThatDoesNotAnswerHoldsTheDeletes() + { + var verified = Path.GetTempFileName(); + try + { + using var owner = new SilentOwner(); + var warnings = new List(); + await using var tracker = new RecordingTracker(inlineFailed: warnings.Add); + tracker.AddDelete(verified); + + await tracker.AcceptAll(); + + await Assert.That(File.Exists(verified)).IsTrue(); + await Assert.That(tracker.Deletes).HasSingleItem(); + await Assert.That(warnings).IsEquivalentTo( + [$"Could not accept the pending snapshots. The snapshot viewer did not answer. {Tracker.DeletesHeld}"]); + } + finally + { + File.Delete(verified); + } + } + + /// + /// The three answers a listing can come back with, which the menu's flattened one cannot tell + /// apart. Nothing holding the port is a viewer that has gone, and its queue went with it. + /// + [Test] + public async Task NoOwnerIsNothingPending() + { + var host = new RemoteInlineHost(); + + var answered = host.TryList(out var pending); + + await Assert.That(answered).IsTrue(); + await Assert.That(pending).IsEmpty(); + } + + /// + [Test] + public async Task AnOwnerThatAnswersIsAskedWhatItHolds() + { + using var viewer = new FakeViewer("Sample.cs:1"); + var host = new RemoteInlineHost(); + + var answered = host.TryList(out var pending); + + await Assert.That(answered).IsTrue(); + await Assert.That(pending.Select(_ => _.Name)).IsEquivalentTo(["Sample.cs:1"]); + } + + /// + [Test] + public async Task AnOwnerThatDoesNotAnswerIsNotAnEmptyQueue() + { + using var owner = new SilentOwner(); + var host = new RemoteInlineHost(); + + var answered = host.TryList(out _); + + await Assert.That(answered).IsFalse(); + // The menu keeps the flattened answer it was written against + await Assert.That(host.List()).IsEmpty(); + } + + /// + /// Answering with an error is not saying what is pending either. + /// + [Test] + public async Task AnOwnerThatRefusesTheListingIsNotAnEmptyQueue() + { + using var viewer = new FakeViewer("Sample.cs:1") + { + ListingFails = true + }; + var host = new RemoteInlineHost(); + + var answered = host.TryList(out _); + + await Assert.That(answered).IsFalse(); + } + + /// + /// The port held by a program that is not a viewer at all, which a tray that could not bind it + /// lives with for good. That is no owner, as it is for every send DiffEngine makes, and not an + /// owner that failed to answer: nothing on that port will ever hold a snapshot, so the deletes + /// go ahead as they do with nothing there. + /// + [Test] + public async Task AnotherProgramOnThePortIsNoOwner() + { + var verified = Path.GetTempFileName(); + try + { + using var program = new FakeViewer + { + AnotherProgram = true + }; + var warnings = new List(); + await using var tracker = new RecordingTracker(inlineFailed: warnings.Add); + tracker.AddDelete(verified); + + await tracker.AcceptAll(); + + await Assert.That(File.Exists(verified)).IsFalse(); + await Assert.That(warnings).IsEmpty(); + await Assert.That(program.Verbs).DoesNotContain("acceptall"); + } + finally + { + File.Delete(verified); + // The port is remembered as unowned for ten minutes, and the system may hand the same + // one to a later test + ViewerClient.ForgetUnowned(); + } + } + + /// + /// And said even with no delete waiting on it: the click asked for that viewer's snapshots to + /// be accepted, and none of them were. + /// + [Test] + public async Task ABulkAcceptAgainstAnOwnerThatDoesNotAnswerIsReported() + { + using var owner = new SilentOwner(); + var warnings = new List(); + await using var tracker = new RecordingTracker(inlineFailed: warnings.Add); + + await tracker.AcceptAllSnapshots(); + + await Assert.That(warnings).IsEquivalentTo( + ["Could not accept the pending snapshots. The snapshot viewer did not answer."]); + } + [Test] public async Task AcceptAllWithNothingPendingDoesNotCallTheViewer() { diff --git a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs b/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs index e7af352de..d665f1a06 100644 --- a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs +++ b/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs @@ -153,7 +153,7 @@ public async Task AcceptAllSweepsAndCountsWhatStayed() await File.WriteAllTextAsync(temp, "content"); tracker.AddMove(temp, target, null, null, false, null); - var (accepted, kept) = tracked.AcceptAll(holdDeletes: false); + var (accepted, kept) = tracked.AcceptAllTracked(holdDeletes: false); await Assert.That(accepted).IsEqualTo(2); await Assert.That(kept).IsEqualTo(0); @@ -161,6 +161,57 @@ public async Task AcceptAllSweepsAndCountsWhatStayed() await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("content"); } + /// + /// The owner lists the deletes as its batch begins and hands them back once its snapshots are + /// done. One that arrived in between is not in the batch: it belongs to a patch the batch never + /// had, so it is left pending and counted as neither accepted nor kept. + /// + [Test] + public async Task AcceptAllCarriesOutOnlyTheDeletesListedWhenTheBatchBegan() + { + await using var tracker = new RecordingTracker(); + ITrackedFiles tracked = tracker; + tracker.AddDelete(file); + var listed = tracked + .Deletes() + .Select(_ => _.Key) + .ToList(); + tracker.AddDelete(lateFile); + + var (accepted, kept) = tracked.AcceptAll(listed, holdDeletes: false); + + await Assert.That(accepted).IsEqualTo(1); + await Assert.That(kept).IsEqualTo(0); + await Assert.That(File.Exists(file)).IsFalse(); + await Assert.That(File.Exists(lateFile)).IsTrue(); + await Assert.That(tracker.Deletes.Select(_ => _.File)).IsEquivalentTo([lateFile]); + } + + /// + /// A delete that was listed and then settled while the snapshots were applying. There is + /// nothing left to carry out, and the progress it was counted into still moves past it. + /// + [Test] + public async Task AcceptAllPassesOverADeleteThatWentWhileTheBatchRan() + { + await using var tracker = new RecordingTracker(); + ITrackedFiles tracked = tracker; + tracker.AddDelete(file); + var listed = tracked + .Deletes() + .Select(_ => _.Key) + .ToList(); + tracked.Untrack(TrackedKeys.ForDelete(file)); + var steps = 0; + + var (accepted, kept) = tracked.AcceptAll(listed, holdDeletes: false, () => steps++); + + await Assert.That(accepted).IsEqualTo(0); + await Assert.That(kept).IsEqualTo(0); + await Assert.That(steps).IsEqualTo(1); + await Assert.That(File.Exists(file)).IsTrue(); + } + /// /// A snapshot swept alongside was not written, so the file a delete would remove may be the /// only copy of it left. The delete stays pending, and the file stays where it is; a move is @@ -175,7 +226,7 @@ public async Task AcceptAllHoldingDeletesLeavesThemPending() await File.WriteAllTextAsync(temp, "content"); tracker.AddMove(temp, target, null, null, false, null); - var (accepted, kept) = tracked.AcceptAll(holdDeletes: true); + var (accepted, kept) = tracked.AcceptAllTracked(holdDeletes: true); await Assert.That(accepted).IsEqualTo(1); await Assert.That(kept).IsEqualTo(1); @@ -200,9 +251,96 @@ public async Task DiscardAllUntracksDeletesAndDropsMoveTemps() await Assert.That(File.Exists(temp)).IsFalse(); } + /// + /// A pair the tray already tracks, arriving again over the viewer port. That message carries + /// the two paths and nothing about the tool, and the tray filled the gap with its own choice + /// for the extension - over a move that already said which tool was showing the pair. "Open + /// diff tool" on a viewer pair does exactly this: it starts DiffEngineViewer --diff, which + /// cannot bind the port and forwards the pair to the tray. The pair then read as some other + /// tool's with no window open, and "Accept open" skipped it while it sat on screen. + /// + [Test] + public async Task AMoveArrivingAgainOverTheViewerPortKeepsItsTool() + { + await using var tracker = new RecordingTracker(); + await File.WriteAllTextAsync(temp, "content"); + var arguments = $"--diff \"{temp}\" \"{target}\""; + tracker.AddMove(temp, target, viewerExe, arguments, false, null); + + ((ITrackedFiles) tracker).AddMove(temp, target); + + var move = tracker.Moves.Single(); + await Assert.That(move.Exe).IsEqualTo(viewerExe); + await Assert.That(move.Arguments).IsEqualTo(arguments); + await Assert.That(move.CanKill).IsFalse(); + await Assert.That(move.IsViewer).IsTrue(); + await Assert.That(move.IsOpen).IsTrue(); + } + + /// + /// The same for any other tool: what the sender said about it, and the process it started, + /// are still the tool showing the pair. + /// + [Test] + public async Task AMoveArrivingAgainOverTheViewerPortKeepsAnotherToolAndItsProcess() + { + await using var tracker = new RecordingTracker(); + await File.WriteAllTextAsync(temp, "content"); + // Stands in for the diff tool the sender started for the pair. A process of its own, + // since the tray kills the tool of a move it may kill + var tool = FileLockUtils.StartFileLockProcess(file); + try + { + tracker.AddMove(temp, target, "theExe", "theArguments", true, tool.Id); + + ((ITrackedFiles) tracker).AddMove(temp, target); + + var move = tracker.Moves.Single(); + await Assert.That(move.Exe).IsEqualTo("theExe"); + await Assert.That(move.Arguments).IsEqualTo("theArguments"); + await Assert.That(move.CanKill).IsTrue(); + await Assert.That(move.IsViewer).IsFalse(); + await Assert.That(move.Process!.Id).IsEqualTo(tool.Id); + await Assert.That(move.IsOpen).IsTrue(); + } + finally + { + FileLockUtils.Cleanup(tool); + } + } + + /// + /// What that message does say is taken: the target, and what is derived from it. + /// + [Test] + public async Task AMoveArrivingAgainOverTheViewerPortTakesItsTarget() + { + await using var tracker = new RecordingTracker(); + await File.WriteAllTextAsync(temp, "content"); + tracker.AddMove(temp, target, viewerExe, "--diff", false, null); + var moved = Path.Combine(Path.GetTempPath(), $"TrackedFilesTest_{Guid.NewGuid():N}.Other.verified.bin"); + + ((ITrackedFiles) tracker).AddMove(temp, moved); + + var move = tracker.Moves.Single(); + await Assert.That(move.Target).IsEqualTo(moved); + await Assert.That(move.Extension).IsEqualTo("bin"); + await Assert.That(move.Exe).IsEqualTo(viewerExe); + await Assert.That(move.IsViewer).IsTrue(); + } + + // The copy bundled in some other project's DiffEngine package, which is where a sender's + // viewer is and a path this process has never resolved + static readonly string viewerExe = Path.Combine( + Path.GetTempPath(), + "some-other-package", + "viewer", + "DiffEngineViewer.exe"); + public void Dispose() { File.Delete(file); + File.Delete(lateFile); if (File.Exists(temp)) { File.Delete(temp); @@ -217,6 +355,7 @@ public void Dispose() } string file = Path.GetTempFileName(); + string lateFile = Path.GetTempFileName(); string tempDirectory; string temp; string target; diff --git a/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs b/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs index ad0aa3d2e..71775da77 100644 --- a/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs +++ b/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs @@ -188,6 +188,84 @@ public async Task ViewerAcceptAllEmptiesTheTray() await Assert.That(File.Exists(delete.File)).IsFalse(); } + /// + /// A move and a delete pending on the same verified file, swept from the attached viewer. The + /// tray carries out its moves and then its deletes, so the received file was moved into place + /// and then deleted. The delete stays pending instead, and the window goes on showing it. + /// + [Test] + public async Task ViewerAcceptAllKeepsTheFileATrackedMoveJustWrote() + { + await using var pair = new TrayOwned(); + var move = pair.AddMove(); + await File.WriteAllTextAsync(move.Target, "verified"); + pair.Tracker.AddDelete(move.Target); + pair.Pump(); + + pair.Link.Post(ViewerSideVerb.AcceptAll, null); + + var viewer = pair.Pump(); + await Assert.That(await File.ReadAllTextAsync(move.Target)).IsEqualTo("received"); + await Assert.That(pair.Tracker.Moves).IsEmpty(); + await Assert.That(viewer.Keys()).IsEquivalentTo([TrackedKeys.ForDelete(move.Target)]); + } + + /// + /// A snapshot moving inline lands while an accept-all is applying: its patch, and the delete of + /// the verified file that patch replaces. The batch takes its snapshots as it begins, so the + /// patch is not in it, and it used to read the deletes when their turn came, so the delete was. + /// The verified file went while the patch replacing it was only pending. + /// + [Test] + public async Task TrayAcceptAllLeavesADeleteThatArrivedWhileItWasApplying() + { + using var held = new HeldApply(1); + await using var pair = new TrayOwned(held.Apply); + pair.Queue(sample, 1); + var early = pair.AddDelete(); + + var accepting = pair.Tracker.AcceptAll(); + held.WaitUntilHeld(); + pair.Queue(other, 7); + var late = pair.AddDelete(); + held.Release(); + await accepting; + + // What was pending when the batch began went, and what arrived during it is still there + await Assert.That(File.Exists(early.File)).IsFalse(); + await Assert.That(File.Exists(late.File)).IsTrue(); + await Assert.That(pair.Tracker.Deletes.Select(_ => _.File)).IsEquivalentTo([late.File]); + await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]); + } + + /// + /// The same arrival during the accept-all a displaying viewer asks for. What came in during + /// the batch is not part of it either way, so it is not counted as kept: it is still in the + /// window, as the patch beside it is. + /// + [Test] + public async Task ViewerAcceptAllLeavesADeleteThatArrivedWhileItWasApplying() + { + using var held = new HeldApply(1); + await using var pair = new TrayOwned(held.Apply); + pair.Queue(sample, 1); + var early = pair.AddDelete(); + pair.Pump(); + + pair.Link.Post(ViewerSideVerb.AcceptAll, null); + var pumping = Task.Run(pair.Pump); + held.WaitUntilHeld(); + pair.Queue(other, 7); + var late = pair.AddDelete(); + held.Release(); + var viewer = await pumping; + + await Assert.That(File.Exists(early.File)).IsFalse(); + await Assert.That(File.Exists(late.File)).IsTrue(); + await Assert.That(viewer.Keys()).IsEquivalentTo([Key(other, 7), late.Key]); + await Assert.That(viewer.Message).IsEqualTo("Accepted 1, plus 1 files"); + } + /// /// The arrangement the tray sets up at login: it owns the queue and a viewer displays it. An /// accept-all clicked in that viewer runs in the tray, and the window follows it there - each @@ -360,6 +438,33 @@ public async Task ViewerAcceptOfATrackedMoveReachesTheTray() await Assert.That(await File.ReadAllTextAsync(move.Target)).IsEqualTo("received"); } + /// + /// A pair whose diff tool is the viewer, sent to the tray again as a Diff: what "Open diff tool" + /// on it does, since the viewer that starts cannot bind the port and forwards the pair here. + /// The verb carries no tool, and the tray replaced the one it had recorded with its own choice + /// for the extension, so the pair stopped counting as open and "Accept open" passed over it + /// while it was on screen in the viewer. + /// + [Test] + public async Task ADiffForAPairTheTrayTracksLeavesItOpenInTheViewer() + { + await using var pair = new TrayOwned(); + var move = pair.AddMove(); + var viewerExe = Path.Combine(Path.GetTempPath(), "some-other-package", "viewer", "DiffEngineViewer.exe"); + // The piper move DiffEngine sends for a pair it resolved the viewer for + pair.Tracker.AddMove(move.Temp, move.Target, viewerExe, $"--diff \"{move.Temp}\" \"{move.Target}\"", false, null); + + var response = pair.Send(new(ViewerVerb.Diff, move.Temp, move.Target)); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(pair.Tracker.Moves.Single().Exe).IsEqualTo(viewerExe); + + await pair.Tracker.AcceptOpen(); + + await Assert.That(pair.Tracker.Moves).IsEmpty(); + await Assert.That(await File.ReadAllTextAsync(move.Target)).IsEqualTo("received"); + } + /// /// The tray menu is built from the last scan, so an item can outlive its entry: the test /// re-ran and passed, or the viewer accepted it first. Clicking it accepts nothing, and there diff --git a/src/DiffEngineTray/DiffToolLauncher.cs b/src/DiffEngineTray/DiffToolLauncher.cs index b5b3a45cd..63a0ef039 100644 --- a/src/DiffEngineTray/DiffToolLauncher.cs +++ b/src/DiffEngineTray/DiffToolLauncher.cs @@ -7,10 +7,12 @@ public static void Launch(TrackedMove move) => Launch(move.Exe!, move.Arguments!, move.CanKill, move.Process, _ => move.Process = _); /// - /// The two start flags the tool itself declares, which is what DiffRunner.LaunchProcess - /// launches it with. Hard coded here before, so a console subsystem tool - the bundled viewer - /// is one - was started without CreateNoWindow and came up with a console attached, which - /// DiffEngine's own launch of the same tool does not do. + /// The two start flags the tool itself declares. Hard coded here before, so a console + /// subsystem tool - a viewer from before 20.5 is one - was started without CreateNoWindow and + /// came up with a console attached, which DiffEngine's own launch of the same tool does not do. + /// DiffRunner.LaunchProcess goes further for a tool declared without ShellExecute, and + /// starts it so that it inherits nothing from the test host. Nothing here needs that: no test + /// run is waiting on this process's output. /// /// Resolved by path rather than carried on the move, because the payload the tray receives has /// no room for them: PiperServer's format is frozen, every stable DiffEngine embeds the client diff --git a/src/DiffEngineTray/IInlineHost.cs b/src/DiffEngineTray/IInlineHost.cs index 735813589..ec51e3f49 100644 --- a/src/DiffEngineTray/IInlineHost.cs +++ b/src/DiffEngineTray/IInlineHost.cs @@ -15,6 +15,14 @@ interface IInlineHost IReadOnlyList List(); + /// + /// The listing, for a caller that acts on the answer rather than showing it. False when + /// something holds the queue and could not be asked what is in it, which + /// flattens to nothing pending. Nothing holding the queue at all is nothing + /// pending, and true. + /// + bool TryList(out IReadOnlyList pending); + /// /// The queued patches themselves, for the debug view, and null when the queue is not in this /// process. What the tray holds then is the listing and nothing more: the patch is in the diff --git a/src/DiffEngineTray/ITrackedFiles.cs b/src/DiffEngineTray/ITrackedFiles.cs index a7ae565e4..792280c91 100644 --- a/src/DiffEngineTray/ITrackedFiles.cs +++ b/src/DiffEngineTray/ITrackedFiles.cs @@ -26,9 +26,16 @@ interface ITrackedFiles (bool ok, string? message) Discard(string key); /// - /// Accept every tracked move and delete without prompting. Kept is what stayed pending — - /// locked moves, undeletable files, and deletes held back. + /// Accept every tracked move, and the deletes named, without prompting. Kept is what stayed + /// pending — locked moves, undeletable files, and deletes held back. /// + /// + /// The deletes that were pending when the accept-all began, from , which + /// are the only ones it may carry out. The owner lists them before it takes its snapshots, + /// because one that arrives while they are applying belongs to a patch that is not in the + /// batch, and the file it removes may be the only copy of that snapshot. One that has gone + /// since is passed over, and neither accepted nor kept. + /// /// /// Leave every delete pending rather than carrying it out, because a snapshot swept alongside /// was not written, and the file a delete removes may be the only copy of it left. @@ -37,7 +44,7 @@ interface ITrackedFiles /// Called as each file is dealt with, whichever way it went, so the owner can say how far an /// accept-all has got while a locked move is still being retried. /// - (int accepted, int kept) AcceptAll(bool holdDeletes, Action? advanced = null); + (int accepted, int kept) AcceptAll(IReadOnlyCollection deleteKeys, bool holdDeletes, Action? advanced = null); /// /// Track a pending move or delete that arrived over the viewer port rather than the piper one. diff --git a/src/DiffEngineTray/OwnedInlineHost.cs b/src/DiffEngineTray/OwnedInlineHost.cs index 2436ae88e..a33d8b62c 100644 --- a/src/DiffEngineTray/OwnedInlineHost.cs +++ b/src/DiffEngineTray/OwnedInlineHost.cs @@ -15,8 +15,9 @@ /// /// /// A clean tray exit stages what is still pending back to disk (), so -/// a restart no longer silently discards the queue. A kill or a crash still loses it, exactly as -/// it loses tracked moves and deletes, and the recovery is the same: re-run the tests. +/// a restart no longer silently discards the queue. So does the session ending, which never gets +/// as far as a clean exit: see . A kill or a crash still loses it, +/// exactly as it loses tracked moves and deletes, and the recovery is the same: re-run the tests. /// /// sealed class OwnedInlineHost : @@ -58,16 +59,31 @@ sealed class OwnedInlineHost : /// readonly Lock accepting = new(); + /// + /// Several snapshots of one source file, written with one read and one write and answered in + /// the order given: what a bulk accept hands a file's snapshots to. The applier a test + /// supplied, asked of each in turn, when there is one. + /// + readonly Func, IReadOnlyList> together; + OwnedInlineHost( ViewerServer server, Action failed, IViewerLauncher launcher, - Func applier) + Func? applier) { this.server = server; this.failed = failed; this.launcher = launcher; - this.applier = applier; + this.applier = applier ?? InlineApplier.Apply; + if (applier is null) + { + together = InlineApplier.ApplyAll; + } + else + { + together = _ => _.Select(applier).ToList(); + } } /// @@ -85,7 +101,7 @@ sealed class OwnedInlineHost : int? port = null, Func? applier = null) => ViewerServer.TryBind(port ?? ViewerClient.Port, out var server) - ? new(server, failed, launcher ?? new ProcessViewerLauncher(), applier ?? InlineApplier.Apply) + ? new(server, failed, launcher ?? new ProcessViewerLauncher(), applier) : null; public int Port => server.Port; @@ -127,6 +143,15 @@ public IReadOnlyList List() } } + /// + /// Held in this process, so there is nobody to fail to reach. + /// + public bool TryList(out IReadOnlyList pending) + { + pending = List(); + return true; + } + /// /// The queue is immutable, so the list can be handed out under the gate without copying it. /// @@ -199,6 +224,15 @@ int IQueueOwner.Enqueue(InlinePatch patch) int count; lock (gate) { + if (sessionEnding) + { + // Thrown rather than returned, as an owning viewer's is once it is closing: the + // interface has no refusal for this verb, and a handler that throws is answered + // with an error, so the sender stages the patch itself instead of believing a + // process on its way out took it + throw new InvalidOperationException("This tray is going with the session and can take nothing more."); + } + queue = queue.Enqueue(patch); count = queue.Count; } @@ -415,6 +449,12 @@ bool IQueueOwner.Has(string key) /// same rule for the arrangement where the tray holds the queue, which is the usual one. /// /// + /// The deletes are listed as the batch begins, ahead of the snapshots, and only those are + /// carried out. Read when their turn came, they included the delete of a snapshot that moved + /// inline while the batch was applying, whose patch the batch never had: the verified file + /// went with the patch replacing it still pending. + /// + /// /// The files count towards the progress a listing reports, since a move that is being retried /// while a diff tool lets go of it is as much of the wait as any snapshot. /// @@ -427,13 +467,13 @@ string IQueueOwner.AcceptAll() lock (accepting) { var moves = TrackedFiles?.Moves().Count ?? 0; - var deletes = TrackedFiles?.Deletes().Count ?? 0; - StartProgress(moves + deletes); + var deletes = TrackedFiles?.Deletes().Select(_ => _.Key).ToList() ?? []; + StartProgress(moves + deletes.Count); try { - message = AcceptEvery(moves + deletes, out var refused); - tracked = TrackedFiles?.AcceptAll(refused, Advance); - held = refused && deletes > 0; + message = AcceptEvery(moves + deletes.Count, out var refused); + tracked = TrackedFiles?.AcceptAll(deletes, refused, Advance); + held = refused && deletes.Count > 0; } finally { @@ -588,6 +628,15 @@ void IQueueOwner.Window(WindowCommand command, string? key) /// listing shows the queue shrinking and says how far the batch has got. Together they left /// the window showing an untouched queue for as long as the batch took. /// + /// + /// A file at a time where a file has several. Each snapshot applied on its own rewrote its + /// whole source file, and the rewrite is what costs: a file written a moment ago is scanned by + /// whatever watches the drive before the next thing can open it, so five hundred snapshots in + /// one file were half a minute of writes around a second of patching. So when a snapshot's + /// turn comes, the others still to do in its file are looked up with it and written with it, + /// one read and one write, each with its own outcome. That is still looked up when its turn + /// comes, by the first of the file's, and the wait it is looked up ahead of is the one write. + /// /// /// /// The tracked files the caller sweeps once the snapshots are done, for the progress total. @@ -613,12 +662,20 @@ string AcceptEvery(int files, out bool refused) } var tally = new AcceptAllTally(); + // The keys still to come to. One is taken out as its turn comes, or earlier, when an + // earlier snapshot of its file took it along + var waiting = new HashSet(keys); foreach (var key in keys) { - PendingInline? entry; + if (!waiting.Remove(key)) + { + continue; + } + + List claimed; lock (gate) { - entry = queue.Find(key); + var entry = queue.Find(key); if (entry is null || entry.Conflicted) { @@ -627,14 +684,36 @@ string AcceptEvery(int files, out bool refused) progress = progress?.Advance(); continue; } + + claimed = [entry]; + foreach (var other in queue.Items) + { + if (!ReferenceEquals(other, entry) && + !other.Conflicted && + InlineKey.SamePath(other.Patch.SourceFile, entry.Patch.SourceFile) && + waiting.Remove(other.Key)) + { + claimed.Add(other); + } + } + } + + var results = claimed.Count == 1 + ? [applier(claimed[0].Patch)] + : together(claimed.Select(_ => _.Patch).ToList()); + if (results.Count != claimed.Count) + { + throw new InvalidOperationException($"{claimed.Count} snapshots were applied together and {results.Count} outcomes came back."); } - var result = applier(entry.Patch); - // Together, so no listing can show the entry gone and the count not yet moved past it + // Together, so no listing can show an entry gone and the count not yet moved past it lock (gate) { - queue = queue.AcceptInBatch(entry, result, ref tally); - progress = progress?.Advance(); + for (var index = 0; index < claimed.Count; index++) + { + queue = queue.AcceptInBatch(claimed[index], results[index], ref tally); + progress = progress?.Advance(); + } } Changed?.Invoke(); @@ -780,4 +859,33 @@ public async ValueTask DisposeAsync() /// void Persist() => InlineStaging.Persist(queue.Items); + + /// + /// The session is ending: take nothing more, and stage what the queue holds, before returning. + /// + /// A logoff or a shutdown never reaches . The message loop does not + /// return for one, and Windows may end the process as soon as its windows have answered, so + /// the queue was lost every time the session ended with something pending - which, for a tray + /// started at login, is how it usually stops. See , which calls + /// this from inside the message. + /// + /// + /// Refusing first, under the gate, is what makes the staged queue the final one, the way + /// stopping the listener does for a clean exit: a patch acknowledged after this would be in + /// neither place. + /// + /// + public void SessionEnding() + { + IReadOnlyList pending; + lock (gate) + { + sessionEnding = true; + pending = queue.Items; + } + + InlineStaging.Persist(pending); + } + + bool sessionEnding; } diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs index bdb7b9519..90be35e43 100644 --- a/src/DiffEngineTray/Program.cs +++ b/src/DiffEngineTray/Program.cs @@ -2,7 +2,20 @@ static class Program { - static async Task Main() + /// + /// Synchronous, so that the attribute is on the method the runtime starts. For an + /// async Task Main that is a method the compiler writes, which carries none, and the + /// thread every window here lives on came up MTA: Clipboard.SetText throws ThreadStateException + /// there, so "Copy" in the debug view never copied anything. + /// + /// Blocking on is what that compiler written method did as well. Nothing + /// in it awaits until Application.Run() has returned, by which time WinForms has taken + /// its synchronization context back off this thread, so what follows continues on the pool + /// rather than waiting on a loop that is no longer pumping. + /// + /// + [STAThread] + static void Main() { TrayViewerDirectory.Register(); Logging.Init(); @@ -12,7 +25,7 @@ static async Task Main() try { - await Inner(); + Inner().GetAwaiter().GetResult(); } catch (Exception exception) { @@ -92,6 +105,11 @@ void Warn(string message) => owned.Start(); } + // A logoff or a shutdown does not come back through Application.Run(), so the staging the + // unwind below performs never happens for one. Only for a queue held here: a viewer that + // owns it stages its own + using var sessionEnd = owned is null ? null : new SessionEndWindow(owned.SessionEnding); + // Not a using. Anything throwing between here and the await below would dispose a task // that is still running, and Task.Dispose throws for one that has not completed - which // would replace whatever actually went wrong with an InvalidOperationException. A task diff --git a/src/DiffEngineTray/RemoteInlineHost.cs b/src/DiffEngineTray/RemoteInlineHost.cs index 092d0757f..70ab24593 100644 --- a/src/DiffEngineTray/RemoteInlineHost.cs +++ b/src/DiffEngineTray/RemoteInlineHost.cs @@ -49,14 +49,33 @@ class RemoteInlineHost : IInlineHost public string Description => $"owned by another process on port {ViewerClient.Port}"; public IReadOnlyList List() => - TryList(out var pending) ? pending : []; + TryAsk(out var pending) ? pending : []; + + /// + /// No owner is nothing pending here as well: the viewer has gone, and its queue went with it. + /// An owner that holds the port and did not answer is another matter. It is cold starting, or + /// wedged, and may be holding a patch whose delete this tray has. "Accept all" read that + /// silence as an empty queue and carried its deletes out, so the verified file went with the + /// patch that replaces it never tried. + /// + public bool TryList(out IReadOnlyList pending) + { + if (TryAsk(out pending)) + { + return true; + } + + // Decided now rather than from what was known going in, so an owner that exited while it + // was being asked reads as the absent one it has become + return !OwnerPresent(); + } /// /// False when the owner could not be asked, which flattens to nothing /// pending — right for a menu, and wrong for anything reading the answer as a statement about /// a particular entry. /// - static bool TryList(out IReadOnlyList pending) + static bool TryAsk(out IReadOnlyList pending) { if (!Exchange(new(ViewerVerb.List), ViewerClient.ShortTimeout, out var response) || !response.Ok) @@ -99,7 +118,7 @@ public AcceptOutcome Accept(PendingSnapshot snapshot, out string? message) return AcceptOutcome.Failed; } - if (!TryList(out var pending)) + if (!TryAsk(out var pending)) { // The owner took the accept and then could not be asked what became of it. Applied is // a guess, and the one that tells the user a snapshot landed that may not have @@ -192,7 +211,9 @@ static bool Send(ViewerVerb verb, string? key, TimeSpan wait, out string? messag static bool Exchange(ViewerMessage message, TimeSpan wait, [NotNullWhen(true)] out ViewerResponse? response) { - if (!PortIsHeld()) + // Only a table that says nothing holds the port skips the round trip. With no table to + // read, the connect decides as it always did + if (PortIsHeld() == false) { response = null; return false; @@ -201,6 +222,23 @@ static bool Exchange(ViewerMessage message, TimeSpan wait, [NotNullWhen(true)] o return ViewerClient.TrySend(message, out response, wait: wait); } + /// + /// Whether an owner is there to be asked, for telling one that did not answer from there being + /// none. Asked once an exchange has failed, which reports the same way + /// for both. + /// + /// The table says when nothing holds the port. When something does, or there is no table to + /// read, the exchange got as far as connecting and recorded what it + /// found: a refused connection, or a reply that is not this protocol, is no owner. The second + /// matters because the default port is registered to another program. A tray that could not + /// bind the port over one drives it remotely for good, and taking that program for a viewer + /// that does not answer would hold every delete on that machine. + /// + /// + static bool OwnerPresent() => + PortIsHeld() != false && + !ViewerClient.FoundUnowned(); + /// /// Whether anything holds the port, asked of the OS rather than found out by connecting to it. /// @@ -220,8 +258,11 @@ static bool Exchange(ViewerMessage message, TimeSpan wait, [NotNullWhen(true)] o /// found by the next call, and one that exits just after it costs the timeout exactly as /// before. /// + /// + /// Null when there is no table to read. + /// /// - static bool PortIsHeld() + static bool? PortIsHeld() { var port = ViewerClient.Port; try @@ -232,8 +273,7 @@ static bool PortIsHeld() } catch (NetworkInformationException) { - // No table to read, so let the connect decide as it always did - return true; + return null; } } } diff --git a/src/DiffEngineTray/SessionEndWindow.cs b/src/DiffEngineTray/SessionEndWindow.cs new file mode 100644 index 000000000..e3500067d --- /dev/null +++ b/src/DiffEngineTray/SessionEndWindow.cs @@ -0,0 +1,63 @@ +/// +/// Hears that the Windows session is ending, which is how a tray started at login usually stops. +/// +/// Nothing else here would. A logoff or a shutdown sends every top level window +/// WM_QUERYENDSESSION and then WM_ENDSESSION, and Windows may end the process as soon as those are +/// answered. A Form turns them into a close, which is how the viewer hears of it. The tray has no +/// Form, only the notify icon's window, which agrees to the query and does nothing more: so +/// Application.Run() never returns, nothing after it in Program runs, and the queue an +/// owning tray holds in memory went with the process where a clean exit stages it. +/// +/// +/// A window of its own, because the two messages are sent rather than posted, which a message +/// filter never sees. Top level, because a message only window is not sent them. Never shown. +/// +/// +/// On WM_ENDSESSION rather than on the query before it. Another application can still refuse the +/// query, and a session that then carries on would have a queue both staged on disk and held here. +/// +/// +sealed class SessionEndWindow : + NativeWindow, + IDisposable +{ + const int endSession = 0x0016; + + readonly Action ending; + + /// + /// Run inside the message, on the thread this is created on, so whatever it does is done by + /// the time the message is answered: there may be no time after that. + /// + public SessionEndWindow(Action ending) + { + this.ending = ending; + CreateHandle(new()); + } + + protected override void WndProc(ref Message message) + { + // WParam is zero when the session is not ending after all, which everything that agreed + // to the query is told once something else has refused it + if (message.Msg == endSession && + message.WParam != IntPtr.Zero) + { + try + { + ending(); + } + catch (Exception exception) + { + // Logged and nothing more. Thrown out of here it reaches WinForms' exception + // dialog, which during a logoff is a window holding the session open with nobody + // left to answer it + Log.Error(exception, "Failed while the session was ending"); + } + } + + base.WndProc(ref message); + } + + public void Dispose() => + DestroyHandle(); +} diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs index 46b86d5ec..6328d0555 100644 --- a/src/DiffEngineTray/Tracker.cs +++ b/src/DiffEngineTray/Tracker.cs @@ -172,6 +172,17 @@ public TrackedMove AddMove( { var exeFile = Path.GetFileName(exe); var targetFile = Path.GetFileName(target); + + // A move onto a file is a run that verified against it, so a delete an earlier run raised + // for that file no longer describes a stale one. DiffRunner.SettleDelete says the same + // thing, but not from a library that predates it, not while a viewer holds the queue the + // settle is sent to, and not while that port is remembered as unowned. The delete stayed, + // and "Accept all" moved the received file into place and then deleted it + if (deletes.TryRemove(target, out _)) + { + Log.Information("DeleteWithdrawn. A move now targets the file. File:{file}", target); + } + return moves.AddOrUpdate( temp, addValueFactory: temp => @@ -211,7 +222,9 @@ public TrackedMove AddMove( ProcessEx.TryGet(processId.Value, out process); } - var move = BuildTrackedMove(temp, exe, arguments, canKill, target, process); + var move = exe == null + ? Retarget(existing, target, process) + : BuildTrackedMove(temp, exe, arguments, canKill, target, process); if (exeFile == null) { @@ -226,6 +239,32 @@ public TrackedMove AddMove( }); } + /// + /// A move that arrives for a pair already tracked and names no tool: what it says is the + /// target, and everything recorded about the tool stays as it was. + /// + /// A move with no tool is one over the viewer port, which carries the two paths and nothing + /// else, or one from a run that launched nothing. Neither says anything about the tool the + /// pair was first tracked with, and filling the gap from the extension, as a move seen for the + /// first time has to, replaced that tool with this tray's own choice. "Open diff tool" on a + /// viewer pair is how it happened by hand: the viewer it starts cannot bind the port and + /// forwards the pair here as a Diff. The pair then read as another tool's with no window, so + /// "Accept open" passed over it while it was on screen, and it had become killable. + /// + /// + static TrackedMove Retarget(TrackedMove existing, string target, Process? process) => + new( + existing.Temp, + target, + existing.Exe, + existing.Arguments, + existing.CanKill, + process, + SolutionDirectoryFinder.Find(target), + Path.GetExtension(target).TrimStart('.'), + existing.KillLockingProcess, + existing.IsViewer); + static TrackedMove BuildTrackedMove(string temp, string? exe, string? arguments, bool? canKill, string target, Process? process) { var solution = SolutionDirectoryFinder.Find(target); @@ -408,16 +447,23 @@ public Task AcceptAllSnapshots() => /// way, for the same reason. /// /// - Task AcceptSnapshotsThenDeletes() => + /// + /// The deletes that were pending when the accept-all began, which are the only ones it carries + /// out: see . + /// + /// + /// The files the moves accepted ahead of this were moved onto, which no delete here may remove. + /// + Task AcceptSnapshotsThenDeletes(List pending, HashSet written) => Task.Run(() => { try { if (!SweepSnapshots(out var failure)) { - AcceptAllDeletes(); + AcceptDeletes(pending, written); } - else if (!deletes.IsEmpty) + else if (pending.Any(_ => deletes.ContainsKey(_.File))) { failure = failure is null ? DeletesHeld : $"{failure} {DeletesHeld}"; } @@ -441,7 +487,8 @@ Task AcceptSnapshotsThenDeletes() => public const string DeletesHeld = "Pending deletes were kept, since a snapshot in this batch was not written and a file being deleted may be the only copy of it left. Accept them on their own to delete them anyway."; /// - /// Accepts every pending snapshot, and returns whether one it tried was not written. + /// Accepts every pending snapshot, and returns whether one it tried was not written, or the + /// queue could not be asked whether it holds any. /// /// What to tell the user, when something is still pending afterwards. bool SweepSnapshots(out string? failure) @@ -451,7 +498,17 @@ bool SweepSnapshots(out string? failure) // a stale empty cache would silently do nothing. Inside the worker rather than in front of // it, because the caller is a menu click or a hot key and the read is a round trip // whenever a viewer owns the queue. - if (inline.List().Count == 0) + if (!inline.TryList(out var pending)) + { + // Something holds the queue and did not say what is in it, which is not the queue + // being empty. Reported the way a snapshot that was not written is, because the same + // thing waits on the answer: a patch held over there may be the one a delete here + // belongs to, and reading silence as nothing pending carried that delete out + failure = "Could not accept the pending snapshots. The snapshot viewer did not answer."; + return true; + } + + if (pending.Count == 0) { return false; } @@ -569,9 +626,14 @@ class AcceptBatch // Wire-driven accepts run on a listener thread with no user attached, so the locked-files // dialog must never be raised for them. public bool NeverPrompt; + + // The files this batch moved a received file onto, for the deletes swept after it: see + // WrittenOrAwaited. + public readonly HashSet Written = new(StringComparer.OrdinalIgnoreCase); } - void AcceptMoves(IEnumerable toAccept) + /// The files a received file was moved onto. + HashSet AcceptMoves(IEnumerable toAccept) { var batch = new AcceptBatch(); foreach (var move in toAccept) @@ -586,6 +648,8 @@ void AcceptMoves(IEnumerable toAccept) AcceptMove(move, batch); } } + + return batch.Written; } void AcceptMove(TrackedMove move, AcceptBatch batch) @@ -646,6 +710,7 @@ bool InnerMove(TrackedMove move, AcceptBatch batch) if (FileEx.SafeMove(move.Temp, move.Target)) { + batch.Written.Add(move.Target); DeleteTempDirectory(move); return true; } @@ -856,38 +921,81 @@ public void Clear() /// on a worker for the reason gives. The menu and the /// hot keys discard it; tests await it so what the other surface should now be showing is /// settled rather than in flight. + /// + /// The deletes are listed here, before anything is accepted, and only those are carried out. A + /// snapshot moving inline can land while a batch is applying. Whoever owns the queue took its + /// snapshots as the sweep began, so that patch is not in the batch, and the deletes were read + /// when their turn came, so its delete was: the verified file went while the patch replacing + /// it was only pending. Ahead of the snapshots rather than beside them, because Verify raises + /// the delete and then queues the patch, so a delete listed this early has a patch that was + /// there to be taken unless the batch began between the two. + /// /// public Task AcceptOpen() { - AcceptMoves( + var pending = deletes.Values.ToList(); + var written = AcceptMoves( moves.Values .Where(_ => _.IsOpen) .ToList()); // Every pending snapshot is open by definition: the viewer only stays running while it // has something to show. - return AcceptSnapshotsThenDeletes(); + return AcceptSnapshotsThenDeletes(pending, written); } /// public Task AcceptAll() { - AcceptMoves(moves.Values); + var pending = deletes.Values.ToList(); + var written = AcceptMoves(moves.Values); - return AcceptSnapshotsThenDeletes(); + return AcceptSnapshotsThenDeletes(pending, written); } - void AcceptAllDeletes() + void AcceptDeletes(List pending, HashSet written) { // One at a time, and no Clear afterwards: a delete that fails re-tracks itself, and // clearing would throw that away. Unguarded, the first bad one also took the rest of the // sweep with it, so "Accept all" stopped at the first read-only file - foreach (var delete in deletes.Values.ToList()) + foreach (var delete in pending) { + // Settled, withdrawn or accepted on its own since the batch began + if (!deletes.ContainsKey(delete.File)) + { + continue; + } + + if (WrittenOrAwaited(delete, written)) + { + Log.Information("Kept the pending delete of `{Name}`: a move wrote that file, or is still pending onto it", delete.Name); + continue; + } + Accept(delete); } } + /// + /// Whether a sweep must leave this delete pending, because the file it would remove is one a + /// move in the same sweep has just written or one a move still pending is going to write. + /// + /// withdraws the delete a move finds waiting for its target, so the two + /// are only pending together when the delete arrived second, and which of them is the stale + /// one is then not knowable from here. The move is the snapshot arriving and the delete is the + /// last copy leaving, so the move goes ahead and the delete waits to be accepted on its own: + /// carried out, it removed the received file a moment after that file had been moved into + /// place, and neither was left. + /// + /// + /// By what was written rather than by what was swept. A move whose received file has gone is + /// dropped without writing anything, and the delete beside that one is the newer statement. + /// + /// + bool WrittenOrAwaited(TrackedDelete delete, HashSet written) => + written.Contains(delete.File) || + moves.Values.Any(_ => string.Equals(_.Target, delete.File, StringComparison.OrdinalIgnoreCase)); + public ICollection Deletes => deletes.Values; public ICollection Moves => moves.Values; @@ -1001,13 +1109,18 @@ bool ITrackedFiles.Untrack(string key) return (false, null); } - (int accepted, int kept) ITrackedFiles.AcceptAll(bool holdDeletes, Action? advanced) + (int accepted, int kept) ITrackedFiles.AcceptAll(IReadOnlyCollection deleteKeys, bool holdDeletes, Action? advanced) { var accepted = 0; var kept = 0; + // One batch for the whole sweep, so the deletes below know what its moves wrote + var batch = new AcceptBatch + { + NeverPrompt = true + }; foreach (var move in moves.Values.ToList()) { - if (AcceptWithoutPrompting(move).ok) + if (AcceptWithoutPrompting(move, batch).ok) { accepted++; } @@ -1019,11 +1132,21 @@ bool ITrackedFiles.Untrack(string key) advanced?.Invoke(); } - foreach (var delete in deletes.Values.ToList()) + foreach (var key in deleteKeys) { + // Settled, withdrawn or accepted on its own since the batch began. Nothing to carry + // out, and one fewer to wait for + if (!TrackedKeys.TryStrip(key, TrackedKeys.DeletePrefix, out var file) || + !deletes.TryGetValue(file, out var delete)) + { + advanced?.Invoke(); + continue; + } + // Held rather than tried, and left tracked, so it can still be accepted on its own by // anyone who knows the file is redundant if (!holdDeletes && + !WrittenOrAwaited(delete, batch.Written) && AcceptTracked(delete).ok) { accepted++; @@ -1083,17 +1206,22 @@ int ITrackedFiles.DiscardAll() return (true, $"Deleted {removed.Name}"); } - (bool ok, string? message) AcceptWithoutPrompting(TrackedMove move) + (bool ok, string? message) AcceptWithoutPrompting(TrackedMove move) => + AcceptWithoutPrompting( + move, + new() + { + NeverPrompt = true + }); + + (bool ok, string? message) AcceptWithoutPrompting(TrackedMove move, AcceptBatch batch) { if (!moves.TryRemove(move.Temp, out var removed)) { return (false, null); } - if (InnerMove(removed, new() - { - NeverPrompt = true - })) + if (InnerMove(removed, batch)) { return (true, $"Accepted {removed.Name}"); } diff --git a/src/DiffEngineViewer.Benchmarks/BatchBookkeepingBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/BatchBookkeepingBenchmarks.cs new file mode 100644 index 000000000..8f2505e5a --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/BatchBookkeepingBenchmarks.cs @@ -0,0 +1,66 @@ +using BenchmarkDotNet.Attributes; + +// What a bulk accept costs apart from the applying: the claims, the records and the queue rebuilt +// after each. The applier here answers at once and touches nothing, so everything measured is the +// session's own work, which is done under the lock the render loop takes. +[MemoryDiagnoser] +public class BatchBookkeepingBenchmarks +{ + [Params(200, 2000)] + public int Entries; + + SessionState all = null!; + SessionState group = null!; + + static readonly ViewerActions applied = new( + static _ => InlineApplyResult.Applied, + static (_, _) => + { + }, + static _ => + { + }); + + [GlobalSetup] + public void Setup() + { + var directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks", "bookkeeping"); + var state = SessionState.Start(ViewerMode.Inline, 160, 50); + for (var index = 0; index < Entries; index++) + { + state = ViewerSession.EnqueueInline( + state, + new(Path.Combine(directory, "BenchTests.cs"), 10 + index, $"\"old{index}\"", $"new {index}") + { + TestName = "Bench", + MemberName = "Bench" + }); + } + + all = state; + // One file and one test, so the queue is one group under one header, which is row 0. + group = ViewerSession.OpenMenu(state, 0); + if (group.Menu is null) + { + throw new("No menu opened over the first row."); + } + } + + [Benchmark] + public object AcceptAll() => + Done(ViewerSession.Apply(all, CommandKind.AcceptAll, applied)); + + [Benchmark] + public object AcceptGroup() => + Done(ViewerSession.Apply(group, CommandKind.AcceptGroup, applied)); + + SessionState Done(SessionState state) + { + if (state.Queue.Count != 0) + { + throw new($"{state.Queue.Count} of {Entries} were left: {state.Message}"); + } + + return state; + } +} diff --git a/src/DiffEngineViewer.Benchmarks/CellGridBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/CellGridBenchmarks.cs new file mode 100644 index 000000000..192880cc7 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/CellGridBenchmarks.cs @@ -0,0 +1,57 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +// How a row is cut up for drawing. A run of characters the embedded font draws a cell wide is one +// segment, drawn as one string; anything else is a segment a character, each laid out on its own +// by every head and encoded on its own for two of them. So what counts as such a character decides +// what a row of box drawing, arrows or typographic punctuation costs: a table drawn in a snapshot, +// which is rows of little else. +[MemoryDiagnoser] +public class CellGridBenchmarks +{ + [Params("ascii", "box", "cjk")] + public string Text = ""; + + string[] rows = []; + + [GlobalSetup] + public void Setup() => + rows = Enumerable.Range(0, 100).Select(Row).ToArray(); + + // The segments of a hundred rows, which is two panes of a tall window. + [Benchmark] + public int SegmentsOfAHundredRows() + { + var segments = 0; + foreach (var row in rows) + { + segments += CellGrid.Segments(row).Count; + } + + return segments; + } + + string Row(int index) + { + var builder = new StringBuilder(); + for (var cell = 0; cell < 30; cell++) + { + switch (Text) + { + case "box": + // A table's rule and its cells: light box drawing, an arrow, an ellipsis + builder.Append(cell % 6 == 0 ? "┼" : "──"); + builder.Append(cell % 10 == 0 ? " → …" : ""); + break; + case "cjk": + builder.Append((char) (0x4E00 + (index * 7 + cell * 13) % 2000)); + break; + default: + builder.Append("ab "); + break; + } + } + + return builder.ToString(); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/DiffEngineViewer.Benchmarks.csproj b/src/DiffEngineViewer.Benchmarks/DiffEngineViewer.Benchmarks.csproj new file mode 100644 index 000000000..b085457d6 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/DiffEngineViewer.Benchmarks.csproj @@ -0,0 +1,38 @@ + + + + Exe + net10.0 + enable + false + true + + true + $(MSBuildThisFileDirectory)..\key.snk + + $(NETCoreSdkPortableRuntimeIdentifier) + osx;$(DocumentsRids) + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Benchmarks/Directory.Build.props b/src/DiffEngineViewer.Benchmarks/Directory.Build.props new file mode 100644 index 000000000..2164d998e --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/Directory.Build.props @@ -0,0 +1,15 @@ + + + + false + false + false + false + + diff --git a/src/DiffEngineViewer.Benchmarks/DocumentWatchBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/DocumentWatchBenchmarks.cs new file mode 100644 index 000000000..4ecf680e2 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/DocumentWatchBenchmarks.cs @@ -0,0 +1,253 @@ +#nullable enable +using System.IO.Compression; +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace DiffEngineViewer.Benchmarks; + +// A pair of documents opened in the viewer, received beside verified, timed to the two things a +// reader waits for: the right side's first page, and both sides drawn, which is when every page +// that differs is known. +// +// Through DocumentWatch itself, a job at a time as its tests drive it, and with the documents +// folder a viewer loads rather than a stand-in for it: PDFium for the PDFs, one lock and all, and +// Morph for the Word files. So this times the renderers as well as the order they are asked in, +// and a Word file's pages all land together at the end, which makes its two rows one number. +// +// Nothing here is a viewer's own. The pair is written to a temp directory, and the copies and the +// pages go to the plugin's cache, which is a temp directory this process makes for itself. Both +// are deleted when a benchmark is done. +[MemoryDiagnoser] +public class DocumentWatchBenchmarks +{ + [Params(".pdf", ".docx")] + public string Extension = ""; + + [Params(10, 50)] + public int Pages; + + DocumentPlugin renderer = null!; + DocumentPlugin documents = null!; + string directory = ""; + string left = ""; + string right = ""; + volatile SessionHost? host; + volatile bool untilTheRightSideHasAPage; + + [GlobalSetup] + public void Setup() + { + renderer = DocumentPlugin.Find() ?? + throw new InvalidOperationException("There is no documents folder beside the benchmarks. DiffEngineViewer.Benchmarks.csproj imports Documents.targets to put one there."); + // The renderer a viewer loads, behind one more delegate. See Render. + documents = new(renderer.Text, Render); + directory = Directory.CreateTempSubdirectory("deview-benchmark-documents-").FullName; + left = Write("sample.received", changed: true); + right = Write("sample.verified", changed: false); + } + + [GlobalCleanup] + public void Cleanup() + { + // The cache goes with the plugin that made it + documents.Dispose(); + renderer.Dispose(); + Directory.Delete(directory, true); + } + + // How long the right pane has nothing but a spinner in it. + [Benchmark] + public int RightSidesFirstPage() + { + var state = Open(untilTheRightSideHasAPage: true); + var drawn = Drawn(state, state.Current!.RightDocument); + if (drawn == 0) + { + throw new InvalidOperationException("The right side drew no page."); + } + + return drawn; + } + + // How long until the status line can say which pages differ. + [Benchmark] + public int BothSidesComplete() + { + var state = Open(untilTheRightSideHasAPage: false); + var entry = state.Current!; + var (leftPages, rightPages) = DocumentPages.Of(state, entry); + if (leftPages is not { Complete: true, Failure: null } || + rightPages is not { Complete: true, Failure: null } || + leftPages.Pages.Count != Pages || + rightPages.Pages.Count != Pages) + { + throw new InvalidOperationException($"The pair did not draw as {Pages} pages a side: {Describe(leftPages)} and {Describe(rightPages)}."); + } + + return DocumentPages.Differing(entry, leftPages, rightPages).Count; + } + + // The pair as it arrives, hashed and with its text unread, and then the watch until it has + // nothing left to do. + SessionState Open(bool untilTheRightSideHasAPage) + { + // Nothing kept from the run before. The text, the copies and the pages all go, as they do + // when a pair leaves the queue. + documents.Keep(new HashSet()); + this.untilTheRightSideHasAPage = untilTheRightSideHasAPage; + var entry = QueueEntry.ForFiles(left, right, FileSide.Read(left, documents), FileSide.Read(right, documents)); + var opened = new SessionHost(ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File, 160, 48), entry)); + host = opened; + var watch = new DocumentWatch(opened, documents); + while (watch.Pump()) + { + } + + return opened.State; + } + + // The renderer a viewer loads, with one thing added. A run that is timing the right side's + // first page stops drawing once that page is in the state, by throwing from the next page to + // land on either side. Nothing else stops a call into PDFium or Morph, and left to finish, + // the pages after the one being timed would be most of what was measured. + int Render(string path, string pages, string projection, Action landed) => + renderer.Render( + path, + pages, + _ => + { + landed(_); + if (untilTheRightSideHasAPage && + host is { } opened && + Drawn(opened.State, opened.State.Current?.RightDocument) > 0) + { + throw new OperationCanceledException("The right side's first page has landed."); + } + }, + Enum.Parse(projection)); + + static int Drawn(SessionState state, DocumentFile? side) => + DocumentPages.Of(state, side)?.Pages.Count ?? 0; + + static string Describe(Rendering? rendering) => + rendering is null + ? "not drawn" + : $"{rendering.Pages.Count} pages{(rendering.Complete ? "" : ", still drawing")}{(rendering.Failure is null ? "" : $", {rendering.Failure}")}"; + + string Write(string name, bool changed) + { + var path = Path.Combine(directory, $"{name}{Extension}"); + File.WriteAllBytes(path, Extension == ".pdf" ? Pdf(Pages, changed) : Docx(Pages, changed)); + return path; + } + + // What one page says: a heading and a dozen lines, the same on both sides but for one page in + // ten, which is the usual shape of a document snapshot that failed. + static IEnumerable Lines(int page, bool changed) + { + yield return $"Page {page + 1}"; + var ending = changed && page % 10 == 3 + ? "and this page has changed since it was verified." + : "the quick brown fox jumps over the lazy dog."; + for (var line = 1; line <= 12; line++) + { + yield return $"Line {line} of page {page + 1}: {ending}"; + } + } + + // A PDF written out by hand, A4, a content stream per page. + static byte[] Pdf(int pages, bool changed) + { + var builder = new StringBuilder("%PDF-1.4\n"); + var offsets = new List(); + + void Add(string body) + { + // ASCII throughout, so a character offset is a byte offset + offsets.Add(builder.Length); + builder.Append($"{offsets.Count} 0 obj\n{body}\nendobj\n"); + } + + var kids = string.Join(" ", Enumerable.Range(0, pages).Select(_ => $"{4 + _ * 2} 0 R")); + Add("<< /Type /Catalog /Pages 2 0 R >>"); + Add($"<< /Type /Pages /Kids [{kids}] /Count {pages} >>"); + Add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + for (var page = 0; page < pages; page++) + { + var text = string.Concat(Lines(page, changed).Select(_ => $"({_}) Tj T*\n")); + var content = $"BT /F1 11 Tf 56 780 Td 16 TL\n{text}ET"; + Add($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents {offsets.Count + 2} 0 R >>"); + Add($"<< /Length {content.Length} >>\nstream\n{content}\nendstream"); + } + + var table = builder.Length; + builder.Append($"xref\n0 {offsets.Count + 1}\n0000000000 65535 f \n"); + foreach (var offset in offsets) + { + builder.Append($"{offset:D10} 00000 n \n"); + } + + builder.Append($"trailer\n<< /Size {offsets.Count + 1} /Root 1 0 R >>\nstartxref\n{table}\n%%EOF\n"); + return Encoding.ASCII.GetBytes(builder.ToString()); + } + + // A Word document written out by hand, A4, a page break after each page's lines. + static byte[] Docx(int pages, bool changed) + { + var body = new StringBuilder(); + for (var page = 0; page < pages; page++) + { + foreach (var line in Lines(page, changed)) + { + body.Append($"{line}"); + } + + if (page < pages - 1) + { + body.Append(""""""); + } + } + + body.Append(""""""); + + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + Part( + archive, + "[Content_Types].xml", + """ + + + + + + + """); + Part( + archive, + "_rels/.rels", + """ + + + + + """); + Part( + archive, + "word/document.xml", + $""" + + {body} + """); + } + + return stream.ToArray(); + } + + static void Part(ZipArchive archive, string name, string content) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write(content); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/FrameBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/FrameBenchmarks.cs new file mode 100644 index 000000000..a9bab8735 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/FrameBenchmarks.cs @@ -0,0 +1,49 @@ +using BenchmarkDotNet.Attributes; + +// What a frame costs before a head is handed it. The loop presents sixty times a second, ten when +// the window is hidden, and the screen it presents is a function of the session state alone, which +// only changes when something happens. So this is paid once per change at best, and was paid once +// per frame: a window nobody is touching built its whole queue's labels, groups and tooltips sixty +// times a second to draw the forty rows that fit. +[MemoryDiagnoser] +public class FrameBenchmarks +{ + [Params(100, 500, 2000)] + public int Entries; + + SessionState state = null!; + + [GlobalSetup] + public void Setup() + { + // Nothing here is read: an inline entry is built from its patch, and the directory only has + // to be somewhere with no solution above it. + var directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks", "frame"); + var built = SessionState.Start(ViewerMode.Inline, 160, 50); + for (var index = 0; index < Entries; index++) + { + built = ViewerSession.EnqueueInline( + built, + new(Path.Combine(directory, $"Sample{index % 40}Tests.cs"), 10 + index, "\"old\"", $"new {index}") + { + TestName = $"Test{index}", + Framework = "net10.0" + }); + } + + state = built; + } + + readonly ScreenCache screens = new(); + + // One screen from one state: what every frame paid, and what a change to the state still does. + [Benchmark(Baseline = true)] + public object BuildScreen() => + ScreenBuilder.Build(state); + + // What the loop asks for each frame now: the screen of the state, which is the one in hand + // whenever the state is the one the last frame had. + [Benchmark] + public object FrameWithNothingChanged() => + screens.For(state); +} diff --git a/src/DiffEngineViewer.Benchmarks/GroupAcceptBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/GroupAcceptBenchmarks.cs new file mode 100644 index 000000000..b766b6763 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/GroupAcceptBenchmarks.cs @@ -0,0 +1,147 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +// "Accept all for" a test, clicked in a window that owns its queue: every snapshot of the group +// written into one source file by the real applier. The first number is how long the click holds +// the render thread, under the lock every arrival and listing waits on. The second is how long +// until the last snapshot is written, whoever writes it. +// +// Each round leaves the file ready for the next: the snapshots go from one set of literals to the +// other and then back, so every invocation applies every patch for real and nothing has to be put +// back in between. +[MemoryDiagnoser] +public class GroupAcceptBenchmarks +{ + const int columns = 160; + const int rows = 50; + + [Params(20, 200)] + public int Entries; + + string directory = ""; + SessionState forward = null!; + SessionState back = null!; + bool flipped; + string accepted = ""; + readonly IViewerWindow window = new NoWindow(); + + // The second item of a header's menu, after the fold. + static readonly ViewerInput click = new(CommandKind.None, -1, -1, 0, false, columns, rows) + { + ClickedMenuItem = 1 + }; + + [GlobalSetup] + public void Setup() + { + directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks", $"group-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var source = Path.Combine(directory, "BenchTests.cs"); + File.WriteAllText(source, Source()); + forward = Queue(source, "a", "b"); + back = Queue(source, "b", "a"); + flipped = false; + accepted = $"Accepted {Entries}"; + } + + [GlobalCleanup] + public void Cleanup() => + Directory.Delete(directory, true); + + [Benchmark] + public void WindowHeldByTheClick() + { + var state = ViewerProgram.Apply(Next(), click, null, window); + // Either the click applied everything, or it began a batch for a worker to apply. + if (state.Batch is null && + state.Message != accepted) + { + throw new($"The click ended with: {state.Message}"); + } + } + + [Benchmark] + public void ClickUntilEverySnapshotIsWritten() + { + var host = new SessionHost(Next()); + host.Mutate(_ => ViewerProgram.Apply(_, click, null, window)); + // What the loop starts on a worker when a click leaves a batch behind. Nothing to do when + // the click applied everything itself. + new AcceptAllRunner(host, ViewerActions.Real).Drive(); + if (host.State.Message != accepted) + { + throw new($"The batch ended with: {host.State.Message}"); + } + } + + SessionState Next() + { + flipped = !flipped; + return flipped ? forward : back; + } + + SessionState Queue(string source, string from, string to) + { + var state = SessionState.Start(ViewerMode.Inline, columns, rows); + for (var index = 0; index < Entries; index++) + { + state = ViewerSession.EnqueueInline( + state, + new(source, 6 + index, $"\"{from}{index}\"", $"{to}{index}") + { + TestName = "Bench", + MemberName = "Bench" + }); + } + + // One file and one test, so the queue is one group under one header, which is row 0. + var open = ViewerSession.OpenMenu(state, 0); + if (open.Menu?.Items[click.ClickedMenuItem].Kind != CommandKind.AcceptGroup) + { + throw new("The menu over the first row has no group accept where the click lands."); + } + + return open; + } + + string Source() + { + var builder = new StringBuilder(); + builder.Append("public class BenchTests\n{\n [Test]\n public async Task Bench()\n {\n"); + for (var index = 0; index < Entries; index++) + { + builder.Append($" await Verify(value{index}).Snapshot(\"a{index}\");\n"); + } + + builder.Append(" }\n}\n"); + return builder.ToString(); + } + + sealed class NoWindow : IViewerWindow + { + public bool Present(Screen screen) => + true; + + public ViewerInput Poll() => + default; + + public void SetHidden(bool hidden) + { + } + + public void SetClipboard(string text) + { + } + + public void Focus() + { + } + + public bool Capture(Screen screen, int width, int height, string pngPath) => + false; + + public void Dispose() + { + } + } +} diff --git a/src/DiffEngineViewer.Benchmarks/NativeFrameBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/NativeFrameBenchmarks.cs new file mode 100644 index 000000000..d5d022607 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/NativeFrameBenchmarks.cs @@ -0,0 +1,86 @@ +#nullable enable +using BenchmarkDotNet.Attributes; + +/// +/// What one frame costs the Linux head to draw: a comparison of text, for what a frame costs with +/// no picture in it, and a comparison of two pictures that fill their panes, once with pictures +/// that can be seen through and once with pictures that cannot. At the size the viewer opens at, +/// and at the size of a window maximised on a 4K screen. +/// +/// Linux only, and only with a display to open a window on: anywhere else these are left out of +/// the run. The numbers in this file's history are from an ubuntu:24.04 container set up +/// as the unix job in build.yml is, which is Xvfb and Mesa's software rasteriser, +/// here on the four threads a hosted runner would give it. The shim is built from native +/// and copied to src/DiffEngineViewer.Linux/runtimes/linux-x64/native, as that job does, +/// and then: LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe LP_NUM_THREADS=4 xvfb-run -a +/// --server-args="-screen 0 3840x2160x24" dotnet run -c Release --project +/// src/DiffEngineViewer.Benchmarks -- --filter "*NativeFrame*" --warmupCount 5 --iterationCount +/// 30. +/// +/// +/// Mean here is the time from a frame being handed over to the window having it, and that is +/// only so because of . The CPU and Triangles columns do not depend +/// on it. +/// +/// +[MemoryDiagnoser] +[NativeHead] +public class NativeFrameBenchmarks +{ + [Params("1100x700", "3840x2160")] + public string Window = ""; + + NativeHead? head; + Screen[] screens = []; + int turn; + + [GlobalSetup(Target = nameof(Text))] + public void OpenOnText() => + Open(NativeScenes.Text); + + [GlobalSetup(Target = nameof(OpaquePictures))] + public void OpenOnOpaquePictures() => + Open(NativeScenes.OpaquePictures); + + [GlobalSetup(Target = nameof(TranslucentPictures))] + public void OpenOnTranslucentPictures() => + Open(NativeScenes.TranslucentPictures); + + [GlobalCleanup] + public void Close() => + head?.Dispose(); + + /// + /// The shim holds the loop to sixty frames a second by waiting out what is left of a sixtieth + /// of a second once a frame is on the screen, so frames presented one after another all take + /// 16.7 ms however little there was to draw. A frame presented later than that after the one + /// before has nothing left to wait out, and takes as long as it took. BenchmarkDotNet runs a + /// benchmark that has one of these once an iteration, so every measured frame is such a frame. + /// + [IterationSetup] + public void FallOutOfStep() => + Thread.Sleep(20); + + [Benchmark] + public bool Text() => + Frame(); + + [Benchmark] + public bool OpaquePictures() => + Frame(); + + [Benchmark] + public bool TranslucentPictures() => + Frame(); + + bool Frame() => + head!.Run(screens[turn++ & 1]); + + void Open(Func scene) + { + var size = Window.Split('x'); + head = NativeHead.Open(int.Parse(size[0]), int.Parse(size[1])); + screens = NativeScenes.Alternating(scene(head.Columns, head.Rows)); + head.Settle(screens[0]); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/NativeHead.cs b/src/DiffEngineViewer.Benchmarks/NativeHead.cs new file mode 100644 index 000000000..ec017e064 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/NativeHead.cs @@ -0,0 +1,363 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; + +/// +/// The Linux head's window, opened by a benchmark and turned the way ViewerProgram's loop +/// turns it: a screen presented through , then input polled. +/// +/// A frame's time on the clock says little about what it cost. The shim holds the loop to sixty +/// frames a second, so a frame that took one millisecond to draw and one that took ten both come +/// back after 16.7. What is counted around the turns of an operation instead is the processor +/// time the process spent, on every thread, since a software rasteriser spreads a frame over +/// several, and what was drawn, which is asked of OpenGL itself: the primitives generated between +/// the start of a turn and its end are the triangles the shim submitted, whichever rasteriser +/// then filled them, and a turn that generated none put nothing on the screen. +/// +/// +/// Each turn has a query of its own, and none is read until the operation's turns are over and +/// the clock has been read. Reading one has llvmpipe finish whatever it is holding, on all its +/// threads, and for a turn that drew nothing that is work the question itself made: read turn by +/// turn, it would be most of what a second costs a window that draws nothing. +/// +/// +/// Every call into the shim has to come from the thread that opened the window, and so does every +/// call into OpenGL, since the GL context belongs to that thread. BenchmarkDotNet's in process +/// runner gives each benchmark case a thread of its own and runs its setup, its iterations and its +/// cleanup there, so a window opened in a case's setup and closed in its cleanup keeps to that. +/// +/// +sealed unsafe class NativeHead : IDisposable +{ + const string shim = "diffengine_viewer"; + + /// + /// What every turn since the process started has added up to, for + /// to take the difference of across a run. + /// + public static NativeHeadTotals Totals; + + static string? shimPath; + static bool resolved; + static int? server; + static bool serverSought; + + /// + /// The most turns one operation has: a second of them. + /// + public const int MostTurns = 60; + + readonly IViewerWindow window; + readonly uint[] queries; + + NativeHead(IViewerWindow window, uint[] queries) + { + this.window = window; + this.queries = queries; + } + + /// + /// Whether there is a shim to load and a display to open its window on. False on Windows, + /// whose head draws with WinForms, and on macOS, where a window may only be made on the main + /// thread, so the benchmarks that need this are left out of a run there rather than failing it. + /// + public static bool Available => + OperatingSystem.IsLinux() && + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")) && + TryFindShim(out _); + + /// + /// The window's size in character cells, as the shim measured it from the font it loaded. + /// + public int Columns { get; private set; } + + public int Rows { get; private set; } + + /// + /// Opens the window on screen rather than hidden: a frame put into a window nobody can see + /// costs the rasteriser the same and the window system nothing, and the second is part of + /// what a frame costs. + /// + public static NativeHead Open(int width, int height) + { + if (!resolved) + { + // In place of NativeResolver.Register, which only looks beside the assembly. A + // resolver can be set once for an assembly, and nothing else in this process sets one. + resolved = true; + NativeLibrary.SetDllImportResolver(typeof(NativeViewerWindow).Assembly, Resolve); + } + + var window = NativeViewerWindow.Open("DiffEngineViewer", width, height, false, null, out var error); + if (window is null) + { + throw new InvalidOperationException(error); + } + + var queries = new uint[MostTurns]; + fixed (uint* ids = queries) + { + Gl.GenQueries(queries.Length, ids); + } + + var head = new NativeHead(window, queries); + + // The first frame is drawn before the window's size in cells is known, as the loop's is. + window.Present(ScreenBuilder.Build(SessionState.Start(ViewerMode.File))); + var input = window.Poll(); + head.Columns = input.Columns; + head.Rows = input.Rows; + return head; + } + + /// + /// One operation of a benchmark: so many turns of the loop on one screen, counted. + /// + public bool Run(Screen screen, int turns = 1) + { + var open = true; + var before = ProcessorTime(); + for (var turn = 0; turn < turns; turn++) + { + Gl.BeginQuery(Gl.PrimitivesGenerated, queries[turn]); + open &= window.Present(screen); + Gl.EndQuery(Gl.PrimitivesGenerated); + window.Poll(); + } + + Totals.ProcessorTime += ProcessorTime() - before; + + for (var turn = 0; turn < turns; turn++) + { + uint triangles; + Gl.GetQueryObject(queries[turn], Gl.QueryResult, &triangles); + Totals.Triangles += triangles; + if (triangles > 0) + { + Totals.Drawn++; + } + } + + Totals.Operations++; + return open; + } + + /// + /// Turns the loop, uncounted, for as long as it takes a screen to come to rest: its pictures + /// decoded on the shim's own thread and handed over, and whatever the shim does in the frames + /// after a change done with. + /// + public void Settle(Screen screen, int turns = 180) + { + for (var turn = 0; turn < turns; turn++) + { + window.Present(screen); + window.Poll(); + } + } + + public void Dispose() => + window.Dispose(); + + static nint Resolve(string library, Assembly assembly, DllImportSearchPath? searchPath) + { + if (library == shim && + TryFindShim(out var path)) + { + return NativeLibrary.Load(path); + } + + return nint.Zero; + } + + /// + /// The shim the Linux head in this checkout ships: beside this assembly if a build put it + /// there, and otherwise in that head's own runtimes folder, which is where the CI job copies + /// the one it has just built from source. + /// + static bool TryFindShim([NotNullWhen(true)] out string? path) + { + if (shimPath is not null) + { + path = shimPath; + return true; + } + + if (NativeResolver.TryFind(out path)) + { + shimPath = path; + return true; + } + + var architecture = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine( + directory.FullName, + "DiffEngineViewer.Linux", + "runtimes", + $"linux-{architecture}", + "native", + $"lib{shim}.so"); + if (File.Exists(candidate)) + { + path = shimPath = candidate; + return true; + } + } + + path = null; + return false; + } + + /// + /// Processor time of this process in nanoseconds, every thread included. From the clock + /// rather than , which counts in hundredths of a second. + /// + static long ProcessorTime() + { + Timespec time; + // CLOCK_PROCESS_CPUTIME_ID + ClockGetTime(2, &time); + return time.Seconds * 1_000_000_000 + time.Nanoseconds; + } + + /// + /// Processor time of the X server this process draws to, in nanoseconds, or -1 when it cannot + /// be found. Putting a frame on the screen is work for the server too: under Xvfb with a + /// software rasteriser it copies every pixel of the window, and none of that is counted + /// against this process. + /// + public static long ServerProcessorTime() + { + try + { + if (ServerProcess() is not { } process) + { + return -1; + } + + // One line a thread: the nanoseconds it has run, the nanoseconds it has waited to, + // and how many times it has been scheduled. + long total = 0; + foreach (var task in Directory.EnumerateDirectories($"/proc/{process}/task")) + { + var fields = File.ReadAllText(Path.Combine(task, "schedstat")).Split(' '); + total += long.Parse(fields[0], CultureInfo.InvariantCulture); + } + + return total; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException) + { + return -1; + } + } + + /// + /// The server whose command line names the display this process was given. + /// + static int? ServerProcess() + { + if (serverSought) + { + return server; + } + + serverSought = true; + var display = Environment.GetEnvironmentVariable("DISPLAY") ?? ""; + var colon = display.LastIndexOf(':'); + if (colon < 0) + { + return null; + } + + // ":99" or "host:99.0" + var number = ":" + display[(colon + 1)..].Split('.')[0]; + foreach (var directory in Directory.EnumerateDirectories("/proc")) + { + if (!int.TryParse(Path.GetFileName(directory), out var process)) + { + continue; + } + + string[] arguments; + try + { + arguments = File.ReadAllText(Path.Combine(directory, "cmdline")).Split('\0'); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + continue; + } + + if (Path.GetFileName(arguments[0]) is "Xvfb" or "Xorg" or "X" or "Xwayland" && + arguments.Contains(number)) + { + server = process; + break; + } + } + + return server; + } + + [StructLayout(LayoutKind.Sequential)] + struct Timespec + { + public long Seconds; + public long Nanoseconds; + } + + [DllImport("libc", EntryPoint = "clock_gettime")] + static extern int ClockGetTime(int clock, Timespec* time); + + /// + /// The four OpenGL calls a query takes, from the library the shim's own context came from. + /// + static class Gl + { + const string library = "libGL.so.1"; + + public const uint PrimitivesGenerated = 0x8C87; + public const uint QueryResult = 0x8866; + + [DllImport(library, EntryPoint = "glGenQueries")] + public static extern void GenQueries(int count, uint* ids); + + [DllImport(library, EntryPoint = "glBeginQuery")] + public static extern void BeginQuery(uint target, uint id); + + [DllImport(library, EntryPoint = "glEndQuery")] + public static extern void EndQuery(uint target); + + [DllImport(library, EntryPoint = "glGetQueryObjectuiv")] + public static extern void GetQueryObject(uint id, uint name, uint* value); + } +} + +/// +/// What the operations run so far have added up to. +/// +struct NativeHeadTotals +{ + /// + /// What a figure is reported per: one turn for a benchmark of a frame, sixty for a benchmark + /// of a second. + /// + public long Operations; + + /// + /// Turns that submitted anything to draw. + /// + public long Drawn; + + public long Triangles; + + /// + /// Nanoseconds of processor time this process spent inside the turns. + /// + public long ProcessorTime; +} diff --git a/src/DiffEngineViewer.Benchmarks/NativeHeadAttribute.cs b/src/DiffEngineViewer.Benchmarks/NativeHeadAttribute.cs new file mode 100644 index 000000000..690db1081 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/NativeHeadAttribute.cs @@ -0,0 +1,163 @@ +using BenchmarkDotNet.Analysers; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Filters; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; +using BenchmarkDotNet.Validators; + +/// +/// For a benchmark class that turns the Linux head's window. Leaves its benchmarks out of a run +/// wherever is false, which is every run on Windows, and adds +/// the columns a frame is judged by to the ones BenchmarkDotNet has: see +/// for why its own time column is not one of them. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class NativeHeadAttribute : Attribute, IConfigSource +{ + public IConfig Config { get; } = + ManualConfig.CreateEmpty() + .AddFilter(new SimpleFilter(_ => NativeHead.Available)) + .AddDiagnoser(NativeHeadDiagnoser.Instance); +} + +/// +/// Reports what counted during the measured part of a run, per run of the +/// benchmark method, beside what the X server spent over the same stretch. +/// +sealed class NativeHeadDiagnoser : IDiagnoser +{ + public static readonly NativeHeadDiagnoser Instance = new(); + + static readonly Column processorTime = new( + "ProcessorTime", + "CPU", + "Processor time this process spent on one operation, on every thread", + UnitType.Time, + "ns", + 0); + + static readonly Column serverTime = new( + "ServerProcessorTime", + "X server CPU", + "Processor time the X server spent while one operation ran", + UnitType.Time, + "ns", + 1); + + static readonly Column drawn = new( + "FramesDrawn", + "Drawn", + "Frames of one operation that submitted anything to draw", + UnitType.Dimensionless, + "Count", + 2); + + static readonly Column triangles = new( + "Triangles", + "Triangles", + "Triangles one operation submitted, as OpenGL counted them", + UnitType.Dimensionless, + "Count", + 3); + + readonly Dictionary runs = []; + NativeHeadTotals start; + long serverStart; + + public IEnumerable Ids => + [nameof(NativeHeadDiagnoser)]; + + public IEnumerable Exporters => + []; + + public IEnumerable Analysers => + []; + + public RunMode GetRunMode(BenchmarkCase benchmarkCase) => + RunMode.NoOverhead; + + public void Handle(HostSignal signal, DiagnoserActionParameters parameters) + { + if (signal == HostSignal.BeforeActualRun) + { + start = NativeHead.Totals; + serverStart = NativeHead.ServerProcessorTime(); + return; + } + + if (signal != HostSignal.AfterActualRun) + { + return; + } + + var server = NativeHead.ServerProcessorTime(); + var end = NativeHead.Totals; + runs[parameters.BenchmarkCase] = ( + new() + { + Operations = end.Operations - start.Operations, + Drawn = end.Drawn - start.Drawn, + Triangles = end.Triangles - start.Triangles, + ProcessorTime = end.ProcessorTime - start.ProcessorTime + }, + server < 0 || serverStart < 0 ? -1 : server - serverStart); + } + + public IEnumerable ProcessResults(DiagnoserResults results) + { + if (!runs.TryGetValue(results.BenchmarkCase, out var run) || + run.Totals.Operations == 0) + { + yield break; + } + + // Counted as they run rather than taken from the results, so the figures do not rest on + // which of BenchmarkDotNet's iterations fall between its two signals. + double operations = run.Totals.Operations; + yield return new(processorTime, run.Totals.ProcessorTime / operations); + if (run.Server >= 0) + { + yield return new(serverTime, run.Server / operations); + } + + yield return new(drawn, run.Totals.Drawn / operations); + yield return new(triangles, run.Totals.Triangles / operations); + } + + public void DisplayResults(ILogger logger) + { + } + + public IEnumerable Validate(ValidationParameters validationParameters) => + []; + + sealed class Column(string id, string name, string legend, UnitType unitType, string unit, int priority) : + IMetricDescriptor + { + public string Id => id; + + public string DisplayName => name; + + public string Legend => legend; + + // To the thousandth, since a time is shown in the unit Mean is, and an idle second's + // processor time is a few thousandths of the second it is measured against. + public string NumberFormat => "0.###"; + + public UnitType UnitType => unitType; + + public string Unit => unit; + + public bool TheGreaterTheBetter => false; + + public int PriorityInCategory => priority; + + public bool GetIsAvailable(Metric metric) => + true; + } +} diff --git a/src/DiffEngineViewer.Benchmarks/NativeIdleBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/NativeIdleBenchmarks.cs new file mode 100644 index 000000000..d576b5931 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/NativeIdleBenchmarks.cs @@ -0,0 +1,55 @@ +#nullable enable +using BenchmarkDotNet.Attributes; + +/// +/// What a second costs the Linux head while nothing happens: the same screen presented sixty +/// times with nothing arriving in between, which is the loop of a viewer left open on a +/// comparison. Of text and of two pictures, at the two sizes +/// uses, and run the way its summary says. +/// +/// One operation is that second, so CPU is what a second of an idle window costs this process +/// and X server CPU what it costs the server, Drawn is how many of its sixty frames were put on +/// the screen, and Triangles what was submitted to put them there. Mean is how long the sixty +/// took. It is a second for a head that is keeping to sixty frames a second, whether it drew +/// them or left them alone, and less than a second would be a loop that has stopped waiting for +/// the next frame. +/// +/// +[MemoryDiagnoser] +[NativeHead] +public class NativeIdleBenchmarks +{ + [Params("1100x700", "3840x2160")] + public string Window = ""; + + NativeHead? head; + Screen? screen; + + [GlobalSetup(Target = nameof(Text))] + public void OpenOnText() => + Open(NativeScenes.Text); + + [GlobalSetup(Target = nameof(Pictures))] + public void OpenOnPictures() => + Open(NativeScenes.TranslucentPictures); + + [GlobalCleanup] + public void Close() => + head?.Dispose(); + + [Benchmark] + public bool Text() => + head!.Run(screen!, NativeHead.MostTurns); + + [Benchmark] + public bool Pictures() => + head!.Run(screen!, NativeHead.MostTurns); + + void Open(Func scene) + { + var size = Window.Split('x'); + head = NativeHead.Open(int.Parse(size[0]), int.Parse(size[1])); + screen = ScreenBuilder.Build(scene(head.Columns, head.Rows)); + head.Settle(screen); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/NativeScenes.cs b/src/DiffEngineViewer.Benchmarks/NativeScenes.cs new file mode 100644 index 000000000..33eb4c51f --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/NativeScenes.cs @@ -0,0 +1,166 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +/// +/// What the Linux head is given to draw in a benchmark: a text comparison that fills the window +/// at any size, and two comparisons of pictures. +/// +/// The pictures are real files, since a head draws a picture from the path the model carries, and +/// they are large enough to fill a pane of a 4K window, since a fitted picture is never drawn +/// larger than its own size. Both kinds are stored with an alpha channel. One fades to nothing +/// across its right hand half, so the checkerboard behind it has to show through. The other has +/// no pixel that is anything but opaque, which is what a drawn page of a document or a screenshot +/// is, and nothing behind it can be seen. +/// +/// +static class NativeScenes +{ + const int pictureSize = 1920; + + public static SessionState Text(int columns, int rows) => + Comparing( + columns, + rows, + QueueEntry.ForFiles( + "sample.received.txt", + "sample.verified.txt", + FileSide.OfText(Lines(true)), + FileSide.OfText(Lines(false)))); + + public static SessionState OpaquePictures(int columns, int rows) => + Pictures(columns, rows, true); + + public static SessionState TranslucentPictures(int columns, int rows) => + Pictures(columns, rows, false); + + static SessionState Pictures(int columns, int rows, bool opaque) + { + var kind = opaque ? "opaque" : "translucent"; + var left = Write($"{kind}.received.png", Png(198, 64, 64, opaque)); + var right = Write($"{kind}.verified.png", Png(64, 150, 198, opaque)); + return Comparing(columns, rows, QueueEntry.ForFiles(left, right, FileSide.Read(left), FileSide.Read(right))); + } + + /// + /// The same frame twice, told apart by one letter of its status line and by nothing else. A + /// benchmark of what a frame costs to draw presents them in turn, so that each is a frame the + /// head has to draw, however good it gets at leaving an unchanged one alone. + /// + public static Screen[] Alternating(SessionState state) + { + var screen = ScreenBuilder.Build(state); + return + [ + screen with { Status = "tick" }, + screen with { Status = "tock" } + ]; + } + + static SessionState Comparing(int columns, int rows, QueueEntry entry) => + ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File, columns, rows), entry); + + /// + /// Three hundred lines, which is more than a 4K window shows, one in ten of them changed. + /// + static string Lines(bool changed) + { + var builder = new StringBuilder(); + for (var index = 0; index < 300; index++) + { + var value = changed && index % 10 == 0 ? index + 1 : index; + builder.Append($" \"property{index:D3}\": \"the value of property {value:D3}\",\n"); + } + + return builder.ToString(); + } + + static string Write(string name, byte[] content) + { + var directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, name); + File.WriteAllBytes(path, content); + return path; + } + + /// + /// A colour that darkens from top to bottom, so the picture is more than one flat fill, with + /// eight bits a channel and an alpha channel either way. + /// + static byte[] Png(byte red, byte green, byte blue, bool opaque) + { + var half = pictureSize / 2; + var raw = new byte[pictureSize * (1 + pictureSize * 4)]; + var index = 0; + for (var y = 0; y < pictureSize; y++) + { + // Filter type 0: the row is stored as it is. + raw[index++] = 0; + var shade = 255 - 128 * y / pictureSize; + for (var x = 0; x < pictureSize; x++) + { + raw[index++] = (byte) (red * shade / 255); + raw[index++] = (byte) (green * shade / 255); + raw[index++] = (byte) (blue * shade / 255); + raw[index++] = opaque || x < half ? (byte) 255 : (byte) (255 - 255 * (x - half) / (pictureSize - half - 1)); + } + } + + using var output = new MemoryStream(); + output.Write([0x89, (byte) 'P', (byte) 'N', (byte) 'G', 0x0D, 0x0A, 0x1A, 0x0A]); + var header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header, pictureSize); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), pictureSize); + // Eight bits a channel, truecolour with alpha, and none of the three optional encodings. + header[8] = 8; + header[9] = 6; + Chunk(output, "IHDR", header); + Chunk(output, "IDAT", Compress(raw)); + Chunk(output, "IEND", []); + return output.ToArray(); + } + + static byte[] Compress(byte[] raw) + { + using var output = new MemoryStream(); + using (var zlib = new ZLibStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + zlib.Write(raw); + } + + return output.ToArray(); + } + + static void Chunk(Stream output, string name, byte[] data) + { + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, data.Length); + output.Write(length); + + // The name and the data are one run for the checksum, which covers both and not the length. + var payload = new byte[4 + data.Length]; + Encoding.ASCII.GetBytes(name).CopyTo(payload, 0); + data.CopyTo(payload, 4); + output.Write(payload); + + Span checksum = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(checksum, Crc(payload)); + output.Write(checksum); + } + + static uint Crc(byte[] bytes) + { + var value = 0xFFFFFFFFu; + foreach (var current in bytes) + { + value ^= current; + for (var bit = 0; bit < 8; bit++) + { + value = (value & 1) == 0 ? value >> 1 : 0xEDB88320u ^ (value >> 1); + } + } + + return value ^ 0xFFFFFFFFu; + } +} diff --git a/src/DiffEngineViewer.Benchmarks/Program.cs b/src/DiffEngineViewer.Benchmarks/Program.cs new file mode 100644 index 000000000..3e09aa6f8 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/Program.cs @@ -0,0 +1,21 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Running; +using BenchmarkDotNet.Toolchains.InProcess.Emit; + +// Run with: dotnet run -c Release --project src/DiffEngineViewer.Benchmarks -- --filter * +// Filter e.g.: dotnet run -c Release --project src/DiffEngineViewer.Benchmarks -- --filter *Frame* +// +// The viewer's model: everything a frame costs before a head draws it. Nothing here binds a port, +// and nothing opens a window except the Native classes, which turn the Linux head's own and are +// left out of a run anywhere there is no shim or no display: every run on Windows and macOS. +// NativeFrameBenchmarks says how to run them. +// +// In process and a short run, for the reasons DiffEngine.Benchmarks' Program.cs gives. +var config = DefaultConfig.Instance + .AddJob( + Job.ShortRun + .WithToolchain(InProcessEmitToolchain.Instance)); +BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args, config); diff --git a/src/DiffEngineViewer.Benchmarks/ScreenPayloadBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/ScreenPayloadBenchmarks.cs new file mode 100644 index 000000000..e3b4de9e4 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/ScreenPayloadBenchmarks.cs @@ -0,0 +1,87 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +// The macOS and Linux heads are handed a screen as flat buffers, which ScreenPayload encodes: every +// string as UTF-8, and every row as the segments CellGrid cuts it into. A row of plain text is one +// segment. A row of CJK is one per character, and each segment's byte offset was found by counting +// the row's bytes again from its start, so such a row cost by the square of its length. +// +// A window the size of a 4K display: 426 cells across and 130 down at the 9 pixel cell. Lines of +// 300 characters, since a row is encoded as far as the window is wide rather than the pane, so a +// long line is where a row's length shows. +[MemoryDiagnoser] +public class ScreenPayloadBenchmarks +{ + // Box drawing is what a table drawn in a snapshot is made of. The embedded font has it, so + // whether a row of it is one segment or one a character is CellGrid's to decide. + [Params("ascii", "box", "cjk")] + public string Text = ""; + + readonly ScreenPayload payload = new(); + Screen first = null!; + Screen second = null!; + bool flip; + + [GlobalSetup] + public void Setup() + { + var state = ViewerSession.EnqueueFile( + SessionState.Start(ViewerMode.File, 426, 130), + QueueEntry.ForFiles( + "Sample.received.txt", + "Sample.verified.txt", + FileSide.OfText(Rows(changed: true)), + FileSide.OfText(Rows(changed: false)))); + // Two screens that say the same thing, so one after the other is a screen the payload has + // not been handed before without being a different amount of work. + first = ScreenBuilder.Build(state); + second = ScreenBuilder.Build(state); + } + + // A screen that changed since the last frame, which is every frame of a scroll or a drag. + [Benchmark] + public void EncodeAChangedScreen() + { + flip = !flip; + payload.Build(flip ? first : second); + } + + // The screen the last frame was, which is every frame of a window nobody is touching. + [Benchmark] + public void EncodeTheSameScreenAgain() => + payload.Build(first); + + string Rows(bool changed) + { + var builder = new StringBuilder(); + for (var row = 0; row < 400; row++) + { + for (var column = 0; column < 300; column++) + { + if (Text == "cjk") + { + // CJK Unified Ideographs, two cells each + builder.Append((char) (0x4E00 + (row * 7 + column * 13) % 2000)); + } + else if (Text == "box") + { + builder.Append(column % 6 == 0 ? '┼' : '─'); + } + else + { + builder.Append((char) ('a' + (row + column) % 26)); + } + } + + if (changed && + row % 10 == 0) + { + builder.Append('!'); + } + + builder.Append('\n'); + } + + return builder.ToString(); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/SelectionFrameBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/SelectionFrameBenchmarks.cs new file mode 100644 index 000000000..b9c900d5f --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/SelectionFrameBenchmarks.cs @@ -0,0 +1,60 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +// A frame while everything in a pane is selected. The status line says how much is selected, and +// working that out walks every selected row, so ctrl+a on a large file made each frame cost by +// the length of the file for as long as the selection stood. Tab indented, since a row with a tab +// in it is flattened to be measured and that is where the allocation was. +[MemoryDiagnoser] +public class SelectionFrameBenchmarks +{ + [Params(20_000, 100_000)] + public int Lines; + + SessionState selected = null!; + + [GlobalSetup] + public void Setup() + { + var state = ViewerSession.EnqueueFile( + SessionState.Start(ViewerMode.File, 160, 50), + QueueEntry.ForFiles( + "Sample.received.txt", + "Sample.verified.txt", + FileSide.OfText(Text(Lines, changeEvery: 100)), + FileSide.OfText(Text(Lines, changeEvery: -1)))); + selected = ViewerSession.Apply(state, CommandKind.SelectAll); + if (selected.LiveSelection is null) + { + throw new("Nothing was selected, so this would measure a frame with no selection."); + } + } + + readonly ScreenCache screens = new(); + + // What a frame of a drag still pays, since each one moves the selection's end. + [Benchmark(Baseline = true)] + public object BuildScreen() => + ScreenBuilder.Build(selected); + + // And what every frame after ctrl+a pays, for as long as the selection stands. + [Benchmark] + public object FrameWithNothingChanged() => + screens.For(selected); + + static string Text(int lines, int changeEvery) + { + var builder = new StringBuilder(); + for (var index = 0; index < lines; index++) + { + var value = changeEvery > 0 && index % changeEvery == 0 ? index + 1 : index; + builder.Append("\t\t\"property"); + builder.Append(index); + builder.Append("\": "); + builder.Append(value); + builder.Append(",\n"); + } + + return builder.ToString(); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/TrackedEntryBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/TrackedEntryBenchmarks.cs new file mode 100644 index 000000000..c5c201487 --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/TrackedEntryBenchmarks.cs @@ -0,0 +1,52 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +// What an owning viewer does with a failing pair before it answers the test process that sent it: +// read both files and diff them. The sender's synchronous wait is three seconds, so this is the +// number that decides whether a pair is answered at all. Two files with no line in common, which +// is what a change of indentation leaves, and files rather than strings so the reads are in it. +[MemoryDiagnoser] +public class TrackedEntryBenchmarks +{ + [Params(10_000, 40_000)] + public int Lines; + + string directory = ""; + string received = ""; + string verified = ""; + + [GlobalSetup] + public void Setup() + { + directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks", $"entry-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + received = Path.Combine(directory, "Sample.received.json"); + verified = Path.Combine(directory, "Sample.verified.json"); + File.WriteAllText(received, Json(Lines, " ")); + File.WriteAllText(verified, Json(Lines, " ")); + } + + [GlobalCleanup] + public void Cleanup() => + Directory.Delete(directory, true); + + [Benchmark] + public object ReadAndDiffAPair() => + TrackedEntry.ForMove(received, verified); + + static string Json(int lines, string indent) + { + var builder = new StringBuilder(); + for (var index = 0; index < lines; index++) + { + builder.Append(indent); + builder.Append("\"property"); + builder.Append(index); + builder.Append("\": "); + builder.Append(index); + builder.Append(",\n"); + } + + return builder.ToString(); + } +} diff --git a/src/DiffEngineViewer.Benchmarks/TrackedWatchBenchmarks.cs b/src/DiffEngineViewer.Benchmarks/TrackedWatchBenchmarks.cs new file mode 100644 index 000000000..9dc1028cf --- /dev/null +++ b/src/DiffEngineViewer.Benchmarks/TrackedWatchBenchmarks.cs @@ -0,0 +1,47 @@ +using BenchmarkDotNet.Attributes; + +// One pass of the watch that keeps an owned queue's pending files in step with the disk. It runs +// five times a second for as long as the viewer does, which with a tray installed is for days, so +// what a pass costs is paid whether or not anybody is looking. Real files, since the cost is the +// stat: two per pending move. +[MemoryDiagnoser] +public class TrackedWatchBenchmarks +{ + [Params(100, 1000)] + public int Pending; + + string directory = ""; + TrackedWatch watch = null!; + + [GlobalSetup] + public void Setup() + { + directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks", $"watch-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var state = SessionState.Start(ViewerMode.Inline, 160, 50); + for (var index = 0; index < Pending; index++) + { + var received = Path.Combine(directory, $"Sample{index}.received.txt"); + var verified = Path.Combine(directory, $"Sample{index}.verified.txt"); + File.WriteAllText(received, $"received {index}"); + File.WriteAllText(verified, $"verified {index}"); + state = ViewerSession.EnqueueTracked(state, TrackedEntry.ForMove(received, verified)); + } + + if (state.Queue.Count != Pending) + { + throw new($"Queued {state.Queue.Count} of {Pending}."); + } + + watch = new(new(state)); + } + + [GlobalCleanup] + public void Cleanup() => + Directory.Delete(directory, true); + + // Nothing on disk changes between passes, which is every pass but a handful. + [Benchmark] + public void OnePassWithNothingChanged() => + watch.Pump(); +} diff --git a/src/DiffEngineViewer.Documents/Documents.targets b/src/DiffEngineViewer.Documents/Documents.targets index c7106ecb8..c6861063f 100644 --- a/src/DiffEngineViewer.Documents/Documents.targets +++ b/src/DiffEngineViewer.Documents/Documents.targets @@ -8,7 +8,8 @@ It is the same switch the Mac and Linux heads already use to keep their own natives out of the bundle. - Imported by each head, by DiffEngineTray and by DiffEngineViewer.Tests. Each says in DocumentsRids + Imported by each head, by DiffEngineTray, by DiffEngineViewer.Tests and by + DiffEngineViewer.Benchmarks. Each says in DocumentsRids which RIDs it ships natives for. The other runtimes folders are left out, which is safe because the running RID is the only one probed: deps.json still naming a folder for another OS costs nothing, while a missing asset for the running one would stop the folder loading. diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so index b0106d983..ba559ab21 100644 Binary files a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so differ diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so index f4c2ad76d..42421a863 100644 Binary files a/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so and b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib index dbd0f0594..48d338336 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib index dbd0f0594..48d338336 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Tests/AcceptAllProgressTests.cs b/src/DiffEngineViewer.Tests/AcceptAllProgressTests.cs index ab0857c25..b22b96d25 100644 --- a/src/DiffEngineViewer.Tests/AcceptAllProgressTests.cs +++ b/src/DiffEngineViewer.Tests/AcceptAllProgressTests.cs @@ -14,7 +14,7 @@ public async Task EachEntryLeavesAsItLands() while ((state = ViewerSession.ClaimNext(state)).Batch?.Current is { } entry) { seen.Add($"{ScreenBuilder.Build(state).Status}, {state.Queue.Count} pending"); - state = ViewerSession.ApplyClaimed(entry, Fixtures.Applied)(state); + state = ViewerSession.ApplyClaimed(state, Fixtures.Applied)(state); } await Assert.That(string.Join("\n", seen)).IsEqualTo( @@ -49,7 +49,7 @@ public async Task TheFilesAreCountedAfterTheSnapshots() while ((state = ViewerSession.ClaimNext(state)).Batch?.Current is { } entry) { kinds.Add($"{state.Progress!.Describe()}: {entry.Kind}"); - state = ViewerSession.ApplyClaimed(entry, actions)(state); + state = ViewerSession.ApplyClaimed(state, actions)(state); } await Assert.That(string.Join("\n", kinds)).IsEqualTo( @@ -83,13 +83,13 @@ public async Task AnEntrySettledPartWayIsPassedOver() var state = ViewerSession.BeginAcceptAll(Pending()); state = ViewerSession.ClaimNext(state); var first = state.Batch!.Current!; - state = ViewerSession.ApplyClaimed(first, actions)(state); + state = ViewerSession.ApplyClaimed(state, actions)(state); var next = state.Batch!.Remaining[0]; state = ViewerSession.Settle(state, next); while ((state = ViewerSession.ClaimNext(state)).Batch?.Current is { } entry) { - state = ViewerSession.ApplyClaimed(entry, actions)(state); + state = ViewerSession.ApplyClaimed(state, actions)(state); } await Assert.That(applied.Count).IsEqualTo(2); @@ -106,7 +106,7 @@ public async Task AReRunWhileItAppliesKeepsTheNewContent() { var state = ViewerSession.BeginAcceptAll(Fixtures.Inline(Fixtures.Patch())); state = ViewerSession.ClaimNext(state); - var record = ViewerSession.ApplyClaimed(state.Batch!.Current!, Fixtures.Applied); + var record = ViewerSession.ApplyClaimed(state, Fixtures.Applied); state = ViewerSession.EnqueueInline(state, Fixtures.Patch(content: "third run")); state = ViewerSession.ClaimNext(record(state)); @@ -128,7 +128,7 @@ public async Task AnEntryThatBecameAConflictIsLeftForReview() Fixtures.Patch("ATests.cs", 1, framework: "net8.0"), Fixtures.Patch("BTests.cs", 2, framework: "net8.0"))); state = ViewerSession.ClaimNext(state); - state = ViewerSession.ApplyClaimed(state.Batch!.Current!, Fixtures.Applied)(state); + state = ViewerSession.ApplyClaimed(state, Fixtures.Applied)(state); state = ViewerSession.EnqueueInline(state, Fixtures.Patch("BTests.cs", 2, content: "nine", framework: "net9.0")); state = ViewerSession.ClaimNext(state); @@ -357,10 +357,11 @@ static async Task Until(Func condition) static ViewerMessage Inline(InlinePatch patch) => new(ViewerVerb.Inline, Body: InlinePatchFile.Build(patch)); + // A file each, so each is a claim of its own: snapshots of one file are claimed together. static SessionState Pending() => Fixtures.Inline( Fixtures.Patch(), - Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), + Fixtures.Patch("MoreTests.cs", 88, "\"one\"", "two"), Fixtures.Patch("OtherTests.cs", 12, null, "brand new")); /// diff --git a/src/DiffEngineViewer.Tests/BatchBookkeepingTests.cs b/src/DiffEngineViewer.Tests/BatchBookkeepingTests.cs new file mode 100644 index 000000000..228df8fd3 --- /dev/null +++ b/src/DiffEngineViewer.Tests/BatchBookkeepingTests.cs @@ -0,0 +1,141 @@ +/// +/// What a batch does to the queue besides applying it. Recording an entry takes it out of the list +/// or marks it where it stands, and finding what is visible walks the queue without describing +/// it: each used to rebuild or describe the whole queue an entry, which for 2,000 snapshots was +/// seconds and gigabytes beside the applying. Both have to come to what the long way round gave. +/// +public class BatchBookkeepingTests +{ + /// + /// Every other inline transition rebuilds the list from the queue and orders it. A batch's + /// record does neither, so after each entry it records the list has to be in the order a + /// rebuild would have left it in, whatever became of the entry: written, refused, or failed. + /// + [Test] + public void AfterEachEntryTheQueueIsStillInOrder() + { + var random = new Random(11); + for (var iteration = 0; iteration < 200; iteration++) + { + var state = ViewerSession.BeginAcceptAll(Mixed(random)); + var actions = Fixtures.Applied with + { + ApplyInline = _ => random.Next(4) switch + { + 0 => InlineApplyResult.Failed("the file is held"), + 1 => InlineApplyResult.NotFound("no Verify call"), + _ => InlineApplyResult.Applied + }, + MoveFile = static (_, _) => + { + }, + DeleteFile = static _ => + { + } + }; + while ((state = ViewerSession.ClaimNext(state)).Batch?.Current is { } entry) + { + state = ViewerSession.ApplyClaimed(state, actions)(state); + if (!QueueProjection.Order(state.Queue).SequenceEqual(state.Queue, ReferenceEqualityComparer.Instance)) + { + Assert.Fail($"Out of order after {entry.Name}: {string.Join(", ", state.Queue.Select(_ => _.Name))}"); + } + } + } + } + + /// + /// And what it leaves is what the batch's own rules say of each entry: one that was written is + /// gone, one that was not is still there saying why, and nothing else was touched. + /// + [Test] + public async Task EachEntryIsLeftAsTheBatchRulesSay() + { + var state = Fixtures.Inline( + Fixtures.Patch("OneTests.cs", 10), + Fixtures.Patch("TwoTests.cs", 20, "\"a\"", "b"), + Fixtures.Patch("ThreeTests.cs", 30, "\"c\"", "d"), + Fixtures.Patch("FourTests.cs", 40, "\"e\"", "f")); + var untouched = state.Queue.Single(_ => _.Name == "FourTests.cs:40"); + var actions = Fixtures.Applied with + { + ApplyInline = _ => _.LineHint switch + { + 20 => InlineApplyResult.Failed("the file is held"), + 30 => InlineApplyResult.NotFound("no Verify call"), + _ => InlineApplyResult.Applied + } + }; + + state = ViewerSession.BeginAcceptAll(state); + while ((state = ViewerSession.ClaimNext(state)).Batch?.Current is { } entry && + entry.Name != "FourTests.cs:40") + { + state = ViewerSession.ApplyClaimed(state, actions)(state); + } + + await Assert.That(state.Queue.Select(_ => _.Name)).IsEquivalentTo(["TwoTests.cs:20", "ThreeTests.cs:30", "FourTests.cs:40"]); + await Assert.That(state.Queue.Single(_ => _.Name == "TwoTests.cs:20").Status).IsEqualTo("the file is held"); + await Assert.That(state.Queue.Single(_ => _.Name == "ThreeTests.cs:30").Status!).Contains("no Verify call"); + // The very entry, not one rebuilt to look like it: nothing about it changed + await Assert.That(state.Queue.Single(_ => _.Name == "FourTests.cs:40")).IsSameReferenceAs(untouched); + await Assert.That(state.Batch!.Tally).IsEqualTo(new AcceptAllTally(1, 1, 1, state.Batch.Tally.Failure)); + } + + /// + /// Which entries have a row is asked without describing the rows, on every step through the + /// queue and after every entry a batch takes out. It is the same walk, so it has to give what + /// the rows that are drawn give, with nothing folded and with each header folded in turn. + /// + [Test] + public async Task WhatIsVisibleIsWhatHasARow() + { + var random = new Random(12); + for (var iteration = 0; iteration < 50; iteration++) + { + var state = Mixed(random); + await SameAsTheRows(state); + foreach (var header in QueueProjection.Rows(state).Where(_ => _.GroupKey is not null).ToList()) + { + await SameAsTheRows(ViewerSession.ToggleGroup(state, header.GroupKey!)); + } + } + } + + static async Task SameAsTheRows(SessionState state) + { + var drawn = QueueProjection.Rows(state) + .Where(_ => _.EntryIndex >= 0) + .Select(_ => _.EntryIndex); + + await Assert.That(string.Join(",", QueueProjection.VisibleEntries(state))).IsEqualTo(string.Join(",", drawn)); + } + + /// + /// Snapshots across two solutions and outside any, some of them several to a test, so there + /// are solution headers and test headers to order under, with a move and a delete beside them. + /// + static SessionState Mixed(Random random) + { + string[] solutions = ["SolutionA", "SolutionB"]; + var patches = new List(); + for (var index = random.Next(3, 12); index > 0; index--) + { + var file = $"Tests{random.Next(3)}.cs"; + var source = random.Next(3) == 0 + ? file + : Fixtures.SolutionFile(solutions[random.Next(2)], "Tests", file); + patches.Add( + Fixtures.Patch( + source, + 10 + patches.Count, + $"\"old{patches.Count}\"", + $"new {patches.Count}", + testName: random.Next(2) == 0 ? $"Test{random.Next(2)}" : null)); + } + + var state = Fixtures.Inline([.. patches]); + state = ViewerSession.EnqueueTracked(state, Fixtures.Move(solution: "SolutionA")); + return ViewerSession.EnqueueTracked(state, Fixtures.Delete(solution: "SolutionB")); + } +} diff --git a/src/DiffEngineViewer.Tests/CellGridTests.cs b/src/DiffEngineViewer.Tests/CellGridTests.cs index afcc51cfc..c37ebdb0d 100644 --- a/src/DiffEngineViewer.Tests/CellGridTests.cs +++ b/src/DiffEngineViewer.Tests/CellGridTests.cs @@ -54,6 +54,136 @@ await Assert.That(CellGrid.Segments("ab中ćd")) public async Task CyrillicIsARunLikeAscii() => await Assert.That(CellGrid.Segments("Привет, мир")).IsEquivalentTo([new CellGrid.Segment(0, 11, 0)]); + /// + /// Whatever the embedded font has is drawn in runs, from whichever block: a table drawn in a + /// snapshot is rows of box drawing, and was a segment a character. + /// + [Test] + public async Task BoxDrawingArrowsAndPunctuationAreARunLikeAscii() => + await Assert.That(CellGrid.Segments("├── a → b … “c” ≤ d")) + .IsEquivalentTo([new CellGrid.Segment(0, 19, 0)]); + + /// + /// A character the font does not have is drawn from whatever font the machine finds for it, at + /// that font's width, so it is never inside a run. That holds in the blocks the font mostly + /// covers too, where it used to be taken on trust: Latin Extended-B's ƀ and Cyrillic's Ѡ. + /// + [Test] + [Arguments("aƀb")] + [Arguments("aѠb")] + public async Task ACharacterTheFontLacksIsASegmentOfItsOwn(string text) => + await Assert.That(CellGrid.Segments(text)) + .IsEquivalentTo, CellGrid.Segment>( + [ + new(0, 1, 0), + new(1, 1, 1), + new(2, 1, 2) + ]); + + /// + /// The font has a glyph for the high voltage sign, a cell wide, and the grid gives it two as + /// it does every emoji. The grid decides, so it is drawn on its own and what follows is where + /// the grid says. + /// + [Test] + public async Task ACharacterGivenTwoCellsIsOnItsOwnWhateverTheFontHas() => + await Assert.That(CellGrid.Segments("a⚡b")) + .IsEquivalentTo, CellGrid.Segment>( + [ + new(0, 1, 0), + new(1, 1, 1), + new(2, 1, 3) + ]); + + /// + /// Half a surrogate pair is read as the replacement character, which the font has a glyph + /// for. The text still holds half a pair, which no font has, so it stays out of a run. + /// + [Test] + public async Task ASurrogateWithNoPartnerIsASegmentOfItsOwn() => + await Assert.That(CellGrid.Segments("a\uD800b")) + .IsEquivalentTo, CellGrid.Segment>( + [ + new(0, 1, 0), + new(1, 1, 1), + new(2, 1, 2) + ]); + + /// + /// A row of nothing but what the font draws a cell wide is measured as a row of ASCII is, with + /// no walk through its clusters: as many cells as characters, and each character at its own. + /// + [Test] + public async Task ARowTheFontDrawsThroughoutIsMeasuredAsPlainTextIs() + { + const string rule = "├──┼──┤ → é"; + + await Assert.That(CellGrid.Cells(rule)).IsEqualTo(11); + await Assert.That(CellGrid.Snap(rule, 4)).IsEqualTo(4); + await Assert.That(CellGrid.Index(rule, 4)).IsEqualTo(4); + await Assert.That(CellGrid.Snap(rule, 40)).IsEqualTo(11); + await Assert.That(CellGrid.Index(rule, 40)).IsEqualTo(11); + await Assert.That(CellGrid.Segments(rule)).IsEquivalentTo([new CellGrid.Segment(0, 11, 0)]); + } + + /// + /// And one wide character in such a row puts it back on the grid a cluster at a time. + /// + [Test] + public async Task AWideCharacterInSuchARowIsStillCounted() + { + const string rule = "──中─"; + + await Assert.That(CellGrid.Cells(rule)).IsEqualTo(5); + await Assert.That(CellGrid.Index(rule, 4)).IsEqualTo(3); + await Assert.That(CellGrid.Segments(rule)) + .IsEquivalentTo, CellGrid.Segment>( + [ + new(0, 2, 0), + new(2, 1, 2), + new(3, 1, 4) + ]); + } + + /// + /// What the grid asks the font: every printable ASCII character, since a row of those is drawn + /// as one string without asking; what it has past ASCII, and past the basic plane; and not a + /// character it lacks, nor a mark, which it has at no width at all. + /// + [Test] + public async Task TheEmbeddedFontSaysWhatItDrawsACellWide() + { + for (var character = 0x20; character <= 0x7E; character++) + { + await Assert.That(FontCoverage.Has(character)).IsTrue(); + } + + await Assert.That(FontCoverage.Has(0x2500)).IsTrue(); + await Assert.That(FontCoverage.Has(0x2192)).IsTrue(); + await Assert.That(FontCoverage.Has(0x044F)).IsTrue(); + await Assert.That(FontCoverage.Has(0x1D538)).IsTrue(); + + await Assert.That(FontCoverage.Has(0x4E2D)).IsFalse(); + await Assert.That(FontCoverage.Has(0x0180)).IsFalse(); + await Assert.That(FontCoverage.Has(0x0301)).IsFalse(); + await Assert.That(FontCoverage.Has(0x1F600)).IsFalse(); + await Assert.That(FontCoverage.Has(0x10FFFF)).IsFalse(); + await Assert.That(FontCoverage.Has(-1)).IsFalse(); + } + + /// + /// Bytes that are not a font say nothing is covered, rather than throwing out of the first row + /// to be drawn: every character is then a segment of its own, which is slower and still right. + /// + [Test] + public async Task BytesThatAreNotAFontCoverNothing() + { + await Assert.That(FontCoverage.Read([]).Has('a')).IsFalse(); + await Assert.That(FontCoverage.Read([0, 1, 0, 0, 0, 200, 9, 9, 9, 9, 9, 9, 9, 9]).Has('a')).IsFalse(); + var truncated = EmbeddedFont.Bytes().AsSpan(0, 2000).ToArray(); + await Assert.That(FontCoverage.Read(truncated).Has('a')).IsFalse(); + } + /// /// A column inside a wide character moves to its end, so a selection takes it whole or not at /// all, and the index it maps to is after the whole character. diff --git a/src/DiffEngineViewer.Tests/DocumentRendererTests.cs b/src/DiffEngineViewer.Tests/DocumentRendererTests.cs index 6aff5d2e2..01b2fbdcd 100644 --- a/src/DiffEngineViewer.Tests/DocumentRendererTests.cs +++ b/src/DiffEngineViewer.Tests/DocumentRendererTests.cs @@ -242,6 +242,53 @@ public async Task ACorruptDocumentFailsAlone() await Assert.That(Render(WritePdf("fine.pdf", "alpha")).Count).IsEqualTo(1); } + /// + /// The watch draws the two sides of an entry at once, so the renderer is called on two threads + /// at a time, and each call has to draw what it draws alone. A page that came out differently + /// for having been drawn beside another would be reported as a page that differs. + /// + [Test] + [Arguments(".pdf")] + [Arguments(".docx")] + [Arguments(".xlsx")] + [Arguments(".pptx")] + [Arguments(".svg")] + [Arguments(".geojson")] + [Arguments(".fgb")] + public async Task TwoDrawnAtOnceDrawAsEachDoesAlone(string extension) + { + var bytes = extension switch + { + ".pdf" => SamplePdf.Build("alpha", "bravo", "charlie"), + ".svg" => Encoding.UTF8.GetBytes(FileTypeLaunchTests.SvgOf("red")), + ".geojson" or ".fgb" => FileTypeLaunchTests.MapOf(extension, moved: false), + _ => File.ReadAllBytes(Sample($"sample{extension}")) + }; + + string Copy(string name) + { + var path = Path.Combine(directory, $"{name}{extension}"); + File.WriteAllBytes(path, bytes); + return path; + } + + var alone = Hashes(Render(Copy("alone"))); + var left = Copy("left"); + var right = Copy("right"); + + // More than once, since two calls that only sometimes get in each other's way are the + // kind there would be + for (var round = 0; round < 3; round++) + { + var drawn = await Task.WhenAll( + Task.Run(() => Hashes(Render(left))), + Task.Run(() => Hashes(Render(right)))); + + await Assert.That(drawn[0]).IsEquivalentTo(alone); + await Assert.That(drawn[1]).IsEquivalentTo(alone); + } + } + /// /// The whole of it as a viewer runs it: a pair read through , then the /// watch reading their text and drawing their pages through the real folder, until there is diff --git a/src/DiffEngineViewer.Tests/DocumentWatchTests.cs b/src/DiffEngineViewer.Tests/DocumentWatchTests.cs index ece3a1cbe..f4bde253e 100644 --- a/src/DiffEngineViewer.Tests/DocumentWatchTests.cs +++ b/src/DiffEngineViewer.Tests/DocumentWatchTests.cs @@ -6,6 +6,11 @@ /// page whose colour comes from the line. Two documents' pages therefore differ exactly where their /// lines do, which is the property the real renderer's determinism gives the viewer. /// +/// +/// Nothing in it stands in for PDFium's lock. All the watch can see of one PDF stopped inside +/// PDFium and another stopped at the lock is two calls that no longer land pages, so that is what +/// a test arranges, by holding each side's pages back as they are about to land. +/// /// public class DocumentWatchTests : IDisposable @@ -46,11 +51,17 @@ public async Task PagesLandOneAtATime() var watch = new DocumentWatch(host, documents.Plugin); watch.Pump(); - // Left, then right, a job each, each page published as it lands - documents.Landing = () => landed.Add(Pages(host.State, host.State.Current!.LeftDocument).Count); + // Both sides in the one job, each page published as it lands + var received = host.State.Current!.LeftDocument; + documents.Landing = _ => + { + if (_ == Hash(received)) + { + landed.Add(Pages(host.State, received).Count); + } + }; await Assert.That(watch.Pump()).IsTrue(); await Assert.That(landed).IsEquivalentTo([0, 1, 2]); - await Assert.That(watch.Pump()).IsTrue(); var state = host.State; var left = DocumentPages.Of(state, state.Current!.LeftDocument)!; @@ -58,6 +69,84 @@ public async Task PagesLandOneAtATime() await Assert.That(left.Complete && right.Complete).IsTrue(); await Assert.That(DocumentPages.Differing(state.Current!, left, right)).IsEquivalentTo([1]); await Assert.That(DocumentPages.Current(state)).IsEqualTo(1); + await Assert.That(watch.Pump()).IsFalse(); + } + + /// + /// Both sides are drawn at once, a call each. One after the other, the right pane was a spinner + /// for as long as every page of the left took, and which pages differ was not known until both + /// were done. + /// + [Test] + public async Task BothSidesAreDrawnAtOnce() + { + var (host, documents) = Owned("alpha\nbravo\ncharlie", "alpha\nBRAVO\ncharlie", ".docx"); + var watch = new DocumentWatch(host, documents.Plugin); + watch.Pump(); + + // A page lands only when the other side has one to land beside it, which never happens + // when one side is drawn to its last page before the other is started + using var together = new Barrier(2); + var met = new ConcurrentBag(); + documents.Landing = _ => met.Add(together.SignalAndWait(TimeSpan.FromSeconds(10))); + + await Assert.That(watch.Pump()).IsTrue(); + + await Assert.That(met.Count).IsEqualTo(6); + await Assert.That(met).DoesNotContain(false); + var state = host.State; + var left = DocumentPages.Of(state, state.Current!.LeftDocument)!; + var right = DocumentPages.Of(state, state.Current!.RightDocument)!; + await Assert.That(left.Complete && right.Complete).IsTrue(); + await Assert.That(DocumentPages.Differing(state.Current!, left, right)).IsEquivalentTo([1]); + } + + /// + /// Two PDFs are drawn at once as well, but the second is started behind the first's first page + /// rather than beside it. Which of two that have stopped is inside PDFium is told from whose + /// pages stopped first, and before the first has landed a page there is nothing to tell it by. + /// + [Test] + public async Task TheSecondPdfIsStartedBehindTheFirstPageOfTheFirst() + { + var (host, documents) = Owned("alpha\nbravo\ncharlie", "delta\necho"); + var watch = new DocumentWatch(host, documents.Plugin); + watch.Pump(); + var left = host.State.Current!.LeftDocument; + var right = host.State.Current!.RightDocument; + var startedBesideTheFirstPage = false; + var drawnBesideTheLastPage = false; + documents.Landing = _ => + { + if (_ != Hash(left)) + { + return; + } + + switch (Pages(host.State, left).Count) + { + case 0: + // Long enough for a second call to have been started, were one going to be + Thread.Sleep(100); + startedBesideTheFirstPage = documents.Renders > 1; + break; + case 2: + // The left's last page waits for the right's first, which never comes when + // the right is waiting for the left's last + drawnBesideTheLastPage = Soon(() => Pages(host.State, right).Count > 0); + break; + } + }; + + await Assert.That(watch.Pump()).IsTrue(); + + await Assert.That(startedBesideTheFirstPage).IsFalse(); + await Assert.That(drawnBesideTheLastPage).IsTrue(); + await Assert.That(Pages(host.State, left).Count).IsEqualTo(3); + var drawn = DocumentPages.Of(host.State, right)!; + await Assert.That(drawn.Complete).IsTrue(); + await Assert.That(drawn.Failure).IsNull(); + await Assert.That(drawn.Pages.Count).IsEqualTo(2); } /// @@ -95,6 +184,60 @@ public async Task OnlyTheEntryOnScreenIsReadAndDrawn() await Assert.That(host.State.Current!.LeftText).IsEqualTo("charlie"); } + /// + /// Stepping on while an entry is being drawn starts nothing for the one stepped to until the + /// two calls in hand are done. They cannot be stopped, and two more beside them would be four + /// at once, with two more again for every entry a reader steps through. + /// + [Test] + public async Task NothingIsStartedForTheNextEntryWhileOneIsBeingDrawn() + { + var release = new ManualResetEventSlim(); + try + { + var documents = new FakeDocuments(); + disposables.Add(documents); + var first = TrackedEntry.ForMove(Write("first.received.docx", "alpha"), Write("first.verified.docx", "bravo"), documents.Plugin); + var second = TrackedEntry.ForMove(Write("second.received.docx", "charlie"), Write("second.verified.docx", "delta"), documents.Plugin); + var state = ViewerSession.EnqueueTracked(SessionState.Start(ViewerMode.Inline, Fixtures.Columns, Fixtures.Rows), first); + state = ViewerSession.EnqueueTracked(state, second); + var host = new SessionHost(ViewerSession.SelectKey(state, first.Key)); + var watch = new DocumentWatch(host, documents.Plugin); + watch.Pump(); + using var drawing = new CountdownEvent(2); + documents.Landing = _ => + { + drawing.Signal(); + release.Wait(); + }; + + var pump = Task.Run(watch.Pump); + await Assert.That(drawing.Wait(TimeSpan.FromSeconds(30))).IsTrue(); + host.Mutate(_ => ViewerSession.SelectKey(_, second.Key)); + + // Long enough for the entry now on screen to have been read, were it going to be + await Task.Delay(300); + await Assert.That(pump.IsCompleted).IsFalse(); + await Assert.That(documents.Texts).IsEqualTo(2); + await Assert.That(documents.Renders).IsEqualTo(2); + + documents.Landing = null; + release.Set(); + await pump; + while (watch.Pump()) + { + } + + await Assert.That(documents.Texts).IsEqualTo(4); + await Assert.That(documents.Renders).IsEqualTo(4); + await Assert.That(host.State.Current!.LeftText).IsEqualTo("charlie"); + } + finally + { + release.Set(); + } + } + /// /// Nothing left to do is a pass that does nothing: no job, and the state as it was, so an open /// menu stays open through it. @@ -232,7 +375,7 @@ public async Task ACallThatNeverReturnsIsLeftBehind() Timeout = TimeSpan.FromMilliseconds(200) }; watch.Pump(); - documents.Landing = release.Wait; + documents.Landing = _ => release.Wait(); watch.Pump(); @@ -247,6 +390,510 @@ public async Task ACallThatNeverReturnsIsLeftBehind() } } + /// + /// A side that hangs takes nothing from the other, which is drawn to its last page meanwhile. + /// + [Test] + public async Task ASideThatHangsDoesNotStopTheOther() + { + var release = new ManualResetEventSlim(); + try + { + var (host, documents) = Owned("alpha", "bravo\ncharlie\ndelta", ".docx"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromSeconds(1) + }; + watch.Pump(); + var left = host.State.Current!.LeftDocument; + var right = host.State.Current!.RightDocument; + documents.Landing = _ => + { + if (_ == Hash(left)) + { + release.Wait(); + } + }; + + watch.Pump(); + + await Assert.That(DocumentPages.Of(host.State, left)!.Failure).IsEqualTo("gave up after 1 seconds."); + var drawn = DocumentPages.Of(host.State, right)!; + await Assert.That(drawn.Complete).IsTrue(); + await Assert.That(drawn.Failure).IsNull(); + await Assert.That(drawn.Pages.Count).IsEqualTo(3); + } + finally + { + release.Set(); + } + } + + /// + /// Each side is given up on by its own count. The left landing page after page does not keep a + /// right side that has stopped from being given up on, and giving up on the right takes + /// nothing from the left, which lands every page it has. + /// + [Test] + public async Task EachSideIsGivenUpOnByItsOwnCount() + { + var release = new ManualResetEventSlim(); + try + { + // Sixty pages a twentieth of a second apart is three seconds of landing pages, against + // a second for a side that lands none + var (host, documents) = Owned(string.Join('\n', Enumerable.Range(1, 60)), "alpha", ".docx"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromSeconds(1) + }; + watch.Pump(); + var left = host.State.Current!.LeftDocument; + var right = host.State.Current!.RightDocument; + var givenUpOnWhileTheLeftWasLanding = false; + documents.Landing = _ => + { + if (_ != Hash(left)) + { + release.Wait(); + return; + } + + if (DocumentPages.Of(host.State, right) is { Failure: not null }) + { + // Nothing left to wait out, so the rest land at once + givenUpOnWhileTheLeftWasLanding = true; + return; + } + + Thread.Sleep(50); + }; + + watch.Pump(); + + await Assert.That(DocumentPages.Of(host.State, right)!.Failure).IsEqualTo("gave up after 1 seconds."); + await Assert.That(givenUpOnWhileTheLeftWasLanding).IsTrue(); + var drawn = DocumentPages.Of(host.State, left)!; + await Assert.That(drawn.Complete).IsTrue(); + await Assert.That(drawn.Failure).IsNull(); + await Assert.That(drawn.Pages.Count).IsEqualTo(60); + } + finally + { + release.Set(); + } + } + + /// + /// A call that was left behind is stopped where its next page lands, which is the one place it + /// can be. It used to run on to its last page. + /// + [Test] + public async Task ACallLeftBehindIsStoppedAtItsNextPage() + { + var release = new ManualResetEventSlim(); + try + { + var (host, documents) = Owned("alpha\nbravo\ncharlie", "delta", ".docx"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromSeconds(1) + }; + watch.Pump(); + var left = host.State.Current!.LeftDocument; + var landings = 0; + documents.Landing = _ => + { + if (_ == Hash(left)) + { + Interlocked.Increment(ref landings); + release.Wait(); + } + }; + watch.Pump(); + + release.Set(); + await Until(() => documents.Returned == 2); + + await Assert.That(landings).IsEqualTo(1); + // And the page it was stopped at is not taken, having landed after it was given up on + var rendering = DocumentPages.Of(host.State, left)!; + await Assert.That(rendering.Failure).IsEqualTo("gave up after 1 seconds."); + await Assert.That(rendering.Pages).IsEmpty(); + } + finally + { + release.Set(); + } + } + + /// + /// A long document lands a page every so often for longer than the timeout, which is how long + /// it may go with nothing coming of it, not how long it may take. Counted from its start, it + /// was given up on part way through with pages still arriving. + /// + [Test] + public async Task ADocumentThatKeepsLandingPagesIsNotGivenUpOn() + { + var (host, documents) = Owned("alpha\nbravo\ncharlie", "alpha"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromSeconds(2) + }; + watch.Pump(); + // Three pages at 0.8 seconds each: longer than the timeout in all, well inside it apiece + documents.Landing = _ => Thread.Sleep(800); + + watch.Pump(); + + var rendering = DocumentPages.Of(host.State, host.State.Current!.LeftDocument)!; + await Assert.That(rendering.Failure).IsNull(); + await Assert.That(rendering.Pages.Count).IsEqualTo(3); + } + + /// + /// PDFium reads one document at a time, so while a PDF that was left behind is still inside it + /// the next one waits, and is read when that call returns. It used to fail at once, and so did + /// every PDF after it until the viewer was restarted, however long PDFium had been free. + /// + [Test] + public async Task APdfWaitsForTheOneLeftBehindAndIsThenRead() + { + var release = new ManualResetEventSlim(); + try + { + var (host, documents) = Owned("alpha", "bravo"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromMilliseconds(200) + }; + watch.Pump(); + documents.Landing = _ => release.Wait(); + watch.Pump(); + documents.Landing = null; + var right = host.State.Current!.RightDocument; + + var wait = watch.Turn(); + + // Not a job done and not a failure of this document: nothing is recorded against it + await Assert.That(wait).IsNotNull(); + await Assert.That(host.State.Message!).Contains("sample.verified.pdf is waiting for an earlier PDF"); + await Assert.That(DocumentPages.Of(host.State, right)).IsNull(); + + release.Set(); + await Until(() => + { + watch.Turn(); + return DocumentPages.Of(host.State, right) is { Complete: true }; + }); + + var rendering = DocumentPages.Of(host.State, right)!; + await Assert.That(rendering.Failure).IsNull(); + await Assert.That(rendering.Pages.Count).IsEqualTo(1); + await Assert.That(host.State.Message).IsNull(); + } + finally + { + release.Set(); + } + } + + /// + /// Two PDFs take turns at PDFium's lock, so when one of them stops inside PDFium the other + /// stops at the lock, and both run out of time. Only the first is given up on. The second was + /// stopped by nothing of its own, so it is put back as not started, with nothing recorded + /// against it, and is drawn once PDFium is free. + /// + [Test] + public async Task APdfStoppedBehindTheOtherSideIsNotFailed() + { + var release = new ManualResetEventSlim(); + try + { + var (host, documents) = Owned("alpha\nbravo", "charlie"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromSeconds(1) + }; + watch.Pump(); + var left = host.State.Current!.LeftDocument; + var right = host.State.Current!.RightDocument; + // The left side lands a page, which is what starts the right, and neither lands + // another: all that can be seen of one PDF inside PDFium and one waiting for it + documents.Landing = _ => + { + if (_ != Hash(left) || + Pages(host.State, left).Count > 0) + { + release.Wait(); + } + }; + + watch.Pump(); + documents.Landing = null; + + var givenUpOn = DocumentPages.Of(host.State, left)!; + await Assert.That(givenUpOn.Failure).IsEqualTo("gave up after 1 seconds."); + await Assert.That(givenUpOn.Pages.Count).IsEqualTo(1); + await Assert.That(DocumentPages.Of(host.State, right)).IsNull(); + + var wait = watch.Turn(); + + await Assert.That(wait).IsNotNull(); + await Assert.That(host.State.Message!).Contains("sample.verified.pdf is waiting for an earlier PDF"); + await Assert.That(DocumentPages.Of(host.State, right)).IsNull(); + + release.Set(); + await Until(() => + { + watch.Turn(); + return DocumentPages.Of(host.State, right) is { Complete: true }; + }); + + var rendering = DocumentPages.Of(host.State, right)!; + await Assert.That(rendering.Failure).IsNull(); + await Assert.That(rendering.Pages.Count).IsEqualTo(1); + await Assert.That(host.State.Message).IsNull(); + } + finally + { + release.Set(); + } + } + + /// + /// Both sides of an entry can be left behind, and PDFium is not free until both have returned. + /// The first of them back says nothing about the second, which a PDF started then would wait + /// on with nothing to bound it. + /// + [Test] + public async Task APdfWaitsForEveryCallLeftBehind() + { + var releaseLeft = new ManualResetEventSlim(); + var releaseRight = new ManualResetEventSlim(); + try + { + var (host, documents) = Owned("alpha\nbravo", "charlie"); + var watch = new DocumentWatch(host, documents.Plugin) + { + Timeout = TimeSpan.FromSeconds(1) + }; + watch.Pump(); + var left = host.State.Current!.LeftDocument; + var right = host.State.Current!.RightDocument; + documents.Landing = _ => + { + if (_ != Hash(left)) + { + releaseRight.Wait(); + } + else if (Pages(host.State, left).Count > 0) + { + releaseLeft.Wait(); + } + }; + watch.Pump(); + documents.Landing = null; + + // The left side's call comes back, and the right side's has not + releaseLeft.Set(); + await Until(() => documents.Returned == 1); + // Long enough for the watch to have heard that it did + await Task.Delay(200); + watch.Turn(); + + await Assert.That(host.State.Message!).Contains("sample.verified.pdf is waiting for an earlier PDF"); + await Assert.That(DocumentPages.Of(host.State, right)).IsNull(); + await Assert.That(documents.Renders).IsEqualTo(2); + + releaseRight.Set(); + await Until(() => + { + watch.Turn(); + return DocumentPages.Of(host.State, right) is { Complete: true }; + }); + + await Assert.That(DocumentPages.Of(host.State, right)!.Failure).IsNull(); + await Assert.That(documents.Renders).IsEqualTo(3); + } + finally + { + releaseLeft.Set(); + releaseRight.Set(); + } + } + + /// + /// The viewer's own copy of a document could not be written: something had the file, the disk + /// was full. That says nothing about the document, so nothing is recorded against it. The + /// reason is said, and the next turn tries again. + /// + [Test] + public async Task ACopyThatCannotBeWrittenIsSaidAndTriedAgain() + { + var (host, documents) = Owned("alpha", "bravo"); + var watch = new DocumentWatch(host, documents.Plugin); + var blocked = Block(documents, host.State.Current!.LeftDocument); + + var wait = watch.Turn(); + + await Assert.That(wait).IsNotNull(); + await Assert.That(host.State.Message!).StartsWith("Could not read the documents: "); + await Assert.That(host.State.Current!.LeftDocument!.Value.Reading).IsTrue(); + await Assert.That(documents.Texts).IsEqualTo(0); + + Directory.Delete(blocked); + while (watch.Turn() is null) + { + } + + var entry = host.State.Current!; + await Assert.That(entry.LeftText).IsEqualTo("alpha"); + await Assert.That(entry.LeftDocument!.Value.Unreadable).IsNull(); + var rendering = DocumentPages.Of(host.State, entry.LeftDocument)!; + await Assert.That(rendering.Failure).IsNull(); + await Assert.That(rendering.Pages.Count).IsEqualTo(1); + // And the status line stops giving a reason for a document that has now been read + await Assert.That(host.State.Message).IsNull(); + } + + /// + /// A drawing is marked as started so that it is never started twice, which made one whose copy + /// then could not be written a spinner for as long as its entry stayed in the queue. An SVG's + /// text is the file itself, so drawing it is the first thing to want the copy. + /// + [Test] + public async Task ADrawingWhoseCopyCouldNotBeWrittenIsNotLeftAsStarted() + { + var (host, documents) = Owned("alpha", "bravo", ".svg"); + var watch = new DocumentWatch(host, documents.Plugin); + var left = host.State.Current!.LeftDocument; + var blocked = Block(documents, left); + + watch.Turn(); + + await Assert.That(host.State.Message!).StartsWith("Could not read the documents: "); + await Assert.That(DocumentPages.Of(host.State, left)).IsNull(); + + Directory.Delete(blocked); + while (watch.Turn() is null) + { + } + + var rendering = DocumentPages.Of(host.State, left)!; + await Assert.That(rendering.Complete).IsTrue(); + await Assert.That(rendering.Pages.Count).IsEqualTo(1); + } + + /// + /// A side that cannot be started says nothing about the other, which is drawn in the turn that + /// says why the first was not, rather than once whatever was in its way has gone. + /// + [Test] + public async Task ASideThatCannotBeStartedDoesNotHoldUpTheOther() + { + var (host, documents) = Owned("alpha", "bravo", ".svg"); + var watch = new DocumentWatch(host, documents.Plugin); + var blocked = Block(documents, host.State.Current!.LeftDocument); + + var wait = watch.Turn(); + + await Assert.That(wait).IsNotNull(); + await Assert.That(host.State.Message!).StartsWith("Could not read the documents: "); + await Assert.That(DocumentPages.Of(host.State, host.State.Current!.LeftDocument)).IsNull(); + var rendering = DocumentPages.Of(host.State, host.State.Current!.RightDocument)!; + await Assert.That(rendering.Complete).IsTrue(); + await Assert.That(rendering.Failure).IsNull(); + await Assert.That(rendering.Pages.Count).IsEqualTo(1); + + Directory.Delete(blocked); + } + + /// + /// The turn comes round again for as long as the copy cannot be written, and says why once: + /// said on each, it took the status line back from whatever the reader did in between. + /// + [Test] + public async Task WhatIsInTheWayIsSaidOnce() + { + var (host, documents) = Owned("alpha", "bravo"); + var watch = new DocumentWatch(host, documents.Plugin); + var blocked = Block(documents, host.State.Current!.LeftDocument); + watch.Turn(); + host.Mutate(_ => _ with { Message = "Copied 3 lines" }); + + watch.Turn(); + + await Assert.That(host.State.Message).IsEqualTo("Copied 3 lines"); + + // Nor does it take the line back as what was in the way goes: that clears only its own + Directory.Delete(blocked); + while (watch.Turn() is null) + { + } + + await Assert.That(host.State.Current!.HasText).IsTrue(); + await Assert.That(host.State.Message).IsEqualTo("Copied 3 lines"); + } + + /// + /// The loop itself, on its own thread as the viewer runs it. A turn that failed used to be its + /// last: the reason was said and the loop returned, and no document was read or drawn again + /// until the viewer was restarted. + /// + [Test] + public async Task TheLoopOutlivesATurnThatFailed() + { + var (host, documents) = Owned("alpha", "bravo"); + var blocked = Block(documents, host.State.Current!.LeftDocument); + using var cancel = new CancelSource(); + var watch = new DocumentWatch(host, documents.Plugin); + var loop = Task.Run(() => watch.Run(cancel.Token)); + try + { + await Until(() => host.State.Message is not null); + await Assert.That(host.State.Message!).StartsWith("Could not read the documents: "); + + Directory.Delete(blocked); + await Until(() => + host.State.Current!.HasText && + DocumentPages.Of(host.State, host.State.Current.RightDocument) is { Complete: true }); + + await Assert.That(loop.IsCompleted).IsFalse(); + } + finally + { + cancel.Cancel(); + await loop; + } + } + + /// + /// A directory where a side's copy is written aside before it is moved into place, which is + /// what a file that cannot be written looks like on every platform. Returned to be deleted, + /// which is whatever was in the way going. + /// + static string Block(FakeDocuments documents, DocumentFile? side) + { + var extension = Path.GetExtension(side!.Value.Path); + var partial = Path.Combine(documents.Cache.For(side.Value.Hash!), $"source{extension}.partial"); + return Directory.CreateDirectory(partial).FullName; + } + + static async Task Until(Func condition) + { + var timer = Stopwatch.StartNew(); + while (!condition()) + { + if (timer.Elapsed > TimeSpan.FromSeconds(30)) + { + throw new("Still waiting after 30 seconds."); + } + + await Task.Delay(20); + } + } + /// /// Replacing an entry an accept-all has claimed would lose what it records about it, so the /// text waits for the batch to finish. @@ -262,12 +909,12 @@ public async Task TextWaitsForAnAcceptAll() await Assert.That(host.State.Current!.LeftDocument!.Value.Reading).IsTrue(); } - (SessionHost Host, FakeDocuments Documents) Owned(string left, string right) + (SessionHost Host, FakeDocuments Documents) Owned(string left, string right, string extension = ".pdf") { var documents = new FakeDocuments(); disposables.Add(documents); - var leftFile = Write("sample.received.pdf", left); - var rightFile = Write("sample.verified.pdf", right); + var leftFile = Write($"sample.received{extension}", left); + var rightFile = Write($"sample.verified{extension}", right); var entry = QueueEntry.ForFiles( leftFile, rightFile, @@ -280,6 +927,30 @@ public async Task TextWaitsForAnAcceptAll() static IReadOnlyList Pages(SessionState state, DocumentFile? side) => DocumentPages.Of(state, side)?.Pages ?? []; + static string Hash(DocumentFile? side) => + side!.Value.Hash!; + + /// + /// Whether something comes true while a page is held back for it. On the thread that is + /// drawing, so it waits rather than awaits, and gives up rather than hold a failing run + /// forever. + /// + static bool Soon(Func condition) + { + var timer = Stopwatch.StartNew(); + while (!condition()) + { + if (timer.Elapsed > TimeSpan.FromSeconds(10)) + { + return false; + } + + Thread.Sleep(10); + } + + return true; + } + string Write(string name, string content) { var path = Path.Combine(directory, name); @@ -318,17 +989,28 @@ public FakeDocuments() => public string? RenderFailure { get; set; } /// - /// Called as each page is about to land. + /// Called as each page is about to land, with the hash of the document it is a page of, + /// which is how a test tells one side from the other: both are drawn at once, each on a + /// thread of its own. /// - public Action? Landing { get; set; } + public Action? Landing { get; set; } + + int texts; + int renders; + int returned; - public int Texts { get; private set; } + public int Texts => Volatile.Read(ref texts); - public int Renders { get; private set; } + public int Renders => Volatile.Read(ref renders); + + /// + /// How many of the calls to draw have come back, however they ended. + /// + public int Returned => Volatile.Read(ref returned); string Text(string path) { - Texts++; + Interlocked.Increment(ref texts); if (TextFailure is not null) { throw new(TextFailure); @@ -339,23 +1021,32 @@ string Text(string path) int Render(string path, string directory, string projection, Action landed) { - Renders++; - if (RenderFailure is not null) + Interlocked.Increment(ref renders); + try { - throw new(RenderFailure); - } + if (RenderFailure is not null) + { + throw new(RenderFailure); + } + + // The copy being drawn sits in a directory named for the hash of its bytes + var hash = Path.GetFileName(Path.GetDirectoryName(path))!; + var lines = File.ReadAllLines(path); + for (var index = 0; index < lines.Length; index++) + { + var colour = SHA256.HashData(Encoding.UTF8.GetBytes(lines[index])); + var page = Path.Combine(directory, $"page_{index + 1:0000}.png"); + File.WriteAllBytes(page, SamplePng.Build(8, 8, colour[0], colour[1], colour[2])); + Landing?.Invoke(hash); + landed(page); + } - var lines = File.ReadAllLines(path); - for (var index = 0; index < lines.Length; index++) + return lines.Length; + } + finally { - var colour = SHA256.HashData(Encoding.UTF8.GetBytes(lines[index])); - var page = Path.Combine(directory, $"page_{index + 1:0000}.png"); - File.WriteAllBytes(page, SamplePng.Build(8, 8, colour[0], colour[1], colour[2])); - Landing?.Invoke(); - landed(page); + Interlocked.Increment(ref returned); } - - return lines.Length; } public void Dispose() => diff --git a/src/DiffEngineViewer.Tests/EngineDiffTests.cs b/src/DiffEngineViewer.Tests/EngineDiffTests.cs index 4c723f1fb..90b412891 100644 --- a/src/DiffEngineViewer.Tests/EngineDiffTests.cs +++ b/src/DiffEngineViewer.Tests/EngineDiffTests.cs @@ -77,6 +77,33 @@ public async Task ARepeatOfThePairReplacesIt() await Assert.That(entry.LeftText).IsEqualTo("changed"); } + /// + /// And one that says what the last said, which is what a test that keeps failing the same way + /// sends on every run, leaves the reader where they are. It used to open the entry again, at + /// its first change, under whoever was half way down it. + /// + [Test] + public async Task ARepeatOfAnUnchangedPairLeavesTheReaderWhereTheyAre() + { + using var scope = new EngineScope(); + var received = Path.Combine(directory, "Deep.Test.received.txt"); + var target = Path.Combine(directory, "Deep.Test.verified.txt"); + await File.WriteAllTextAsync(received, Fixtures.Deep(true)); + await File.WriteAllTextAsync(target, Fixtures.Deep(false)); + await EngineRunner.LaunchAsync(Viewer(), received, target); + var host = scope.Fixture.Host; + var opened = host.State.ScrollTop; + var scrolled = host.Mutate(_ => ViewerSession.Apply(_, CommandKind.PageDown)).ScrollTop; + await Assert.That(scrolled).IsNotEqualTo(opened); + + // The next run writes what the last one wrote + await File.WriteAllTextAsync(received, Fixtures.Deep(true)); + await EngineRunner.LaunchAsync(Viewer(), received, target); + + await Assert.That(host.State.ScrollTop).IsEqualTo(scrolled); + await Assert.That(host.State.Queue).HasSingleItem(); + } + /// /// Settling is what replaces killing the window for a tool that had one per pair, and it names /// one entry: the pair beside it stays. @@ -97,6 +124,39 @@ await Assert.That(scope.Fixture.Host.State.Queue.Single().Key) .IsEqualTo(TrackedKeys.ForMove(second.Received)); } + /// + /// The first run of a snapshot has no verified file, and the pair reaches the viewer as that. + /// + /// The viewer was declared as needing a target, so EmptyFiles wrote a placeholder before the + /// viewer heard of the pair, and the viewer compared against it as though it were the expected + /// file: a blank page, or an empty PDF it could not open. An extension EmptyFiles has no file + /// for stopped the launch there, which is every map but one, so nothing was raised over a new + /// map snapshot at all. + /// + /// + [Test] + [Arguments(".txt")] + [Arguments(".png")] + [Arguments(".pdf")] + [Arguments(".geojson")] + public async Task ANewSnapshotArrivesWithNoPlaceholderWritten(string extension) + { + using var scope = new EngineScope(); + var received = Path.Combine(directory, $"New.Test.received{extension}"); + var target = Path.Combine(directory, $"New.Test.verified{extension}"); + await File.WriteAllTextAsync(received, "received"); + + var result = await EngineRunner.LaunchAsync(Viewer(), received, target); + + await Assert.That(result).IsEqualTo(EngineLaunchResult.AlreadyRunningAndSupportsRefresh); + await Assert.That(File.Exists(target)).IsFalse(); + var entry = scope.Fixture.Host.State.Queue.Single(); + await Assert.That(entry.Key).IsEqualTo(TrackedKeys.ForMove(received)); + // Nothing on the expected side, rather than a file that happens to be empty + await Assert.That(entry.RightStamp).IsNull(); + await Assert.That(scope.Fixture.Windows).IsEquivalentTo([WindowCommand.Focus]); + } + static EngineResolvedTool Viewer() => new( nameof(EngineTool.DiffEngineViewer), @@ -109,7 +169,11 @@ static EngineResolvedTool Viewer() => isMdi: false, autoRefresh: false, binaryExtensions: [], - requiresTarget: true, + // As the viewer that ships is declared, since whether a target is written first is + // part of the route being covered + requiresTarget: engine::DiffEngine.Definitions.Tools + .Single(_ => _.Tool == EngineTool.DiffEngineViewer) + .RequiresTarget, supportsText: true, useShellExecute: false); diff --git a/src/DiffEngineViewer.Tests/FileTypeLaunchTests.cs b/src/DiffEngineViewer.Tests/FileTypeLaunchTests.cs index bb263eb9d..7e49eb379 100644 --- a/src/DiffEngineViewer.Tests/FileTypeLaunchTests.cs +++ b/src/DiffEngineViewer.Tests/FileTypeLaunchTests.cs @@ -267,6 +267,57 @@ public async Task DamagedDocuments() await ManualViewer.WaitForClose(); } + /// + /// The first run of a snapshot: a received file and nothing verified beside it, for every type + /// the viewer is offered. What DiffEngine does about the missing file is decided per extension + /// before the viewer hears of the pair, and it used to write a placeholder there, or give up + /// where it had none to write, which was most of the maps. + /// + [Test] + [Explicit] + [Arguments(".png")] + [Arguments(".jpg")] + [Arguments(".pdf")] + [Arguments(".docx")] + [Arguments(".xlsx")] + [Arguments(".pptx")] + [Arguments(".svg")] + [Arguments(".geojson")] + [Arguments(".topojson")] + [Arguments(".kml")] + [Arguments(".gpx")] + [Arguments(".wkt")] + [Arguments(".kmz")] + [Arguments(".wkb")] + [Arguments(".fgb")] + [Arguments(".geoparquet")] + public async Task NewSnapshot(string extension) + { + await Assert.That(EngineTools.IsDetectedForExtension(EngineTool.DiffEngineViewer, extension)).IsTrue(); + + var directory = ManualViewer.TempDirectory(); + var temp = Path.Combine(directory.FullName, $"Sample.received{extension}"); + var target = Path.Combine(directory.FullName, $"Sample.verified{extension}"); + var image = extension is ".png" or ".jpg"; + await File.WriteAllBytesAsync(temp, image ? SampleImages.Build(extension, 220, 40, 40) : Whole(extension)); + + ManualViewer.Expect( + $"New snapshot {extension}", + $"Headers Sample.received{extension} and Sample.verified{extension}", + "The right pane is empty, with no picture, no page and no spinner left turning", + image + ? "The left pane has the picture's rows, each marked added, and the picture under them" + : "The left pane has the document's text, every line marked added, and its drawing under it", + $"The status line says only Sample.received{extension} exists, and nothing about a file that could not be read", + $"No Sample.verified{extension} is written beside the received file until Accept is pressed"); + + var result = await EngineRunner.LaunchAsync(EngineTool.DiffEngineViewer, temp, target); + + await Assert.That(result).IsEqualTo(EngineLaunch.StartedNewInstance); + await Assert.That(File.Exists(target)).IsFalse(); + await ManualViewer.WaitForClose(); + } + static byte[] Whole(string extension) => extension switch { diff --git a/src/DiffEngineViewer.Tests/Fixtures.cs b/src/DiffEngineViewer.Tests/Fixtures.cs index 65bbf2ab8..c29dccf91 100644 --- a/src/DiffEngineViewer.Tests/Fixtures.cs +++ b/src/DiffEngineViewer.Tests/Fixtures.cs @@ -277,6 +277,31 @@ public static SessionState Document() public static SessionState DocumentDrawing() => ViewerSession.Rendered(Document(), "BB", Rendering.Started); + /// + /// The pair holds, as a pending move in a queue rather than in a window + /// of its own. That is the fullest footer there is: a queue's three buttons, the three every + /// entry has, and the five a paged document adds. + /// + public static SessionState DocumentInQueue() + { + var leftPage = WriteImage("page.received.png", SamplePng.Build(200, 260, 198, 64, 64)); + var rightPage = WriteImage("page.verified.png", SamplePng.Build(200, 260, 64, 150, 198)); + var left = new DocumentFile("temp/sample.received.pdf", 1_234, DocumentFormat.Pdf, "AA"); + var right = new DocumentFile("code/sample.verified.pdf", 1_240, DocumentFormat.Pdf, "BB"); + var state = ViewerSession.EnqueueTracked( + SessionState.Start(ViewerMode.Inline, Columns, Rows), + QueueEntry.ForMove( + "move:temp/sample.received.pdf", + "Sample.Test (pdf)", + null, + "temp/sample.received.pdf", + "code/sample.verified.pdf", + new(Long(false), null, null, null, left), + new(Long(true), null, null, null, right))); + state = ViewerSession.Rendered(state, "AA", new([new(leftPage, 200, 260, "LEFT")], true)); + return ViewerSession.Rendered(state, "BB", new([new(rightPage, 200, 260, "RIGHT")], true)); + } + static string WriteImage(string name, byte[] content) { // A fixed directory and a fixed name: only the file name reaches a pane header, and a diff --git a/src/DiffEngineViewer.Tests/GroupAcceptBatchTests.cs b/src/DiffEngineViewer.Tests/GroupAcceptBatchTests.cs new file mode 100644 index 000000000..c774059cf --- /dev/null +++ b/src/DiffEngineViewer.Tests/GroupAcceptBatchTests.cs @@ -0,0 +1,200 @@ +/// +/// "Accept all in" a header is an accept-all over that header's members: the same batch, an entry +/// at a time and outside the lock, with fewer entries in it. It used to be one transition that +/// applied the whole group before it returned, which in a queue of one solution is the whole +/// queue, on the render thread. +/// +public class GroupAcceptBatchTests +{ + /// + /// What the window does with the click: begins the batch, and writes nothing. Real actions are + /// what a click is dispatched with, so anything applied here would be an attempt on a source + /// file that does not exist, and would show as a failed entry. + /// + [Test] + public async Task The_click_begins_a_batch_and_applies_nothing() + { + var open = OverSolutionA(TwoSolutions()); + + var clicked = ViewerProgram.Apply(open, Click(open, "Accept all in SolutionA"), link: null, new NoWindow()); + + await Assert.That(clicked.Batch).IsNotNull(); + await Assert.That(clicked.Batch!.Total).IsEqualTo(2); + await Assert.That(clicked.Batch.Current).IsNull(); + await Assert.That(clicked.Queue.Count).IsEqualTo(3); + await Assert.That(clicked.Queue.All(_ => _.Status is null)).IsTrue(); + await Assert.That(clicked.Menu).IsNull(); + await Assert.That(ScreenBuilder.Build(clicked).Status).IsEqualTo("Accepting 1 of 2"); + } + + /// + /// Carried out the way the loop has it carried out, the lock is free while each member is + /// applied, and the batch reaches the group's members and nobody else's. + /// + [Test] + public async Task The_batch_is_applied_outside_the_lock_and_stops_at_the_group() + { + var open = OverSolutionA(TwoSolutions()); + var host = new SessionHost(open); + var seen = new List(); + var actions = Fixtures.Applied with + { + ApplyInline = _ => + { + // Another thread, standing in for the render loop, since the lock lets the thread + // that holds it back in + var drawn = Task.Run(() => host.Mutate(state => state)); + if (!drawn.Wait(TimeSpan.FromSeconds(10))) + { + throw new("The lock was held across an apply."); + } + + seen.Add($"{ScreenBuilder.Build(drawn.Result).Status}: {Path.GetFileName(_.SourceFile)}"); + return InlineApplyResult.Applied; + } + }; + host.Mutate(ViewerSession.BeginAcceptGroup); + + var message = new AcceptAllRunner(host, actions).Drive(); + + await Assert.That(string.Join("\n", seen)).IsEqualTo( + """ + Accepting 1 of 2: ATests.cs + Accepting 2 of 2: OtherTests.cs + """); + await Assert.That(message).IsEqualTo("Accepted 2"); + await Assert.That(host.State.Queue.Select(_ => _.Name)).IsEquivalentTo(["BTests.cs:3"]); + await Assert.That(host.State.Batch).IsNull(); + } + + /// + /// What still needs review is counted over the group. A conflict in another solution is not + /// this accept's to report, as it was not before. + /// + [Test] + public async Task A_conflict_outside_the_group_is_not_counted() + { + var state = ViewerSession.EnqueueInline( + TwoSolutions(), + Fixtures.Patch(Fixtures.SolutionFile("SolutionB", "Tests", "BTests.cs"), 3, "\"x\"", "z", framework: "net9.0")); + await Assert.That(state.Queue.Single(_ => _.Name == "BTests.cs:3").Conflicted).IsTrue(); + + var accepted = ViewerSession.Apply(OverSolutionA(state), CommandKind.AcceptGroup, Fixtures.Applied); + + await Assert.That(accepted.Message).IsEqualTo("Accepted 2"); + } + + /// + /// And one inside it is, and is left for a reviewer. + /// + [Test] + public async Task A_conflict_inside_the_group_is_counted_and_kept() + { + var state = ViewerSession.EnqueueInline( + TwoSolutions(), + Fixtures.Patch(Fixtures.SolutionFile("SolutionA", "Tests", "OtherTests.cs"), 2, "\"a\"", "c", framework: "net9.0")); + + var accepted = ViewerSession.Apply(OverSolutionA(state), CommandKind.AcceptGroup, Fixtures.Applied); + + await Assert.That(accepted.Message).IsEqualTo("Accepted 1, 1 conflict needs review"); + await Assert.That(accepted.Queue.Select(_ => _.Name)).IsEquivalentTo(["OtherTests.cs:2", "BTests.cs:3"]); + } + + /// + /// The batch's rule for a snapshot the applier would not take: it stays, saying why. A group + /// accept used to drop it as a single accept does, which out of a group of thirty is an entry + /// nobody saw go, with no literal written and nothing left in the queue to say so. + /// + [Test] + public async Task A_snapshot_that_was_not_written_stays_and_says_why() + { + var actions = Fixtures.Applying(InlineApplyResult.NotFound("no Verify call")); + + var accepted = ViewerSession.Apply(OverSolutionA(TwoSolutions()), CommandKind.AcceptGroup, actions); + + await Assert.That(accepted.Queue.Count).IsEqualTo(3); + var members = accepted.Queue.Where(_ => _.Name != "BTests.cs:3").ToList(); + await Assert.That(members.All(_ => _.Status!.Contains("no Verify call"))).IsTrue(); + await Assert.That(accepted.Queue.Single(_ => _.Name == "BTests.cs:3").Status).IsNull(); + await Assert.That(accepted.Message).IsEqualTo("Accepted 0, 2 not written"); + } + + /// + /// Nothing else that changes the queue is taken while a group's batch runs, the same as while + /// an accept-all does: the two are one mechanism, and a second one begun over the first would + /// claim entries the first is part way through. + /// + [Test] + public async Task A_second_accept_is_refused_while_a_groups_batch_runs() + { + var running = ViewerSession.ClaimNext(ViewerSession.BeginAcceptGroup(OverSolutionA(TwoSolutions()))); + await Assert.That(running.Batch!.Current).IsNotNull(); + + var again = ViewerProgram.Apply(running, Key(CommandKind.AcceptAll), link: null, new NoWindow()); + + await Assert.That(again.Batch).IsSameReferenceAs(running.Batch); + await Assert.That(ViewerSession.BeginAcceptAll(running)).IsSameReferenceAs(running); + } + + /// + /// A header with no menu open over it has no group to accept, and the state is left alone. + /// + [Test] + public async Task With_no_menu_open_there_is_no_group_to_accept() + { + var state = TwoSolutions(); + + await Assert.That(ViewerSession.BeginAcceptGroup(state)).IsSameReferenceAs(state); + } + + // Each from one framework, so the same call site reported by another with other content is a + // conflict rather than a re-run. + static SessionState TwoSolutions() => + Fixtures.Inline( + Fixtures.Patch(Fixtures.SolutionFile("SolutionA", "Tests", "ATests.cs"), 1, framework: "net8.0"), + Fixtures.Patch(Fixtures.SolutionFile("SolutionA", "Tests", "OtherTests.cs"), 2, "\"a\"", "b", framework: "net8.0"), + Fixtures.Patch(Fixtures.SolutionFile("SolutionB", "Tests", "BTests.cs"), 3, "\"x\"", "y", framework: "net8.0")); + + static SessionState OverSolutionA(SessionState state) + { + var rows = QueueProjection.Visible(state, ScreenBuilder.BodyRows(state), out _).ToList(); + return ViewerSession.OpenMenu(state, rows.FindIndex(_ => _.GroupName == "SolutionA")); + } + + static ViewerInput Click(SessionState state, string label) => + Key(CommandKind.None) with + { + ClickedMenuItem = state.Menu!.Items.ToList().FindIndex(_ => _.Label == label) + }; + + static ViewerInput Key(CommandKind key) => + new(key, -1, -1, 0, false, Fixtures.Columns, Fixtures.Rows); + + sealed class NoWindow : IViewerWindow + { + public bool Present(Screen screen) => + true; + + public ViewerInput Poll() => + default; + + public void SetHidden(bool hidden) + { + } + + public void Focus() + { + } + + public void SetClipboard(string text) + { + } + + public bool Capture(Screen screen, int width, int height, string pngPath) => + false; + + public void Dispose() + { + } + } +} diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.cs b/src/DiffEngineViewer.Tests/InlineScreenTests.cs index c0f02d733..632522e5a 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.cs +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.cs @@ -44,14 +44,11 @@ public Task AfterAcceptAll() => [Test] public Task AcceptAllInProgress() { - var state = ViewerSession.BeginAcceptAll(Pending()); - for (var step = 0; step < 2; step++) - { - state = ViewerSession.ClaimNext(state); - state = ViewerSession.ApplyClaimed(state.Batch!.Current!, Fixtures.Applied)(state); - } + // The two snapshots of the first file, which a batch claims and writes together + var state = ViewerSession.ClaimNext(ViewerSession.BeginAcceptAll(Pending())); + state = ViewerSession.ApplyClaimed(state, Fixtures.Applied)(state); - // The third claimed, and still applying + // The second file's claimed, and still applying return Verify(Fixtures.Render(ViewerSession.ClaimNext(state))); } diff --git a/src/DiffEngineViewer.Tests/MapProjectionTests.cs b/src/DiffEngineViewer.Tests/MapProjectionTests.cs index bb9a0f4db..6bd7d6a8d 100644 --- a/src/DiffEngineViewer.Tests/MapProjectionTests.cs +++ b/src/DiffEngineViewer.Tests/MapProjectionTests.cs @@ -373,7 +373,12 @@ public FakeMaps() => int Render(string path, string directory, string projection, Action landed) { - Projections.Add(projection); + // Both sides are drawn at once, each on a thread of its own + lock (Projections) + { + Projections.Add(projection); + } + var colour = SHA256.HashData(Encoding.UTF8.GetBytes(File.ReadAllText(path) + projection)); var page = Path.Combine(directory, "page_0001.png"); File.WriteAllBytes(page, SamplePng.Build(8, 8, colour[0], colour[1], colour[2])); diff --git a/src/DiffEngineViewer.Tests/PixelTests.DocumentPage.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.DocumentPage.Linux.verified.png new file mode 100644 index 000000000..135a1c046 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.DocumentPage.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.DocumentPageInQueue.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.DocumentPageInQueue.Linux.verified.png new file mode 100644 index 000000000..4f5a9934a Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.DocumentPageInQueue.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png index 59807c967..e2208c0c1 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png index c3ee51eac..57d6584d9 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.Images.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.ImagesEnlarged.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.ImagesEnlarged.OSX.verified.png index bdbbe3f6e..4f18c02a6 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.ImagesEnlarged.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.ImagesEnlarged.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.Minimal.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Minimal.OSX.verified.png index 2a01b622a..1a318cf1d 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.Minimal.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.Minimal.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.OutsideTheFont.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.OutsideTheFont.Linux.verified.png new file mode 100644 index 000000000..39cdcf377 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.OutsideTheFont.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png index 002900590..95c31ff32 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.cs b/src/DiffEngineViewer.Tests/PixelTests.cs index 11643ec32..ef0da762e 100644 --- a/src/DiffEngineViewer.Tests/PixelTests.cs +++ b/src/DiffEngineViewer.Tests/PixelTests.cs @@ -239,15 +239,6 @@ public Task InlineAccepted() public Task Minimal() => Capture(ViewerSession.Apply(Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)), CommandKind.ToggleMinimal)); - /// - /// raylib does three things at the end of a frame, behind one flag: puts it on the screen, - /// reads input, and waits for the next frame. raylib 6.0's CMake turned that flag on, so - /// deview_present did none of them - the window stayed blank and took no keys, and the loop - /// drew as fast as it could. A capture draws into a texture and never gets that far, so every - /// snapshot above kept passing. The wait is the one of the three that can be timed from here, - /// so it stands for all of them. Last in the order, so the frames it draws in the live context - /// come after every capture rather than between two of them. - /// /// /// The image comparison six steps in, eight times the size that fits, and dragged to the top /// right corner: each pane filled with the same part of its picture, cut off at the edges of @@ -280,9 +271,93 @@ public Task ImagesEnlarged() public Task PaneMenu() => Capture(ViewerSession.OpenPaneMenu(Fixtures.File(), PaneSide.Right)); + /// + /// A document's page under its text, in a window of its own. Its ten buttons leave 54 pixels + /// beside them, and the status line - which lines, which page, which pages differ - is the + /// one place those are said: it takes a line of its own under the buttons rather than running + /// off the window from wherever they ended. + /// + /// Linux only, until a baseline for the macOS head has been taken on the runner its others + /// come from. How a footer that does not fit is laid out is each head's own, so this one says + /// nothing about that one. + /// + /// [Test] [PixelTest] [NotInParallel(nameof(PixelTests), Order = 13)] + [SkipOnMac("There is no macOS baseline for this scene: a footer that does not fit is laid out by each head in its own way.")] + public Task DocumentPage() => + Capture(Fixtures.Document()); + + /// + /// The same document pending in a queue, which is the fullest footer there is: eleven buttons + /// that need 1199 pixels of a window with 1084. They wrap onto a second row, where the last + /// used to be drawn past the window's edge and could not be clicked, and the status line goes + /// beside what wrapped rather than past that. Linux only, as is. + /// + [Test] + [PixelTest] + [NotInParallel(nameof(PixelTests), Order = 14)] + [SkipOnMac("There is no macOS baseline for this scene: a footer that does not fit is laid out by each head in its own way.")] + public Task DocumentPageInQueue() => + Capture(Fixtures.DocumentInQueue()); + + /// + /// Text the embedded font has no glyphs for: wide characters, which the grid gives two cells, + /// narrow ones, marks that take none, and one from outside the basic plane. + /// + /// In its window the Linux head draws these from the machine's own fonts, and in a capture it + /// never does, because a baseline would then be a picture of whatever a runner had installed. + /// So this is the replacement glyph at each character's column, on a machine with every font + /// and on one with none. It is shown in the window first, and for long enough that the fonts + /// this machine has for it have been found and merged: that is the state a capture has to be + /// indifferent to, and one that drew with the window's font would fail here on any machine + /// with a font for one of these characters. + /// + /// + /// Linux only. The macOS head draws through Core Text, in a capture as in its window, so there + /// these characters are the runner's fonts, and a baseline of them has to come from that runner. + /// + /// + [Test] + [PixelTest] + [NotInParallel(nameof(PixelTests), Order = 15)] + [SkipOnMac("There is no macOS baseline for this scene: that head draws these characters from the runner's own fonts, and a capture host never creates its window.")] + public async Task OutsideTheFont() + { + var state = Fixtures.File( + "plain text\n日本語 and 漢字\n한국어 텍스트\nעברית عربي ไทย น้ำ\nemoji \U0001F600 and ★\nmixed 中a文b字c", + "plain text\n日本語 and 漢子\n한국어 텍스트\nעברית عربي ไทย น้ำ\nemoji \U0001F600 and ★\nmixed 中a文b字c"); + var screen = ScreenBuilder.Build(ViewerSession.Resize(state, columns, rows)); + await OnShimThread( + () => + { + // A second of frames. A font is asked for on the first of them, found on a thread + // of its own, and merged at the top of the next frame after it lands + for (var frame = 0; frame < 60; frame++) + { + window!.Present(screen); + } + + return true; + }); + + await Capture(state); + } + + /// + /// raylib does three things at the end of a frame, behind one flag: puts it on the screen, + /// reads input, and waits for the next frame. raylib 6.0's CMake turned that flag on, so + /// deview_present did none of them - the window stayed blank and took no keys, and the loop + /// drew as fast as it could. A capture draws into a texture and never gets that far, so every + /// snapshot above kept passing. The wait is the one of the three that can be timed from here, + /// so it stands for all of them. Last in the order, so the frames it draws in the live context + /// come after the captures rather than between two of them. is + /// the one capture with frames of its own ahead of it, which are what it is about. + /// + [Test] + [PixelTest] + [NotInParallel(nameof(PixelTests), Order = 16)] [SkipOnMac("A capture host never creates the macOS window, and that head waits for the next frame in its event pump rather than after drawing one.")] public async Task PresentWaitsForTheNextFrame() { diff --git a/src/DiffEngineViewer.Tests/ReEnqueueTests.cs b/src/DiffEngineViewer.Tests/ReEnqueueTests.cs index 345e5b7e3..caa4c24ce 100644 --- a/src/DiffEngineViewer.Tests/ReEnqueueTests.cs +++ b/src/DiffEngineViewer.Tests/ReEnqueueTests.cs @@ -30,6 +30,77 @@ public async Task A_re_send_of_different_content_starts_at_its_first_change() await Assert.That(again.ScrollTop).IsEqualTo(26); } + /// + /// A re-run reporting the entry on screen from another line: an accept higher in the file + /// moved its call site. The queue takes the entry to that line rather than adding a second + /// one, so the key the selection was following has gone, and the entry it was following is + /// under the patch's. The reader stays on it rather than landing on the first in the list. + /// + [Test] + public async Task A_re_run_from_the_line_its_call_site_moved_to_stays_on_screen() + { + var state = Fixtures.Inline(Site(10, "First", "one"), Site(30, "Second", "two")); + state = ViewerSession.Apply(state, CommandKind.NextItem); + await Assert.That(state.Current!.Key).IsEqualTo(InlineKey.For("A.cs", 30)); + + var again = ViewerSession.EnqueueInline(state, Site(36, "Second", "two, as it is now")); + + await Assert.That(again.Queue.Count).IsEqualTo(2); + await Assert.That(again.Current!.Key).IsEqualTo(InlineKey.For("A.cs", 36)); + await Assert.That(again.Current.LeftText).IsEqualTo("two, as it is now"); + } + + /// + /// The same re-run arriving at a line another test's entry is still under. A queue holds one + /// entry to a key, so the entry on screen keeps the key it had and takes the content, and the + /// other test's entry is left as it was. + /// + [Test] + public async Task A_re_run_onto_the_line_of_another_entry_updates_the_one_on_screen() + { + var state = Fixtures.Inline(Site(20, "First", "one"), Site(30, "Second", "two")); + await Assert.That(state.Current!.Key).IsEqualTo(InlineKey.For("A.cs", 20)); + + var again = ViewerSession.EnqueueInline(state, Site(30, "First", "one, as it is now")); + + await Assert.That(again.Queue.Select(_ => $"{_.Key} {_.LeftText}")).IsEquivalentTo( + [ + $"{InlineKey.For("A.cs", 20)} one, as it is now", + $"{InlineKey.For("A.cs", 30)} two" + ]); + await Assert.That(again.Current!.Key).IsEqualTo(InlineKey.For("A.cs", 20)); + } + + /// + /// The same move seen from a window displaying somebody else's queue. The first entry was + /// accepted and the owner took the other two to the lines the re-run reported, so the key the + /// selection was following is in no listing any more. Held by its index instead, the reader + /// was put on the entry after the one they had been reading. + /// + [Test] + public async Task An_owner_taking_the_entry_on_screen_to_another_line_keeps_it_on_screen() + { + var pending = Fixtures.Pending(Site(10, "First", "one"), Site(20, "Second", "two"), Site(30, "Third", "three")); + var state = ViewerSession.Apply(Fixtures.Attached(pending), CommandKind.NextItem); + await Assert.That(state.Current!.Key).IsEqualTo(InlineKey.For("A.cs", 20)); + + var moved = pending + .Accept(InlineKey.For("A.cs", 10), _ => InlineApplyResult.Applied, out _) + .Enqueue(Site(26, "Second", "two")) + .Enqueue(Site(36, "Third", "three")); + var synced = ViewerSession.Sync(state, moved, [], null); + + await Assert.That(synced.Queue.Count).IsEqualTo(2); + await Assert.That(synced.Current!.Key).IsEqualTo(InlineKey.For("A.cs", 26)); + } + + static InlinePatch Site(int line, string member, string content) => + new("A.cs", line, "\"old\"", content) + { + TestName = $"Tests.{member}", + MemberName = member + }; + /// /// Scrolled away from where the entry opened, so neither staying put nor starting again can /// pass by coincidence. @@ -45,6 +116,145 @@ static SessionState Scrolled() return state; } + /// + /// The same for a pair of files. A test that keeps failing the same way sends its pair on every + /// run, built again from files that hold what they held, and each one used to open the entry + /// afresh: back at its first change, with the menu closed. + /// + [Test] + public async Task An_identical_pair_arriving_again_leaves_the_reader_alone() + { + var state = ViewerSession.OpenMenu(ScrolledPair(), 0); + await Assert.That(state.Menu).IsNotNull(); + + var again = ViewerSession.EnqueueTracked(state, Pair(Fixtures.Deep(true), written: 2)); + + await Assert.That(again.ScrollTop).IsEqualTo(state.ScrollTop); + await Assert.That(again.Menu).IsSameReferenceAs(state.Menu); + await Assert.That(again.Queue).HasSingleItem(); + // The run rewrote the file, and the entry has to say so or the watch reads it a third time + await Assert.That(again.Queue[0].LeftStamp).IsEqualTo(new FileStamp(2, 1)); + } + + [Test] + public async Task A_pair_arriving_again_with_different_content_starts_at_its_first_change() + { + var state = ViewerSession.OpenMenu(ScrolledPair(), 0); + + var again = ViewerSession.EnqueueTracked(state, Pair($"{Fixtures.Deep(true)}\nand one more line", written: 2)); + + await Assert.That(again.ScrollTop).IsEqualTo(26); + // Opened over the entry that was there, which this one is not + await Assert.That(again.Menu).IsNull(); + } + + /// + /// Kept as it reads, but not as the object it was. applies what a + /// pass found by reference, and a pass that looked while the run had cleared its received file + /// found it gone: applied to the pair the run then staged again, that dropped an entry whose + /// file was there. + /// + [Test] + public async Task A_pass_that_found_the_file_gone_does_not_take_the_pair_staged_again() + { + var state = ScrolledPair(); + var seen = state.Queue[0]; + + var again = ViewerSession.EnqueueTracked(state, Pair(Fixtures.Deep(true), written: 2)); + var refreshed = ViewerSession.Refresh(again, [seen], []); + + await Assert.That(refreshed.Queue).HasSingleItem(); + await Assert.That(refreshed.ScrollTop).IsEqualTo(state.ScrollTop); + } + + /// + /// A document's text is read after it arrives, so a pair arriving again has less in it than the + /// entry already read. That entry stays, on the page and at the zoom it was being looked at. + /// + [Test] + public async Task A_document_arriving_again_keeps_its_text_its_page_and_its_zoom() + { + var state = ViewerSession.EnqueueTracked( + SessionState.Start(ViewerMode.Inline, 210, Fixtures.Rows), + Document(read: true)); + state = DocumentScreenTests.Drawn(state); + state = ViewerSession.Apply(state, CommandKind.NextPage); + state = ViewerSession.Apply(state, CommandKind.ZoomIn); + await Assert.That(state.Page).IsEqualTo(2); + await Assert.That(state.Zoom).IsEqualTo(1); + + var again = ViewerSession.EnqueueTracked(state, Document(read: false)); + + await Assert.That(again.Current!.HasText).IsTrue(); + await Assert.That(again.Current.LeftText).IsEqualTo(DocumentScreenTests.LeftText); + await Assert.That(again.Page).IsEqualTo(2); + await Assert.That(again.Zoom).IsEqualTo(1); + } + + /// + /// The same file holding other bytes is another document, however far along the old one was. + /// + [Test] + public async Task A_document_rewritten_with_other_bytes_is_opened_afresh() + { + var state = ViewerSession.EnqueueTracked( + SessionState.Start(ViewerMode.Inline, 210, Fixtures.Rows), + Document(read: true)); + state = ViewerSession.Apply(DocumentScreenTests.Drawn(state), CommandKind.NextPage); + + var again = ViewerSession.EnqueueTracked(state, Document(read: false, leftHash: "CC")); + + await Assert.That(again.Current!.HasText).IsFalse(); + await Assert.That(again.Page).IsNull(); + } + + /// + /// A tracked pair of sixty lines, scrolled away from where it opened, as + /// is for a patch. + /// + static SessionState ScrolledPair() + { + var opened = ViewerSession.EnqueueTracked( + SessionState.Start(ViewerMode.Inline, Fixtures.Columns, Fixtures.Rows), + Pair(Fixtures.Deep(true), written: 1)); + var state = ViewerSession.Apply(opened, CommandKind.PageDown); + if (opened.ScrollTop != 26 || + state.ScrollTop == 26) + { + throw new("The entry did not open at its first change and then scroll, so nothing below asserts anything."); + } + + return state; + } + + static QueueEntry Pair(string received, long written) => + QueueEntry.ForMove( + "move:temp/sample.received.txt", + "Sample.Test (txt)", + null, + "temp/sample.received.txt", + "code/sample.verified.txt", + new(received, new FileStamp(written, 1), null, null), + new(Fixtures.Deep(false), new FileStamp(1, 1), null, null)); + + /// + /// A PDF pair as it arrives, hashed and with its text still to read, or as it is once + /// has read it. + /// + static QueueEntry Document(bool read, string leftHash = "AA") + { + var left = DocumentScreenTests.Left with { Hash = leftHash, Reading = !read }; + var right = DocumentScreenTests.Right with { Reading = !read }; + return QueueEntry.ForMove( + "move:temp/sample.received.pdf", + "Sample.Test (pdf)", + null, + "temp/sample.received.pdf", + "code/sample.verified.pdf", + new(read ? DocumentScreenTests.LeftText : "", new FileStamp(1, 1), null, null, left), + new(read ? DocumentScreenTests.RightText : "", new FileStamp(1, 1), null, null, right)); + } + static InlinePatch Patch(string content) => Fixtures.Patch("A.cs", 1, Fixtures.Literal(Fixtures.Deep(false)), content); diff --git a/src/DiffEngineViewer.Tests/SameFileBatchTests.cs b/src/DiffEngineViewer.Tests/SameFileBatchTests.cs new file mode 100644 index 000000000..66868f5b1 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SameFileBatchTests.cs @@ -0,0 +1,213 @@ +/// +/// A batch claims the snapshots of one source file together and has them written with one read and +/// one write. Applied one at a time each rewrote the whole file, and the rewrite is what costs: +/// five hundred snapshots in one file were half a minute where the drive is scanned. Each still +/// has an outcome of its own, and everything a single claim guards against is guarded for each. +/// +public class SameFileBatchTests +{ + [Test] + public async Task TheSnapshotsOfOneFileAreWrittenTogether() + { + var together = new List(); + var alone = new List(); + var actions = Recording(together, alone, _ => InlineApplyResult.Applied); + var state = ViewerSession.BeginAcceptAll(TwoFiles()); + var seen = new List(); + + while ((state = ViewerSession.ClaimNext(state)).Batch?.Current is not null) + { + seen.Add($"{ScreenBuilder.Build(state).Status}, {state.Queue.Count} pending"); + state = ViewerSession.ApplyClaimed(state, actions)(state); + } + + await Assert.That(string.Join("\n", seen)).IsEqualTo( + """ + Accepting 1 of 4, 4 pending + Accepting 4 of 4, 1 pending + """); + await Assert.That(together).IsEquivalentTo(["SampleTests.cs:42 SampleTests.cs:88 SampleTests.cs:90"]); + await Assert.That(alone).IsEquivalentTo(["OtherTests.cs:12"]); + await Assert.That(state.Message).IsEqualTo("Accepted 4"); + await Assert.That(state.Queue).IsEmpty(); + } + + /// + /// One write, and still an outcome each: the one that was written leaves, and the two that + /// were not stay, each saying what the applier said of it. + /// + [Test] + public async Task EachOfThemHasItsOwnOutcome() + { + var actions = Recording( + [], + [], + _ => _.LineHint switch + { + 88 => InlineApplyResult.Failed("the file is held"), + 90 => InlineApplyResult.NotFound("no Verify call"), + _ => InlineApplyResult.Applied + }); + + var state = ViewerSession.Apply(TwoFiles(), CommandKind.AcceptAll, actions); + + await Assert.That(state.Queue.Select(_ => _.Name)).IsEquivalentTo(["SampleTests.cs:88", "SampleTests.cs:90"]); + await Assert.That(state.Queue.Single(_ => _.Name == "SampleTests.cs:88").Status).IsEqualTo("the file is held"); + await Assert.That(state.Queue.Single(_ => _.Name == "SampleTests.cs:90").Status!).Contains("no Verify call"); + await Assert.That(state.Message).IsEqualTo("Accepted 2, 1 not written, 1 failed"); + } + + /// + /// A re-run that replaces one of them while the file is being written is news about that one + /// alone. It keeps the new content and is not counted, and the others are recorded as written. + /// + [Test] + public async Task OneReplacedWhileTheyApplyKeepsItsNewContent() + { + var state = ViewerSession.ClaimNext(ViewerSession.BeginAcceptAll(TwoFiles())); + var record = ViewerSession.ApplyClaimed(state, Fixtures.Applied); + + state = ViewerSession.EnqueueInline(state, Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "third run")); + state = record(state); + + await Assert.That(state.Queue.Select(_ => _.Name)).IsEquivalentTo(["SampleTests.cs:88", "OtherTests.cs:12"]); + await Assert.That(state.Queue.Single(_ => _.Name == "SampleTests.cs:88").LeftText).IsEqualTo("third run"); + await Assert.That(state.Batch!.Tally.Accepted).IsEqualTo(2); + } + + /// + /// One that a second framework has made a conflict of is not taken with the others, as it is + /// not claimed on its own: a bulk accept never picks a side. + /// + [Test] + public async Task AConflictInTheFileIsNotClaimedWithTheRest() + { + var state = ViewerSession.EnqueueInline( + Fixtures.Inline( + Fixtures.Patch("SampleTests.cs", 42, framework: "net8.0"), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two", framework: "net8.0"), + Fixtures.Patch("SampleTests.cs", 90, "\"three\"", "four", framework: "net8.0")), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "nine", framework: "net9.0")); + + var claimed = ViewerSession.ClaimNext(ViewerSession.BeginAcceptAll(state)); + + await Assert.That(claimed.Batch!.Current!.Name).IsEqualTo("SampleTests.cs:42"); + await Assert.That(claimed.Batch.Together.Select(_ => _.Name)).IsEquivalentTo(["SampleTests.cs:90"]); + + var done = ViewerSession.Apply(state, CommandKind.AcceptAll, Fixtures.Applied); + await Assert.That(done.Message).IsEqualTo("Accepted 2, 1 conflict needs review"); + } + + /// + /// A group's batch is some of the queue, so what it claims with a snapshot is the rest of that + /// file within the group. A snapshot of the same file under another test is not the group's. + /// + [Test] + public async Task AGroupTakesOnlyItsOwnSnapshotsOfTheFile() + { + var state = Fixtures.Inline( + Fixtures.Patch("SampleTests.cs", 42, testName: "First"), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two", testName: "First"), + Fixtures.Patch("SampleTests.cs", 90, "\"three\"", "four", testName: "Second"), + Fixtures.Patch("SampleTests.cs", 95, "\"five\"", "six", testName: "Second")); + var rows = QueueProjection.Visible(state, ScreenBuilder.BodyRows(state), out _).ToList(); + var open = ViewerSession.OpenMenu(state, rows.FindIndex(_ => _.GroupName == "First")); + + var claimed = ViewerSession.ClaimNext(ViewerSession.BeginAcceptGroup(open)); + + await Assert.That(claimed.Batch!.Current!.Name).IsEqualTo("SampleTests.cs:42"); + await Assert.That(claimed.Batch.Together.Select(_ => _.Name)).IsEquivalentTo(["SampleTests.cs:88"]); + + var done = ViewerSession.Apply(open, CommandKind.AcceptGroup, Fixtures.Applied); + await Assert.That(done.Queue.Select(_ => _.Name)).IsEquivalentTo(["SampleTests.cs:90", "SampleTests.cs:95"]); + await Assert.That(done.Message).IsEqualTo("Accepted 2"); + } + + /// + /// An applier that throws for a claim of several fails all of them, since which were written + /// is not known, and the batch still finishes. + /// + [Test] + public async Task AnApplierThatThrowsFailsEveryOneItWasHanded() + { + var host = new SessionHost(TwoFiles()); + var actions = Fixtures.Applied with + { + ApplyInlineTogether = _ => throw new("the disk went away") + }; + host.Mutate(ViewerSession.BeginAcceptAll); + + var message = new AcceptAllRunner(host, actions).Drive(); + + await Assert.That(message).IsEqualTo("Accepted 1, 3 failed"); + await Assert.That(host.State.Batch).IsNull(); + await Assert.That(host.State.Queue.Count).IsEqualTo(3); + await Assert.That(host.State.Queue.All(_ => _.Status == "the disk went away")).IsTrue(); + } + + /// + /// What a viewer applies with: several snapshots in one real file, one write. + /// + [Test] + public async Task TheRealApplierWritesTheFileOnce() + { + var directory = Path.Combine(Path.GetTempPath(), $"SameFileBatchTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var source = Path.Combine(directory, "SampleTests.cs"); + await File.WriteAllTextAsync( + source, + """ + class C + { + void One() => Verify(value).Snapshot("a"); + void Two() => Verify(value).Snapshot("b"); + void Three() => Verify(value).Snapshot("c"); + } + """); + var state = Fixtures.Inline( + Fixtures.Patch(source, 3, "\"a\"", "one"), + Fixtures.Patch(source, 4, "\"b\"", "two"), + Fixtures.Patch(source, 5, "\"c\"", "three")); + + var done = ViewerSession.Apply(state, CommandKind.AcceptAll, ViewerActions.Real); + + await Assert.That(done.Message).IsEqualTo("Accepted 3"); + var written = await File.ReadAllTextAsync(source); + await Assert.That(written).Contains("Snapshot(\"one\")"); + await Assert.That(written).Contains("Snapshot(\"two\")"); + await Assert.That(written).Contains("Snapshot(\"three\")"); + } + finally + { + Directory.Delete(directory, true); + } + } + + // Three snapshots in one file and one in another. + static SessionState TwoFiles() => + Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), + Fixtures.Patch("SampleTests.cs", 90, "\"three\"", "four"), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new")); + + static ViewerActions Recording(List together, List alone, Func outcome) => + Fixtures.Applied with + { + ApplyInline = _ => + { + alone.Add(Name(_)); + return outcome(_); + }, + ApplyInlineTogether = _ => + { + together.Add(string.Join(" ", _.Select(Name))); + return _.Select(outcome).ToList(); + } + }; + + static string Name(InlinePatch patch) => + $"{Path.GetFileName(patch.SourceFile)}:{patch.LineHint}"; +} diff --git a/src/DiffEngineViewer.Tests/ScreenCacheTests.cs b/src/DiffEngineViewer.Tests/ScreenCacheTests.cs new file mode 100644 index 000000000..550d7640f --- /dev/null +++ b/src/DiffEngineViewer.Tests/ScreenCacheTests.cs @@ -0,0 +1,134 @@ +/// +/// A screen is built when the state is another one, and not otherwise. The loop presents sixty +/// times a second, so anything built per frame is built for a window nobody is touching. +/// +public class ScreenCacheTests +{ + [Test] + public async Task The_same_state_is_the_same_screen() + { + var cache = new ScreenCache(); + var state = Scrollable(); + + var first = cache.For(state); + + await Assert.That(ReferenceEquals(cache.For(state), first)).IsTrue(); + await Assert.That(ReferenceEquals(cache.For(state), first)).IsTrue(); + } + + [Test] + public async Task Another_state_is_another_screen() + { + var cache = new ScreenCache(); + var state = Scrollable(); + var first = cache.For(state); + + var scrolled = cache.For(ViewerSession.Apply(state, CommandKind.ScrollDown)); + + await Assert.That(ReferenceEquals(scrolled, first)).IsFalse(); + await Assert.That(scrolled.Left.ScrollTop).IsEqualTo(first.Left.ScrollTop + 1); + } + + /// + /// Only the state last asked about is kept, so going back to an earlier one builds it again, + /// and what comes back says the same thing. + /// + [Test] + public async Task A_state_come_back_to_is_built_again_and_says_the_same() + { + var cache = new ScreenCache(); + var state = Scrollable(); + var first = cache.For(state); + cache.For(ViewerSession.Apply(state, CommandKind.ScrollDown)); + + var again = cache.For(state); + + await Assert.That(AsciiRenderer.Render(again)).IsEqualTo(AsciiRenderer.Render(first)); + } + + /// + /// The loop itself: five frames with nothing in any of them are five presents of one screen. + /// + [Test] + public async Task Frames_with_nothing_in_them_are_handed_the_screen_of_the_frame_before() + { + var window = new Recording(5, _ => Idle); + + ViewerProgram.Run(new(Scrollable()), server: null, link: null, window.Open); + + await Assert.That(window.Presented.Count).IsEqualTo(5); + await Assert.That(window.Presented.All(_ => ReferenceEquals(_, window.Presented[0]))).IsTrue(); + } + + /// + /// And a frame something happened in is followed by another screen, which then stands until + /// something else does. + /// + [Test] + public async Task A_frame_something_happened_in_is_followed_by_another_screen() + { + var window = new Recording(4, _ => _ == 1 ? Idle with { Key = CommandKind.ScrollDown } : Idle); + + ViewerProgram.Run(new(Scrollable()), server: null, link: null, window.Open); + + var presented = window.Presented; + await Assert.That(presented.Count).IsEqualTo(4); + await Assert.That(ReferenceEquals(presented[1], presented[0])).IsFalse(); + await Assert.That(presented[1].Left.ScrollTop).IsEqualTo(presented[0].Left.ScrollTop + 1); + await Assert.That(ReferenceEquals(presented[2], presented[1])).IsTrue(); + await Assert.That(ReferenceEquals(presented[3], presented[1])).IsTrue(); + } + + /// + /// Forty lines in a body of sixteen rows, opened at its first change on line 3, so there is + /// somewhere to scroll to. + /// + static SessionState Scrollable() => + Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + + static readonly ViewerInput Idle = new(CommandKind.None, -1, -1, 0, false, Fixtures.Columns, Fixtures.Rows); + + /// + /// Keeps every screen it is handed, and closes after a number of frames. + /// + /// How many presents before the window says it has closed. + /// What the poll after each present reports, by how many there have been. + sealed class Recording(int frames, Func input) : IViewerWindow + { + public List Presented { get; } = []; + + public IViewerWindow Open(string title, int width, int height, bool hidden, WindowPlacement? placement, out string? error) + { + error = null; + return this; + } + + public bool Present(Screen screen) + { + Presented.Add(screen); + return Presented.Count < frames; + } + + public ViewerInput Poll() => + input(Presented.Count); + + public void SetHidden(bool hidden) + { + } + + public void Focus() + { + } + + public void SetClipboard(string text) + { + } + + public bool Capture(Screen screen, int width, int height, string pngPath) => + false; + + public void Dispose() + { + } + } +} diff --git a/src/DiffEngineViewer.Tests/ScreenPayloadTests.cs b/src/DiffEngineViewer.Tests/ScreenPayloadTests.cs new file mode 100644 index 000000000..b4993a6e1 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ScreenPayloadTests.cs @@ -0,0 +1,102 @@ +/// +/// What the macOS and Linux heads are handed: a screen as flat buffers. Nothing on Windows reads +/// them, so these read them the way a shim does, by offset and length. +/// +public class ScreenPayloadTests +{ + /// + /// A row is written once, as UTF-8, and its segments are byte ranges of that. Each range has + /// to be the bytes of the characters the segment is, whatever came before it in the row: two + /// cell characters, characters outside the BMP, marks that attach to the one before, and a + /// surrogate with no partner, which is written as the three bytes of a replacement character. + /// + [Test] + public async Task ASegmentIsTheBytesOfItsOwnCharacters() + { + string[] lines = + [ + "plain ascii", + "漢字 then ascii then 漢字", + "tab\tseparated\t漢", + "thumb \U0001F44D\U0001F3FD family \U0001F468‍\U0001F469‍\U0001F467 done", + "é and ─│┼ and → ←", + "lone \uD800 surrogate 漢", + "漢" + ]; + var screen = ScreenBuilder.Build(Fixtures.File(string.Join("\n", lines), "other")); + var payload = new ScreenPayload(); + + payload.Build(screen); + + var checkedSegments = 0; + for (var index = 0; index < screen.Left.Rows.Count; index++) + { + var text = RowText.Clip(RowText.Flatten(screen.Left.Rows[index].Text), screen.Columns); + var row = payload.Rows[index]; + var segments = CellGrid.Segments(text); + await Assert.That(row.SegmentCount).IsEqualTo(segments.Count); + for (var position = 0; position < segments.Count; position++) + { + var segment = segments[position]; + var encoded = payload.Segments[row.SegmentOffset + position]; + var bytes = Convert.ToHexString(payload.Strings.Slice(encoded.TextOffset, encoded.TextLength)); + var expected = Convert.ToHexString(Encoding.UTF8.GetBytes(text.Substring(segment.Start, segment.Length))); + await Assert.That(bytes).IsEqualTo(expected); + await Assert.That(encoded.Column).IsEqualTo(segment.Column); + // Inside the row's own bytes, which is the only text a shim may read for it + await Assert.That(encoded.TextOffset).IsGreaterThanOrEqualTo(row.TextOffset); + await Assert.That(encoded.TextOffset + encoded.TextLength).IsLessThanOrEqualTo(row.TextOffset + row.TextLength); + checkedSegments++; + } + } + + // Most of these rows are several segments each; a screen that came out as one a row would + // have checked nothing. + await Assert.That(checkedSegments).IsGreaterThan(15); + } + + /// + /// The loop hands over the screen it handed over last frame for as long as nothing happens, + /// and encoding that one again was most of what an idle frame cost these heads. Seen by what + /// it allocates: an encode cuts every row that is not plain text into a list of segments, and + /// a screen already held is not looked at. + /// + [Test] + public async Task TheScreenAlreadyHeldIsNotEncodedAgain() + { + var rows = string.Join("\n", Enumerable.Range(0, 40).Select(_ => $"漢字 {_} 漢字")); + var state = Fixtures.File(rows, "other"); + var screen = ScreenBuilder.Build(state); + var another = ScreenBuilder.Build(state); + var payload = new ScreenPayload(); + payload.Build(screen); + + var before = GC.GetAllocatedBytesForCurrentThread(); + payload.Build(screen); + var held = GC.GetAllocatedBytesForCurrentThread() - before; + + before = GC.GetAllocatedBytesForCurrentThread(); + payload.Build(another); + var encoded = GC.GetAllocatedBytesForCurrentThread() - before; + + await Assert.That(held).IsEqualTo(0); + await Assert.That(encoded).IsGreaterThan(0); + } + + /// + /// And a screen it has not been handed before is encoded, even one that says what the last + /// did: the buffers after it describe that screen. + /// + [Test] + public async Task AnotherScreenReplacesWhatWasHeld() + { + var payload = new ScreenPayload(); + payload.Build(ScreenBuilder.Build(Fixtures.File("one\ntwo\nthree", "other"))); + await Assert.That(payload.Rows.Count).IsGreaterThanOrEqualTo(6); + + payload.Build(ScreenBuilder.Build(Fixtures.File("one", "one"))); + + // One row a side + await Assert.That(payload.Rows.Count).IsEqualTo(2); + } +} diff --git a/src/DiffEngineViewer.Tests/SelectionTests.cs b/src/DiffEngineViewer.Tests/SelectionTests.cs index 8d02e1cc7..9751c6e15 100644 --- a/src/DiffEngineViewer.Tests/SelectionTests.cs +++ b/src/DiffEngineViewer.Tests/SelectionTests.cs @@ -445,6 +445,77 @@ public async Task Select_all_ends_on_the_last_cell() await Assert.That(Copy(state)).IsEqualTo("\U0001D400ab"); } + /// + /// The status line counts a selection without building its text, and counts the rows a + /// selection takes whole without measuring them against its columns. What it says has to be + /// what copying hands over all the same: tabs as the four cells they are drawn as, a wide + /// character and a character with its marks as the characters they are, filler rows left out. + /// + [Test] + public void The_summary_counts_what_copying_hands_over() + { + string[] pieces = ["a", "bc", " ", "\t", "漢", "é", "\U0001F44D", "\uD800", "─", "\r"]; + var random = new Random(8); + for (var iteration = 0; iteration < 2000; iteration++) + { + var left = Lines(random, pieces, random.Next(1, 9)); + // Lines of its own on the other side, so the side selected in has filler rows in it + var right = Lines(random, pieces, random.Next(1, 9)); + var state = Files(string.Join("\n", left), string.Join("\n", right)); + var rows = state.Current!.LeftRows.Count; + state = Drag( + state, + PaneSide.Left, + random.Next(rows), + random.Next(12), + random.Next(rows), + random.Next(12)); + var status = ScreenBuilder.Build(state).Status; + if (state.LiveSelection is not { IsEmpty: false } selection) + { + continue; + } + + var copied = SelectionText.Of(selection, state.Current!); + var expected = Summary(copied); + if (SelectionText.Summary(selection, state.Current!) != expected || + !status.Contains(expected)) + { + Assert.Fail($"left: {string.Join("|", left)} right: {string.Join("|", right)} selection: {selection} status: {status} expected: {expected}"); + } + } + } + + static string Summary(string copied) + { + if (copied.Length == 0) + { + return "nothing selected"; + } + + var lines = copied.Count(_ => _ == '\n') + 1; + var length = copied.Count(_ => !char.IsLowSurrogate(_)); + var characters = $"{length} character{(length == 1 ? "" : "s")}"; + return lines == 1 ? $"selected {characters}" : $"selected {lines} lines, {characters}"; + } + + static List Lines(Random random, string[] pieces, int count) + { + var lines = new List(); + for (var line = 0; line < count; line++) + { + var builder = new StringBuilder(); + for (var piece = random.Next(0, 6); piece > 0; piece--) + { + builder.Append(pieces[random.Next(pieces.Length)]); + } + + lines.Add(builder.ToString()); + } + + return lines; + } + /// /// What ctrl+c puts on the clipboard, or null when it puts nothing there. /// diff --git a/src/DiffEngineViewer.Tests/SkipOnMacAttribute.cs b/src/DiffEngineViewer.Tests/SkipOnMacAttribute.cs index a85e484a1..8a6799931 100644 --- a/src/DiffEngineViewer.Tests/SkipOnMacAttribute.cs +++ b/src/DiffEngineViewer.Tests/SkipOnMacAttribute.cs @@ -1,9 +1,13 @@ /// /// Skips a test on macOS, with the reason given at the use site. /// -/// Both uses so far come down to deview_capture making no window on macOS: what that head -/// shows is drawn by AppKit into its window, and it waits for the next frame in that window's -/// event pump. +/// Most uses come down to deview_capture making no window on macOS: what that head shows is +/// drawn by AppKit into its window, and it waits for the next frame in that window's event pump. +/// +/// +/// The rest are scenes that pin something each head does in its own way, and so far have a +/// baseline for the Linux head alone. A macOS one has to be taken on the pinned runner the others +/// come from, and the skip goes when it has been. /// /// public sealed class SkipOnMacAttribute(string reason) : SkipAttribute(reason) diff --git a/src/DiffEngineViewer.Tests/TrackedFileTests.cs b/src/DiffEngineViewer.Tests/TrackedFileTests.cs index e061fc911..a1d155dc0 100644 --- a/src/DiffEngineViewer.Tests/TrackedFileTests.cs +++ b/src/DiffEngineViewer.Tests/TrackedFileTests.cs @@ -330,9 +330,8 @@ public async Task ARefusedPatchStillLetsAMoveThrough() } /// - /// The same guard on the group sweep. It reaches it differently: a group accept takes its - /// entries one at a time, so a refused patch leaves the queue rather than staying in it with a - /// status, and the sweep cannot read the outcome off the queue it is handed. + /// The same guard on a group's accept, which is the same batch over fewer entries: its deletes + /// wait on its own snapshots, counted from the attempts it made. /// [Test] public async Task ARefusedPatchHoldsTheDeleteInAGroupSweepToo() diff --git a/src/DiffEngineViewer.Tests/TrackedWatchTests.cs b/src/DiffEngineViewer.Tests/TrackedWatchTests.cs index 1f2735143..d276c7c83 100644 --- a/src/DiffEngineViewer.Tests/TrackedWatchTests.cs +++ b/src/DiffEngineViewer.Tests/TrackedWatchTests.cs @@ -214,6 +214,110 @@ public async Task AnUnreadableFileIsNotReReadEveryPass() } } + /// + /// A queue longer than a pass looks at is looked at in turn. A thousand pending pairs were two + /// thousand stats a pass, five passes a second, for as long as the viewer ran. + /// + [Test] + public async Task AQueueLongerThanAPassLooksAtIsLookedAtInTurn() + { + var host = OwnedAll(5); + var watch = new TrackedWatch(host) + { + Budget = 2 + }; + // The last in the queue, which a pass that starts at the front does not get to + File.Delete(host.State.Queue[^1].LeftFile!); + + watch.Pump(); + await Assert.That(host.State.Queue.Count).IsEqualTo(5); + + watch.Pump(); + await Assert.That(host.State.Queue.Count).IsEqualTo(4); + } + + /// + /// And one no longer than that is looked at whole every pass, as every queue used to be. + /// + [Test] + public async Task AQueueNoLongerThanAPassLooksAtIsLookedAtWhole() + { + var host = OwnedAll(5); + var watch = new TrackedWatch(host) + { + Budget = 4 + }; + File.Delete(host.State.Queue[^1].LeftFile!); + + watch.Pump(); + + await Assert.That(host.State.Queue.Count).IsEqualTo(4); + } + + /// + /// The entry on screen is the one being read, so it is looked at every pass, wherever the turn + /// has got to. + /// + [Test] + public async Task TheEntryOnScreenIsLookedAtEveryPass() + { + var host = OwnedAll(5); + var watch = new TrackedWatch(host) + { + Budget = 1 + }; + // The turn moves on past the first entry, which is the one on screen + watch.Pump(); + watch.Pump(); + await Assert.That(host.State.Selected).IsEqualTo(0); + await File.WriteAllTextAsync(host.State.Current!.LeftFile!, "rewritten by a later run"); + + watch.Pump(); + + await Assert.That(host.State.Current!.LeftText).IsEqualTo("rewritten by a later run"); + } + + /// + /// A snapshot has no file to look at, so it takes nothing from what a pass looks at: the pairs + /// behind a queue's snapshots are still all reached. + /// + [Test] + public async Task SnapshotsTakeNothingFromWhatAPassLooksAt() + { + // Two snapshots ahead of three pairs, and a pass that looks at three files + var state = Fixtures.Inline(Fixtures.Patch("OneTests.cs", 10), Fixtures.Patch("TwoTests.cs", 20)); + for (var index = 0; index < 3; index++) + { + var (temp, target) = Pair($"Sample{index}.Test"); + state = ViewerSession.EnqueueTracked(state, TrackedEntry.ForMove(temp, target)); + } + + var host = new SessionHost(state); + var watch = new TrackedWatch(host) + { + Budget = 3 + }; + await Assert.That(string.Join(" ", host.State.Queue.Select(_ => _.Kind))).IsEqualTo("Inline Inline Move Move Move"); + File.Delete(host.State.Queue[^1].LeftFile!); + + watch.Pump(); + + await Assert.That(host.State.Queue.Count(_ => _.Kind == QueueEntryKind.Move)).IsEqualTo(2); + await Assert.That(host.State.Queue.Count(_ => _.Kind == QueueEntryKind.Inline)).IsEqualTo(2); + } + + SessionHost OwnedAll(int count) + { + var state = SessionState.Start(ViewerMode.Inline, Fixtures.Columns, Fixtures.Rows); + for (var index = 0; index < count; index++) + { + var (temp, target) = Pair($"Sample{index}.Test"); + state = ViewerSession.EnqueueTracked(state, TrackedEntry.ForMove(temp, target)); + } + + return new(state); + } + static bool WaitUntilBlocked(Thread thread) { var watch = Stopwatch.StartNew(); diff --git a/src/DiffEngineViewer.Tests/ViewerPreferencesTests.cs b/src/DiffEngineViewer.Tests/ViewerPreferencesTests.cs index 069e3aec9..554283a97 100644 --- a/src/DiffEngineViewer.Tests/ViewerPreferencesTests.cs +++ b/src/DiffEngineViewer.Tests/ViewerPreferencesTests.cs @@ -96,6 +96,88 @@ public async Task AWriteKeepsWhatAnotherViewerWroteSince() await Assert.That(read.Window).IsEqualTo(new WindowPlacement(5, 6, 700, 500, false)); } + /// + /// A setting at its default is kept as no line at all, so putting one back takes a line out of + /// the file. The other viewer read that line when it started, and used to write it back with + /// the next thing it remembered, since the file no longer had a value of its own to keep. + /// + [Test] + public async Task ASettingAnotherViewerPutBackToItsDefaultStaysThere() + { + using var directory = new TempDirectory(); + var path = directory.File("viewer.settings"); + var before = new ViewerPreferences(path) + { + Projection = MapProjection.Goode, + Drawings = new Dictionary + { + [DocumentFormat.Pdf] = DrawingView.Picture + } + }; + await Assert.That(before.Projection).IsEqualTo(MapProjection.Goode); + var first = new ViewerPreferences(path); + + // A second viewer, started after the first, puts both back + var second = new ViewerPreferences(path) + { + Projection = MapProjection.Auto, + Drawings = new Dictionary() + }; + await Assert.That(second.Get("projection")).IsNull(); + first.Window = new(5, 6, 700, 500, false); + + var read = new ViewerPreferences(path); + await Assert.That(read.Projection).IsEqualTo(MapProjection.Auto); + await Assert.That(read.Drawings).IsEmpty(); + await Assert.That(read.Window).IsEqualTo(new WindowPlacement(5, 6, 700, 500, false)); + } + + /// + /// The viewer still showing the old setting is asked what it shows after every frame that did + /// something. That is not a change of its own, so it writes nothing: what it holds is its own + /// view, never brought up to date with a file it then looks different from. + /// + [Test] + public async Task SayingWhatIsOnScreenDoesNotUndoAnotherViewersChange() + { + using var directory = new TempDirectory(); + var path = directory.File("viewer.settings"); + new ViewerPreferences(path).Projection = MapProjection.Goode; + var first = new ViewerPreferences(path); + var state = first.Apply(SessionState.Start(ViewerMode.Inline)); + await Assert.That(state.Projection).IsEqualTo(MapProjection.Goode); + + new ViewerPreferences(path).Projection = MapProjection.Auto; + first.Window = new(5, 6, 700, 500, false); + first.Remember(state); + + await Assert.That(new ViewerPreferences(path).Projection).IsEqualTo(MapProjection.Auto); + await Assert.That(first.Projection).IsEqualTo(MapProjection.Goode); + } + + /// + /// A setting whose write failed is still this viewer's to write, and goes with the next one + /// that can be: here a directory was in the way of the file, then was not. + /// + [Test] + public async Task ASettingThatCouldNotBeWrittenGoesWithTheNextThatCan() + { + using var directory = new TempDirectory(); + var path = directory.File("viewer.settings"); + Directory.CreateDirectory(path); + var preferences = new ViewerPreferences(path) + { + Projection = MapProjection.Lambert + }; + Directory.Delete(path); + + preferences.Window = new(1, 2, 300, 400, false); + + var read = new ViewerPreferences(path); + await Assert.That(read.Projection).IsEqualTo(MapProjection.Lambert); + await Assert.That(read.Window).IsEqualTo(new WindowPlacement(1, 2, 300, 400, false)); + } + [Test] public async Task NullForgetsAKey() { diff --git a/src/DiffEngineViewer.Windows.Benchmarks/DiffEngineViewer.Windows.Benchmarks.csproj b/src/DiffEngineViewer.Windows.Benchmarks/DiffEngineViewer.Windows.Benchmarks.csproj new file mode 100644 index 000000000..5ab0fdeb8 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/DiffEngineViewer.Windows.Benchmarks.csproj @@ -0,0 +1,29 @@ + + + + Exe + + net10.0-windows + true + enable + false + + true + $(MSBuildThisFileDirectory)..\key.snk + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Windows.Benchmarks/Directory.Build.props b/src/DiffEngineViewer.Windows.Benchmarks/Directory.Build.props new file mode 100644 index 000000000..2164d998e --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/Directory.Build.props @@ -0,0 +1,15 @@ + + + + false + false + false + false + + diff --git a/src/DiffEngineViewer.Windows.Benchmarks/IdleFrameBenchmarks.cs b/src/DiffEngineViewer.Windows.Benchmarks/IdleFrameBenchmarks.cs new file mode 100644 index 000000000..662d5ec5e --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/IdleFrameBenchmarks.cs @@ -0,0 +1,56 @@ +using BenchmarkDotNet.Attributes; + +// What the form does with the screen it is handed on a frame in which nothing happened, which is +// nearly every frame: sixty a second for as long as the window is up. The form is never shown, so +// nothing is painted and nothing appears on the desktop: this is only the deciding that there is +// nothing to do. +[MemoryDiagnoser] +public class IdleFrameBenchmarks +{ + ViewerForm form = null!; + Screen first = null!; + Screen second = null!; + bool flip; + + [GlobalSetup] + public void Setup() + { + ViewerApp.ConfigureUnscaled(); + var directory = Path.Combine(Path.GetTempPath(), "deview-benchmarks", "idle"); + var state = SessionState.Start(ViewerMode.Inline, 120, 40); + for (var index = 0; index < 500; index++) + { + state = ViewerSession.EnqueueInline( + state, + new(Path.Combine(directory, $"Sample{index % 40}Tests.cs"), 10 + index, "\"old\"", $"new {index}") + { + TestName = $"Test{index}" + }); + } + + // Two screens that say the same thing, as two builds of one state do + first = ScreenBuilder.Build(state); + second = ScreenBuilder.Build(state); + form = new("benchmark", 1100, 700); + form.Apply(first); + } + + [GlobalCleanup] + public void Cleanup() => + form.Dispose(); + + // The screen the loop handed over last frame, which is what it hands over while the state is + // the one it was. + [Benchmark] + public void TheScreenOfTheFrameBefore() => + form.Apply(first); + + // A screen built again from the same state: equal in everything and another object, which is + // what every idle frame handed over while a screen was built per frame. + [Benchmark] + public void AnEqualScreenBuiltAgain() + { + flip = !flip; + form.Apply(flip ? second : first); + } +} diff --git a/src/DiffEngineViewer.Windows.Benchmarks/OffscreenCanvas.cs b/src/DiffEngineViewer.Windows.Benchmarks/OffscreenCanvas.cs new file mode 100644 index 000000000..29ad5c13d --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/OffscreenCanvas.cs @@ -0,0 +1,80 @@ +using System.ComponentModel; +using System.Drawing.Imaging; + +/// +/// The head's canvas with no window around it, painted into a bitmap: what a paint costs, with +/// nothing put on the desktop to find out. +/// +/// What runs is the canvas's own paint, handed a over the bitmap where a +/// window would hand it one over its double buffer. +/// +/// +sealed class OffscreenCanvas : IDisposable +{ + /// + /// Synchronous, as a capture is: there is no message loop here for a decode on the pool to be + /// handed back through, so a picture is decoded and composed by the first paint that needs it. + /// + readonly ViewerCanvas canvas = new() + { + Synchronous = true + }; + + readonly PaintCaller caller = new(); + + readonly Bitmap surface; + + public OffscreenCanvas(int width, int height) + { + canvas.Size = new(width, height); + // Premultiplied, which is what the double buffer a window paints into holds. + surface = new(width, height, PixelFormat.Format32bppPArgb); + } + + /// + /// What a window this size would be showing for : the grid the canvas + /// reports, as ViewerForm.Drain reports it to the loop, and the screen built for that. + /// + public Screen Fit(SessionState state) => + ScreenBuilder.Build( + ViewerSession.Resize( + state, + canvas.ColumnCapacity, + canvas.BodyCapacity + ScreenBuilder.Chrome)); + + /// + /// Hands the canvas a screen, as the loop does on every frame that changed something. + /// + public void Show(Screen screen) => + canvas.Draw(screen); + + /// + /// One paint of the whole canvas. A Graphics per paint, as a window's paint gets, so nothing + /// one paint set on it is still set for the next. + /// + public void Paint() + { + using var graphics = Graphics.FromImage(surface); + using var paint = new PaintEventArgs(graphics, new(Point.Empty, surface.Size)); + caller.Ask(canvas, paint); + } + + public void Dispose() + { + canvas.Dispose(); + caller.Dispose(); + surface.Dispose(); + } + + /// + /// A control, though never one with a handle, because OnPaint is protected and + /// is the door WinForms leaves for one control to ask + /// another to paint. + /// + [DesignerCategory("")] + sealed class PaintCaller : Control + { + public void Ask(Control control, PaintEventArgs paint) => + InvokePaint(control, paint); + } +} diff --git a/src/DiffEngineViewer.Windows.Benchmarks/PicturePaintBenchmarks.cs b/src/DiffEngineViewer.Windows.Benchmarks/PicturePaintBenchmarks.cs new file mode 100644 index 000000000..05cbfb09f --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/PicturePaintBenchmarks.cs @@ -0,0 +1,105 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using BenchmarkDotNet.Attributes; + +// One paint of the canvas over a pair of large pictures the reader has zoomed into, but not so +// far that they are drawn at their own size: more of each picture's pixels go into the pane than +// the pane has. What shows is the pane's worth of pixels however far in it is, so a paint should +// cost by the pane. Every frame of a drag across the picture is one such paint, and so is every +// wheel notch over the text of a document whose page is drawn under it. +[MemoryDiagnoser] +public class PicturePaintBenchmarks +{ + // How far in, as a percentage of the size that fits the pane. A 4000 by 3000 picture fits + // this window at about an eighth of its own size, so these draw it at a fifth, a quarter and + // a half: the first two take more than two of its pixels across for each one drawn, the last + // fewer. + [Params(150, 200, 400)] + public int Percent; + + // The window a viewer opens at. + const int width = 1100; + const int height = 700; + + const int pictureWidth = 4000; + const int pictureHeight = 3000; + + string directory = ""; + OffscreenCanvas canvas = null!; + Screen[] dragged = []; + int frame; + + [GlobalSetup] + public void Setup() + { + ViewerApp.ConfigureUnscaled(); + + directory = Directory.CreateTempSubdirectory("deview-benchmark-").FullName; + var received = Path.Combine(directory, "Sample.received.png"); + var verified = Path.Combine(directory, "Sample.verified.png"); + Draw(received, Color.FromArgb(198, 64, 64), Color.FromArgb(240, 220, 180)); + Draw(verified, Color.FromArgb(64, 150, 198), Color.FromArgb(240, 220, 180)); + + var entry = QueueEntry.ForFiles(received, verified, FileSide.Read(received), FileSide.Read(verified)); + var state = ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File), entry); + while (PictureZoom.Factor(state.Zoom) * 100 < Percent) + { + state = ViewerSession.Apply(state, CommandKind.ZoomIn); + } + + canvas = new(width, height); + // A drag from one corner of what can be shown towards the other, a frame at each step. + // Kept near the middle, so that at every one of these sizes each step moves the picture. + dragged = Enumerable.Range(0, 16) + .Select(_ => canvas.Fit(ViewerSession.PanTo(state, 0.35 + _ * 0.02, 0.45 + _ * 0.006))) + .ToArray(); + + canvas.Show(canvas.Fit(state)); + // Once here: the pictures are decoded by the first paint that needs them, and whatever a + // paint keeps of them from one frame to the next is kept before anything is timed. + canvas.Paint(); + } + + [GlobalCleanup] + public void Cleanup() + { + canvas.Dispose(); + Directory.Delete(directory, true); + } + + [Benchmark] + public void Still() => + canvas.Paint(); + + [Benchmark] + public void Dragged() + { + canvas.Show(dragged[frame++ % dragged.Length]); + canvas.Paint(); + } + + // A wash of colour under a grid of lines: nothing a resampler can skip, and edges that show + // what it did with them. + static void Draw(string path, Color from, Color to) + { + using var bitmap = new Bitmap(pictureWidth, pictureHeight, PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + var all = new Rectangle(0, 0, pictureWidth, pictureHeight); + using var wash = new LinearGradientBrush(all, from, to, 35f); + graphics.FillRectangle(wash, all); + using var line = new Pen(Color.FromArgb(40, 40, 40), 3); + for (var x = 0; x < pictureWidth; x += 80) + { + graphics.DrawLine(line, x, 0, x, pictureHeight); + } + + for (var y = 0; y < pictureHeight; y += 80) + { + graphics.DrawLine(line, 0, y, pictureWidth, y); + } + } + + bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png); + } +} diff --git a/src/DiffEngineViewer.Windows.Benchmarks/Program.cs b/src/DiffEngineViewer.Windows.Benchmarks/Program.cs new file mode 100644 index 000000000..78aea4c46 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/Program.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Running; +using BenchmarkDotNet.Toolchains.InProcess.Emit; + +// Run with: dotnet run -c Release --project src/DiffEngineViewer.Windows.Benchmarks -- --filter * +// Filter e.g.: dotnet run -c Release --project src/DiffEngineViewer.Windows.Benchmarks -- --filter *Paint* +// +// The WinForms head: what a paint costs. A project of its own because the head only builds for +// Windows, where DiffEngineViewer.Benchmarks measures the model on every platform. Everything is +// painted into a bitmap, so nothing here shows a window or binds a port. +// +// In process and a short run, for the reasons DiffEngine.Benchmarks' Program.cs gives. +var config = DefaultConfig.Instance + .AddJob( + Job.ShortRun + .WithToolchain(InProcessEmitToolchain.Instance)); +BenchmarkSwitcher + // Not typeof(Program), as the other two have it: the head is an executable with a Program of + // its own, which this assembly can see, so the name would be ambiguous here. + .FromAssembly(Assembly.GetExecutingAssembly()) + .Run(args, config); diff --git a/src/DiffEngineViewer.Windows.Benchmarks/RowPaintBenchmarks.cs b/src/DiffEngineViewer.Windows.Benchmarks/RowPaintBenchmarks.cs new file mode 100644 index 000000000..280e401ec --- /dev/null +++ b/src/DiffEngineViewer.Windows.Benchmarks/RowPaintBenchmarks.cs @@ -0,0 +1,102 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +// One paint of the canvas over rows far longer than a pane is wide. Nothing scrolls sideways, so +// all of a row past the pane's right edge is never seen, and a paint should cost what the part +// that shows costs. Every wheel notch and every frame of a selection drag is one such paint. +[MemoryDiagnoser] +public class RowPaintBenchmarks +{ + // The window a viewer opens at, where a pane shows 54 characters of a row, and one maximised + // across a wide display, at 1600 pixels a pane and 174 characters. + [Params(1100, 3212)] + public int Width; + + // Room for 36 rows a pane, so 72 rows a paint. + const int height = 800; + + const int megabyte = 1024 * 1024; + + OffscreenCanvas longLines = null!; + OffscreenCanvas megabyteLine = null!; + + [GlobalSetup] + public void Setup() + { + // As the tests' host is: at one scale whatever display this runs on, so the cell a pane + // is counted in is the same number of pixels on every machine. + ViewerApp.ConfigureUnscaled(); + + // 2,000 characters a line, which is more than a pane has pixels at either width, let + // alone cells. One line in ten differs, as the lines of a snapshot that failed do. + longLines = Showing( + Records(lines: 60, length: 2000, changeEvery: -1), + Records(lines: 60, length: 2000, changeEvery: 10)); + + // What a minified bundle or a serialized blob is: all one line. Half way along it, far + // past anything that shows, is one character from outside ASCII, which is all it takes + // for the row to stop being a single run of plain text. + var line = Line(megabyte, 0).Remove(megabyte / 2, 1).Insert(megabyte / 2, "中"); + megabyteLine = Showing($"before\n{line}\nafter", $"before\n{line}\nand after"); + } + + [GlobalCleanup] + public void Cleanup() + { + longLines.Dispose(); + megabyteLine.Dispose(); + } + + [Benchmark] + public void LongLines() => + longLines.Paint(); + + [Benchmark] + public void MegabyteLine() => + megabyteLine.Paint(); + + OffscreenCanvas Showing(string received, string expected) + { + var canvas = new OffscreenCanvas(Width, height); + var entry = QueueEntry.ForFiles( + "Sample.received.txt", + "Sample.verified.txt", + FileSide.OfText(received), + FileSide.OfText(expected)); + canvas.Show(canvas.Fit(ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File), entry))); + // Once here, so the font is measured and every brush made before anything is timed. + canvas.Paint(); + return canvas; + } + + // The records of a minified array, a line each: the shape of a snapshot whose lines are long. + static string Records(int lines, int length, int changeEvery) + { + var builder = new StringBuilder(); + for (var index = 0; index < lines; index++) + { + if (index > 0) + { + builder.Append('\n'); + } + + // A thousand apart, so no line is another's content and a changed one pairs with its own. + var changed = changeEvery > 0 && index % changeEvery == 0; + builder.Append(Line(length, index * 1000 + (changed ? 500 : 0))); + } + + return builder.ToString(); + } + + static string Line(int length, int first) + { + var builder = new StringBuilder(length + 64); + for (var item = first; builder.Length < length; item++) + { + builder.Append($"{{\"id\":{item},\"name\":\"item {item}\",\"tags\":[\"alpha\",\"beta\"],\"price\":{item % 90 + 10}.5}},"); + } + + builder.Length = length; + return builder.ToString(); + } +} diff --git a/src/DiffEngineViewer.Windows.Tests/EnlargedPictureTests.cs b/src/DiffEngineViewer.Windows.Tests/EnlargedPictureTests.cs new file mode 100644 index 000000000..f44dc949b --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/EnlargedPictureTests.cs @@ -0,0 +1,426 @@ +/// +/// A large picture zoomed into, through the real canvas. While it is drawn at half its own size or +/// less, what shows is copied out of the whole of it scaled to that size, which is made once and +/// kept. Further in than that it is drawn straight from the picture. +/// +/// The pictures are stripes of two colours, the left one's across and the right one's down, so +/// where a picture was drawn can be read back from where its stripes change, to the pixel and +/// whatever filter scaled it. One pair for the class: nothing here writes to them. +/// +/// +[NotInParallel] +[TUnit.Core.Executors.STAThreadExecutor] +public class EnlargedPictureTests +{ + const int columns = 120; + const int rows = 37; + + /// + /// Fits a pane of the default window at 15% of its size, so the six steps in go through all + /// three ways of drawing it: the first three are still under half its size, the next two are + /// between that and its own, and the sixth is past it. + /// + static readonly Size picture = new(3600, 2700); + + const int stripe = 300; + + static readonly Color dark = Color.FromArgb(40, 40, 160); + static readonly Color light = Color.FromArgb(230, 210, 80); + + static string directory = ""; + static string left = ""; + static string right = ""; + + [Before(Class)] + public static void WritePictures() + { + directory = Directory.CreateTempSubdirectory("deview-enlarged-").FullName; + left = Write("striped.received.png", across: true); + right = Write("striped.verified.png", across: false); + } + + [After(Class)] + public static void DeletePictures() => + Directory.Delete(directory, true); + + /// + /// Painted again and again, and dragged about between paints, a picture this far out is scaled + /// once a side. Where it has been dragged to is no part of what is kept, so a drag copies a + /// different part of the same thing rather than scaling the picture again for every frame. + /// + [Test] + public async Task ItIsScaledOnceHoweverItIsDragged() + { + using var host = new Host(); + host.Canvas.Synchronous = true; + var state = Zoomed(1); + + host.Draw(state); + host.Draw(state); + foreach (var centre in (double[]) [0.4, 0.45, 0.5, 0.55, 0.6]) + { + host.Draw(ViewerSession.PanTo(state, centre, 0.5)); + } + + await Assert.That(host.Composed).IsEqualTo(2); + } + + /// + /// What is kept is the whole picture at the size it is drawn, so no more than a quarter of the + /// picture's own pixels: at most half its width by half its height. + /// + [Test] + [Arguments(1)] + [Arguments(2)] + [Arguments(3)] + public async Task WhatIsKeptIsTheWholeOfItAtTheSizeItIsDrawn(int steps) + { + using var host = new Host(); + host.Canvas.Synchronous = true; + + host.Draw(Zoomed(steps)); + var placement = host.Placement(PaneSide.Left); + var kept = host.Kept(left); + + await Assert.That(kept).IsNotNull(); + await Assert.That(kept!.Value.Width).IsEqualTo((int) Math.Round(placement.Size.Width)); + await Assert.That(kept.Value.Height).IsEqualTo((int) Math.Round(placement.Size.Height)); + await Assert.That(kept.Value.Width * 2).IsLessThanOrEqualTo(picture.Width); + } + + /// + /// Past half its size the whole of it would be up to the picture's own size again, which is too + /// much to hold twice. Nothing is kept, and it is drawn straight from the picture. + /// + [Test] + [Arguments(4)] + [Arguments(5)] + [Arguments(6)] + public async Task PastHalfItsSizeNothingIsKept(int steps) + { + using var host = new Host(); + host.Canvas.Synchronous = true; + + var drawn = host.Draw(Zoomed(steps)); + + await Assert.That(host.Composed).IsEqualTo(0); + await Assert.That(host.Kept(left)).IsNull(); + await Assert.That(Misplaced(drawn, host.Placement(PaneSide.Left), across: true)).IsEmpty(); + } + + /// + /// However it is drawn, it is the part the placement says, where the placement says: every + /// change of stripe on screen is within a pixel and a half of where the picture's own stripes + /// fall. Fitted, at each step in, and dragged off centre, so through the copy, through the + /// filter that reduces and through the one that shows pixels as they are. + /// + [Test] + [Arguments(0, 0.5, 0.5)] + [Arguments(1, 0.5, 0.5)] + [Arguments(1, 0.37, 0.52)] + [Arguments(2, 0.5, 0.5)] + [Arguments(2, 0.31, 0.64)] + [Arguments(3, 0.5, 0.5)] + [Arguments(3, 0.71, 0.33)] + [Arguments(4, 0.5, 0.5)] + [Arguments(4, 0.29, 0.68)] + [Arguments(5, 0.62, 0.41)] + [Arguments(6, 0.5, 0.5)] + [Arguments(6, 0.13, 0.88)] + public async Task ItIsDrawnWhereItIsPlaced(int steps, double centreX, double centreY) + { + using var host = new Host(); + host.Canvas.Synchronous = true; + + var drawn = host.Draw(ViewerSession.PanTo(Zoomed(steps), centreX, centreY)); + + await Assert.That(Misplaced(drawn, host.Placement(PaneSide.Left), across: true)).IsEmpty(); + await Assert.That(Misplaced(drawn, host.Placement(PaneSide.Right), across: false)).IsEmpty(); + } + + /// + /// A drag moves the picture a pixel for each pixel the pointer moves, with the copy as it does + /// without it. The part that shows starts between two pixels of the copy as often as on one: + /// in a pane an odd number of pixels wide a picture centred in it starts exactly half way, + /// and stays half way for the whole of a drag. Rounded on that line, each frame falls whichever + /// way the arithmetic's last digit sends it, and the picture stands still for one pixel of the + /// drag and jumps two for the next. + /// + [Test] + [Arguments(1100)] + [Arguments(1102)] + public async Task ADragMovesItAPixelForEachPixelDragged(int width) + { + using var host = new Host(width); + host.Canvas.Synchronous = true; + var state = Zoomed(2); + var first = host.Draw(state); + var from = host.Placement(PaneSide.Left); + var start = Stripes(first, from.Bounds, across: true); + await Assert.That(start).IsNotEmpty(); + + var moves = new List(); + var last = start[start.Count / 2]; + for (var by = 1; by <= 40; by++) + { + // As the canvas reports a drag: from where the button went down, not from the last move + var centre = from.Dragged(new(by, 0)); + var drawn = host.Draw(ViewerSession.PanTo(state, centre.X, centre.Y)); + // The same change of stripe, which is the one nearest to where the last frame left it + var now = Stripes(drawn, from.Bounds, across: true).MinBy(_ => Math.Abs(_ - last - 1)); + moves.Add(now - last); + last = now; + } + + Console.WriteLine($"{width} wide, a pane's picture {from.Bounds.Width} wide: moved {string.Join(" ", moves)}"); + await Assert.That(moves.Distinct()).IsEquivalentTo([1]); + } + + /// + /// In a window, where a picture is decoded and scaled on the pool: no paint scales it itself. + /// Each one draws whatever there is to draw meanwhile, and the copy arrives through the message + /// loop, after which nothing is left turning and the picture is where it belongs. + /// + [Test] + public async Task InAWindowItIsScaledOffTheThreadThatPaints() + { + using var host = new Host(); + var state = Zoomed(1); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (host.Composed < 2 && + DateTime.UtcNow < deadline) + { + var before = host.Composed; + host.Draw(state); + await Assert.That(host.Composed).IsEqualTo(before); + Application.DoEvents(); + Thread.Sleep(10); + } + + var landed = host.Draw(state); + + await Assert.That(host.Composed).IsEqualTo(2); + await Assert.That(host.Canvas.Spinners).IsEmpty(); + await Assert.That(Misplaced(landed, host.Placement(PaneSide.Left), across: true)).IsEmpty(); + } + + /// + /// And while the copy at a new size is on its way, the last thing composed stands in for it, + /// the same part of it stretched into place: zooming in from fitted shows the picture at once, + /// rough, rather than a spinner on every step. + /// + [Test] + public async Task UntilItLandsTheLastSizeStandsIn() + { + using var host = new Host(); + var fitted = Zoomed(0); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (host.Composed < 2 && + DateTime.UtcNow < deadline) + { + host.Draw(fitted); + Application.DoEvents(); + Thread.Sleep(10); + } + + await Assert.That(host.Composed).IsEqualTo(2); + + // The first paint after zooming in, before anything more has been handed back + var meanwhile = host.Draw(Zoomed(1)); + + await Assert.That(host.Composed).IsEqualTo(2); + await Assert.That(host.Canvas.Spinners).IsEmpty(); + // Stretched half as large again, so an edge is a pixel or two wide and found within three + await Assert.That(Misplaced(meanwhile, host.Placement(PaneSide.Left), across: true, within: 3)).IsEmpty(); + } + + /// + /// The pair, the given number of steps in from fitted. + /// + static SessionState Zoomed(int steps) + { + var state = ViewerSession.EnqueueFile( + SessionState.Start(ViewerMode.File, columns, rows), + QueueEntry.ForFiles(left, right, FileSide.Read(left), FileSide.Read(right))); + for (var step = 0; step < steps; step++) + { + state = ViewerSession.Apply(state, CommandKind.ZoomIn); + } + + return state; + } + + /// + /// What is wrong with where a picture's stripes were drawn, which is nothing when every change + /// of stripe the placement puts on screen was drawn within pixels of + /// there, and nothing else was drawn that looks like one. A change within a few pixels of the + /// edge of what shows is not held to either, since it may or may not have a pixel beyond it. + /// + static List Misplaced(Bitmap drawn, PicturePlacement placement, bool across, double within = 1.5) + { + const int margin = 4; + var bounds = placement.Bounds; + var origin = across ? bounds.X : bounds.Y; + var end = across ? bounds.Right : bounds.Bottom; + var full = across ? picture.Width : picture.Height; + var source = across ? placement.Source.X : placement.Source.Y; + var size = across ? placement.Size.Width : placement.Size.Height; + var expected = new List(); + for (var edge = stripe; edge < full; edge += stripe) + { + expected.Add(origin + ((double) edge / full - source) * size); + } + + var found = Stripes(drawn, bounds, across); + var wrong = new List(); + var inside = expected.Where(_ => _ > origin + margin && _ < end - margin).ToList(); + if (inside.Count == 0) + { + wrong.Add("No change of stripe is placed where it would show, so nothing was checked"); + } + + foreach (var edge in inside) + { + if (!found.Any(_ => Math.Abs(_ - edge) <= within)) + { + wrong.Add($"The change placed at {edge:F1} was drawn at none of {string.Join(" ", found)}"); + } + } + + foreach (var change in found.Where(_ => _ > origin + margin && _ < end - margin)) + { + if (!expected.Any(_ => Math.Abs(_ - change) <= within)) + { + wrong.Add($"A change was drawn at {change}, and none is placed there: {string.Join(" ", expected.Select(_ => _.ToString("F1")))}"); + } + } + + return wrong; + } + + /// + /// Where the drawn stripes change, along a line through the middle of what shows: the first + /// pixel of each that is the other colour from the one before it. + /// + static List Stripes(Bitmap drawn, Rectangle bounds, bool across) + { + var changes = new List(); + var from = across ? bounds.X : bounds.Y; + var to = across ? bounds.Right : bounds.Bottom; + bool? before = null; + for (var at = from; at < to; at++) + { + var pixel = across + ? drawn.GetPixel(at, bounds.Y + bounds.Height / 2) + : drawn.GetPixel(bounds.X + bounds.Width / 2, at); + // The light stripe has little blue in it and the dark one little else + var isLight = pixel.R > pixel.B; + if (before is { } was && + was != isLight) + { + changes.Add(at); + } + + before = isLight; + } + + return changes; + } + + static string Write(string name, bool across) + { + var path = Path.Combine(directory, name); + using var bitmap = new Bitmap(picture.Width, picture.Height); + using (var graphics = Graphics.FromImage(bitmap)) + { + using var darkBrush = new SolidBrush(dark); + using var lightBrush = new SolidBrush(light); + var count = (across ? picture.Width : picture.Height) / stripe; + for (var index = 0; index < count; index++) + { + graphics.FillRectangle( + index % 2 == 0 ? darkBrush : lightBrush, + across + ? new Rectangle(index * stripe, 0, stripe, picture.Height) + : new Rectangle(0, index * stripe, picture.Width, stripe)); + } + } + + bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png); + return path; + } + + /// + /// A canvas in a real window, parked off screen and out of the taskbar, as the other tests of + /// the canvas host one. + /// + sealed class Host : IDisposable + { + readonly Form form = new() + { + StartPosition = FormStartPosition.Manual, + Location = new(-4000, -2000), + ShowInTaskbar = false + }; + + readonly List bitmaps = []; + + Screen? screen; + + public ViewerCanvas Canvas { get; } = new(); + + public Host(int width = 1100, int height = 700) + { + Canvas.Size = new(width, height); + form.Controls.Add(Canvas); + form.Show(); + } + + public Bitmap Draw(SessionState state) + { + screen = ScreenBuilder.Build(ViewerSession.Resize(state, columns, rows)); + Canvas.Draw(screen); + Canvas.Refresh(); + var bitmap = new Bitmap(Canvas.Width, Canvas.Height); + Canvas.DrawToBitmap(bitmap, new(0, 0, Canvas.Width, Canvas.Height)); + bitmaps.Add(bitmap); + return bitmap; + } + + /// + /// Where the last paint put a side's picture, by the rule the paint and the pointer share. + /// + public PicturePlacement Placement(PaneSide side) => + PicturePlacement.Of( + Canvas.PictureAreas[side == PaneSide.Left ? 0 : 1], + side == PaneSide.Left ? screen!.Left.Image! : screen!.Right.Image!); + + ImageCache Cache => + (ImageCache) typeof(ViewerCanvas) + .GetField("images", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(Canvas)!; + + /// + /// How many times a picture has been composed or scaled to be kept. + /// + public int Composed => + Cache.Composed; + + /// + /// The size of what is kept of a picture, or null when nothing is. + /// + public Size? Kept(string path) => + Cache.Composited(path)?.Size; + + public void Dispose() + { + foreach (var bitmap in bitmaps) + { + bitmap.Dispose(); + } + + form.Dispose(); + } + } +} diff --git a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs index 79f006ff9..dd6949112 100644 --- a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs @@ -126,6 +126,134 @@ public async Task HighlightAfterACharacterOffTheGridCoversTheNext(string line, i await Assert.That(offCentre).IsLessThanOrEqualTo(1); } + /// + /// Rows several times longer than their pane, against a canvas wide enough to hold every one + /// of them whole. A row is handed to GDI+ cut to the cells its pane has room for, and the cut + /// must not show: every pixel of the narrow pane's rows is the pixel the wide canvas has + /// there. Twenty widths a pixel apart, so the cut falls at every offset into a cell it can. + /// + [Test] + public async Task ARowCutAtItsPaneIsDrawnAsTheWholeOfItIs() + { + var lines = LongRows(); + var text = string.Join('\n', lines); + var screen = ScreenBuilder.Build(ViewerSession.Resize(Fixtures.File(text, text), columns, rows)); + + using var whole = new CanvasHost(5600, 400); + var reference = whole.Draw(screen); + var cell = whole.Canvas.CellSize(); + var top = whole.Canvas.BodyTop(); + // Or the reference is cut as well, and this compares one cut with another + var room = (whole.Canvas.Panes().Half - 8 * cell.Width) / cell.Width; + await Assert.That(room).IsGreaterThanOrEqualTo(longRowCells); + + var wrong = new List(); + for (var width = 1100; width < 1120; width++) + { + using var host = new CanvasHost(width, 400); + var drawn = host.Draw(screen); + var (left, half, _) = host.Canvas.Panes(); + // The rule between the panes is drawn over the left one's last pixels but one + var rule = left + half - 2; + var differing = 0; + Point? first = null; + for (var y = top; y < top + lines.Length * cell.Height; y++) + { + for (var x = left + 8 * cell.Width; x < left + half; x++) + { + if (x == rule || + drawn.GetPixel(x, y) == reference.GetPixel(x, y)) + { + continue; + } + + differing++; + first ??= new(x, y); + } + } + + if (differing > 0) + { + wrong.Add($"{width} wide: {differing} pixels differ, the first at {first}"); + } + } + + await Assert.That(wrong).IsEmpty(); + } + + const int longRowCells = 300; + + /// + /// A row of each kind of character the grid places differently, every one 300 cells or just + /// under. + /// + static string[] LongRows() => + [ + Filling("{\"id\":1000,\"name\":\"item 1000\",\"tags\":[\"alpha\",\"beta\"],\"price\":12.5},"), + Filling("M"), + Filling("the quick brown fox jumps over the lazy dog "), + // A glyph whose ink starts at its very left, once at each place in four: GDI+ fits glyphs + // to whole pixels, which can start one in the last pixel of the cell before its own, and + // that is why a cell past the last one showing is kept + Filling("Wi. "), + Filling("Wi. ", "x"), + Filling("Wi. ", "xx"), + Filling("Wi. ", "xxx"), + Filling("\tcolumn"), + Filling("Привет, мир αβγ éñü "), + // Wide characters, each a segment of its own, and a row of nothing else, where every + // other cut would fall inside one + Filling("中文 and ascii "), + Filling("中"), + // A mark on the character before it, and a family joined into one picture. The long form + // of each escape, since neither can be seen in a source file + Filling("e\U00000301a\U00000308 marks "), + Filling("\U0001F600 \U0001F468\U0000200D\U0001F469\U0000200D\U0001F467 ") + ]; + + static string Filling(string unit, string lead = "") => + lead + string.Concat(Enumerable.Repeat(unit, (longRowCells - lead.Length) / CellGrid.Cells(RowText.Flatten(unit)))); + + /// + /// A megabyte of one line, with one wide character half way along it. The row was segmented + /// whole, which walked all of it, and the run before that character was copied out to be cut + /// down to the start of it that shows: a megabyte allocated for each such row, on every paint. + /// Counted in what a paint allocates, which is the same number on a busy machine. + /// + [Test] + public async Task AMegabyteRowIsPaintedFromItsStart() + { + const int megabyte = 1024 * 1024; + var line = new string('x', megabyte / 2) + "中" + new string('y', megabyte / 2); + var screen = ScreenBuilder.Build(ViewerSession.Resize(Fixtures.File(line, line), columns, rows)); + using var host = new CanvasHost(); + // Once before it is measured: the font, the brushes, and anything compiled on first use + host.Draw(screen); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var bitmap = host.Draw(screen); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // The row's own ink, so what was measured is a paint that drew it + var cell = host.Canvas.CellSize(); + var textLeft = host.Canvas.Panes().Left + 8 * cell.Width; + var inked = 0; + for (var y = host.Canvas.BodyTop(); y < host.Canvas.BodyTop() + cell.Height; y++) + { + for (var x = textLeft; x < textLeft + 20 * cell.Width; x++) + { + if (bitmap.GetPixel(x, y).GetBrightness() > 0.6f) + { + inked++; + } + } + } + + Console.WriteLine($"{allocated / 1024} KB allocated by a paint of two megabyte rows, {inked} bright pixels in the first"); + await Assert.That(inked).IsGreaterThan(0); + await Assert.That(allocated).IsLessThan(megabyte / 8); + } + /// /// Ten image pairs drawn one after another, each accepted (received moved over /// verified) before the next, and then a screen with no picture on it. Nothing needs more than @@ -179,6 +307,88 @@ public async Task RepaintingAPictureComposesItOnce() await Assert.That(Bounds(first, _ => _ is {R: 198, G: 64, B: 64})).IsEqualTo(red); } + /// + /// A picture no larger than one square of the checkerboard behind it has no dark square under + /// it at all. The dark squares are handed to GDI+ as one list, and it takes a list of nothing + /// as a mistake, which would fail the compose and leave an icon of eight pixels drawn as + /// nothing. Composed on the pool, as the window does, where a compose that fails is one that + /// never lands rather than one that throws out of a paint. + /// + [Test] + public async Task APictureNoLargerThanOneSquareOfTheCheckerboardIsDrawn() + { + var directory = Directory.CreateTempSubdirectory("deview-small-picture-").FullName; + try + { + var received = Path.Combine(directory, "icon.received.png"); + var verified = Path.Combine(directory, "icon.verified.png"); + await File.WriteAllBytesAsync(received, SamplePng.Build(8, 6, 198, 64, 64)); + await File.WriteAllBytesAsync(verified, SamplePng.Build(8, 6, 64, 150, 198)); + var entry = QueueEntry.ForFiles(received, verified, FileSide.Read(received), FileSide.Read(verified)); + var screen = ScreenBuilder.Build( + ViewerSession.Resize( + ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File, columns, rows), entry), + columns, + rows)); + using var host = new CanvasHost(); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (host.Canvas.Composed() < 2 && + DateTime.UtcNow < deadline) + { + Application.DoEvents(); + host.Draw(screen); + Thread.Sleep(10); + } + + var drawn = host.Draw(screen); + + await Assert.That(Bounds(drawn, _ => _ is {R: 198, G: 64, B: 64})).IsNotNull(); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// + /// One picture on both sides, which a page that two identical documents share is, on a canvas + /// an odd number of pixels wide. The right pane is then a pixel wider than the left, and a + /// picture fitted to each was asked for at two sizes. The cache keeps one composite per + /// picture, so every paint composed it twice over, each landing throwing the other away, for + /// as long as the entry was on screen. + /// + [Test] + [Arguments(1100)] + [Arguments(1101)] + public async Task OnePictureOnBothSidesIsComposedOnce(int width) + { + var directory = Directory.CreateTempSubdirectory("deview-one-picture-").FullName; + try + { + // Wide, so it is the pane's width that it is fitted to + var path = Path.Combine(directory, "page.png"); + await File.WriteAllBytesAsync(path, SamplePng.Build(2000, 500, 198, 64, 64)); + var entry = QueueEntry.ForFiles(path, path, FileSide.Read(path), FileSide.Read(path)); + var screen = ScreenBuilder.Build( + ViewerSession.Resize( + ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File, columns, rows), entry), + columns, + rows)); + using var host = new CanvasHost(width); + host.Canvas.Synchronous = true; + + host.Draw(screen); + host.Draw(screen); + + await Assert.That(host.Canvas.Composed()).IsEqualTo(1); + } + finally + { + Directory.Delete(directory, true); + } + } + /// /// Through the real canvas, loading its pictures the way the window does, on the pool: a /// spinner where each picture goes until it lands, and the pictures once they have, with nothing diff --git a/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs b/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs index 4e0afe148..127de1b03 100644 --- a/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs @@ -1,3 +1,5 @@ +using System.Threading.Channels; + /// /// The decode the WinForms head puts under an image pane's rows. /// @@ -77,13 +79,13 @@ public async Task RemembersAFailure() public async Task ADecodeWithSomewhereToPostItIsHandedBack() { var path = Write("posted.png", SamplePng.Build(8, 6, 200, 40, 40)); - using var posted = new BlockingCollection(); + var posted = new Posts(); using var cache = new ImageCache(posted.Add); var loaded = 0; await Assert.That(cache.Get(path, null, () => loaded++)).IsNull(); - await Assert.That(posted.TryTake(out var handBack, TimeSpan.FromSeconds(10))).IsTrue(); - handBack!(); + var handBack = await posted.Take(); + handBack(); await Assert.That(loaded).IsEqualTo(1); await Assert.That(cache.Get(path, null, () => loaded++)!.Width).IsEqualTo(8); @@ -97,20 +99,77 @@ public async Task ADecodeWithSomewhereToPostItIsHandedBack() public async Task ADecodeForAPictureNoLongerOnScreenIsDropped() { var path = Write("left-behind.png", SamplePng.Build(8, 6, 200, 40, 40)); - using var posted = new BlockingCollection(); + var posted = new Posts(); using var cache = new ImageCache(posted.Add); var loaded = 0; cache.Keep([path]); cache.Get(path, null, () => loaded++); - await Assert.That(posted.TryTake(out var handBack, TimeSpan.FromSeconds(10))).IsTrue(); + var handBack = await posted.Take(); cache.Keep([]); - handBack!(); + handBack(); await Assert.That(loaded).IsEqualTo(0); await Assert.That(cache.Composite(path, new(8, 6), (_, size) => new(size.Width, size.Height))).IsNull(); } + /// + /// And a picture that comes back after its decode was dropped is decoded again. The dropped + /// decode used to leave the path marked as on its way, so nothing was started for it and both + /// panes showed a spinner until the file changed: stepping past a picture before it had + /// decoded, which holding Tab through a queue of them does to nearly every one. + /// + [Test] + public async Task APictureThatComesBackAfterItsDecodeWasDroppedIsDecodedAgain() + { + var path = Write("came-back.png", SamplePng.Build(8, 6, 200, 40, 40)); + var posted = new Posts(); + using var cache = new ImageCache(posted.Add); + var loaded = 0; + cache.Keep([path]); + cache.Get(path, null, () => loaded++); + var dropped = await posted.Take(); + cache.Keep([]); + dropped(); + await Assert.That(cache.Loading(path)).IsFalse(); + + cache.Keep([path]); + await Assert.That(cache.Get(path, null, () => loaded++)).IsNull(); + await Assert.That(cache.Loading(path)).IsTrue(); + var handBack = await posted.Take(); + handBack(); + + await Assert.That(loaded).IsEqualTo(1); + await Assert.That(cache.Loading(path)).IsFalse(); + await Assert.That(cache.Get(path, null, () => loaded++)!.Width).IsEqualTo(8); + } + + /// + /// Back on screen before its decode has landed, the picture is still the one on its way: that + /// decode is kept when it lands, not thrown away and started over. + /// + [Test] + public async Task APictureBackOnScreenBeforeItsDecodeLandsKeepsThatDecode() + { + var path = Write("back-in-time.png", SamplePng.Build(8, 6, 200, 40, 40)); + var posted = new Posts(); + using var cache = new ImageCache(posted.Add); + var loaded = 0; + cache.Keep([path]); + cache.Get(path, null, () => loaded++); + var handBack = await posted.Take(); + cache.Keep([]); + cache.Keep([path]); + + // Nothing new is started for it + await Assert.That(cache.Get(path, null, () => loaded++)).IsNull(); + handBack(); + + await Assert.That(loaded).IsEqualTo(1); + await Assert.That(posted.Count).IsEqualTo(0); + await Assert.That(cache.Get(path, null, () => loaded++)!.Width).IsEqualTo(8); + } + /// /// A pane paints the picture over its checkerboard, scaled, once per size, and copies that on /// every paint after. Scaling it on every paint cost 46 to 66 ms a paint for a pair of 2000 by @@ -133,6 +192,56 @@ public async Task APictureIsComposedOncePerSize() await Assert.That(cache.Composed).IsEqualTo(2); } + /// + /// A picture is composed two ways: fitted over its checkerboard, and enlarged with nothing + /// under it. Either of those at the size the other is asked for is still not the other, so it + /// is composed again rather than handed over because the sizes happen to match. + /// + [Test] + public async Task APictureComposedOneWayIsNotTheOtherAtThatSize() + { + var path = Write("two-ways.png", SamplePng.Build(8, 6, 200, 40, 40)); + using var cache = new ImageCache(); + await Assert.That(cache.Get(path, null)).IsNotNull(); + Func fitted = (_, size) => new(size.Width, size.Height); + Func enlarged = (_, size) => new(size.Width, size.Height); + + var first = cache.Composite(path, new(4, 3), fitted); + var other = cache.Composite(path, new(4, 3), enlarged); + var again = cache.Composite(path, new(4, 3), enlarged); + + await Assert.That(ReferenceEquals(first, other)).IsFalse(); + await Assert.That(ReferenceEquals(other, again)).IsTrue(); + await Assert.That(cache.Composed).IsEqualTo(2); + } + + /// + /// And on the pool as for a new size: what is there stands in until the other way lands. + /// + [Test] + public async Task APictureComposedOneWayStandsInUntilTheOtherLands() + { + var path = Write("two-ways-posted.png", SamplePng.Build(8, 6, 200, 40, 40)); + var posted = new Posts(); + using var cache = new ImageCache(posted.Add); + cache.Get(path, null); + Func enlarged = (_, size) => new(size.Width, size.Height); + cache.Composite(path, new(4, 3), Build, () => { }); + var first = await posted.Take(); + first(); + var fitted = cache.Composite(path, new(4, 3), Build, () => { }); + + var meanwhile = cache.Composite(path, new(4, 3), enlarged, () => { }); + await Assert.That(ReferenceEquals(meanwhile, fitted)).IsTrue(); + + var second = await posted.Take(); + second(); + var landed = cache.Composite(path, new(4, 3), enlarged, () => { }); + await Assert.That(ReferenceEquals(landed, fitted)).IsFalse(); + await Assert.That(posted.Count).IsEqualTo(0); + await Assert.That(cache.Composed).IsEqualTo(2); + } + /// /// The window composes on the pool as well: scaling a page of a document on the UI thread held /// it for tens of milliseconds a size. Until the first lands there is nothing to draw, and the @@ -142,15 +251,15 @@ public async Task APictureIsComposedOncePerSize() public async Task AComposeWithSomewhereToPostItIsHandedBack() { var path = Write("composed-posted.png", SamplePng.Build(8, 6, 200, 40, 40)); - using var posted = new BlockingCollection(); + var posted = new Posts(); using var cache = new ImageCache(posted.Add); await Assert.That(cache.Get(path, null)).IsNotNull(); var loaded = 0; await Assert.That(cache.Composite(path, new(4, 3), Build, () => loaded++)).IsNull(); await Assert.That(cache.Loading(path)).IsTrue(); - await Assert.That(posted.TryTake(out var handBack, TimeSpan.FromSeconds(10))).IsTrue(); - handBack!(); + var handBack = await posted.Take(); + handBack(); await Assert.That(loaded).IsEqualTo(1); await Assert.That(cache.Loading(path)).IsFalse(); @@ -165,20 +274,20 @@ public async Task AComposeWithSomewhereToPostItIsHandedBack() public async Task AResizeShowsTheLastSizeUntilTheNewOneLands() { var path = Write("resized.png", SamplePng.Build(8, 6, 200, 40, 40)); - using var posted = new BlockingCollection(); + var posted = new Posts(); using var cache = new ImageCache(posted.Add); cache.Get(path, null); cache.Composite(path, new(4, 3), Build, () => { }); - await Assert.That(posted.TryTake(out var first, TimeSpan.FromSeconds(10))).IsTrue(); - first!(); + var first = await posted.Take(); + first(); var small = cache.Composite(path, new(4, 3), Build, () => { }); var meanwhile = cache.Composite(path, new(6, 4), Build, () => { }); await Assert.That(ReferenceEquals(meanwhile, small)).IsTrue(); await Assert.That(cache.Loading(path)).IsFalse(); - await Assert.That(posted.TryTake(out var second, TimeSpan.FromSeconds(10))).IsTrue(); - second!(); + var second = await posted.Take(); + second(); await Assert.That(cache.Composite(path, new(6, 4), Build, () => { })!.Size).IsEqualTo(new(6, 4)); } @@ -191,7 +300,7 @@ public async Task AResizeShowsTheLastSizeUntilTheNewOneLands() public async Task AComposeForAPictureNoLongerOnScreenIsDropped() { var path = Write("composed-left-behind.png", SamplePng.Build(8, 6, 200, 40, 40)); - using var posted = new BlockingCollection(); + var posted = new Posts(); using var cache = new ImageCache(posted.Add); using var reading = new ManualResetEventSlim(); var width = 0; @@ -211,8 +320,8 @@ public async Task AComposeForAPictureNoLongerOnScreenIsDropped() () => loaded++); cache.Keep([]); reading.Set(); - await Assert.That(posted.TryTake(out var handBack, TimeSpan.FromSeconds(10))).IsTrue(); - handBack!(); + var handBack = await posted.Take(); + handBack(); await Assert.That(width).IsEqualTo(8); await Assert.That(loaded).IsEqualTo(0); @@ -227,7 +336,7 @@ public async Task AComposeForAPictureNoLongerOnScreenIsDropped() public async Task AComposeThatFailsIsNotTriedAgain() { var path = Write("uncomposable.png", SamplePng.Build(8, 6, 200, 40, 40)); - using var posted = new BlockingCollection(); + var posted = new Posts(); using var cache = new ImageCache(posted.Add); cache.Get(path, null); var attempts = 0; @@ -238,8 +347,8 @@ public async Task AComposeThatFailsIsNotTriedAgain() }; cache.Composite(path, new(4, 3), failing, () => { }); - await Assert.That(posted.TryTake(out var handBack, TimeSpan.FromSeconds(10))).IsTrue(); - handBack!(); + var handBack = await posted.Take(); + handBack(); await Assert.That(cache.Composite(path, new(4, 3), failing, () => { })).IsNull(); await Assert.That(cache.Loading(path)).IsFalse(); @@ -250,6 +359,42 @@ public async Task AComposeThatFailsIsNotTriedAgain() static Bitmap Build(Image picture, Size size) => new(size.Width, size.Height); + /// + /// What a cache posts back, for a test to take and run: the window's BeginInvoke, as a queue. + /// + /// Waited for without holding a thread. A decode runs on the pool, and so does every test, in + /// parallel. These used to block where they stood until the decode posted back, so each held + /// a pool thread while waiting for work that needed one, and once every thread the pool had + /// was held that way the work had nowhere to run until the pool grew another: on a runner of + /// four cores busy with the other test projects, ten seconds went by with nothing decoded and + /// four of these failed together. A test that awaits gives its thread back. + /// + /// + sealed class Posts + { + readonly Channel posted = Channel.CreateUnbounded(); + + public void Add(Action action) => + posted.Writer.TryWrite(action); + + public int Count => + posted.Reader.Count; + + public async Task Take() + { + // Long, since all it bounds is a test that would otherwise never end + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + try + { + return await posted.Reader.ReadAsync(timeout.Token); + } + catch (OperationCanceledException) + { + throw new TimeoutException("Nothing was posted back within a minute."); + } + } + } + [Test] public async Task MissingFile() { diff --git a/src/DiffEngineViewer.Windows.Tests/RowTextTests.cs b/src/DiffEngineViewer.Windows.Tests/RowTextTests.cs new file mode 100644 index 000000000..a5848c09f --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/RowTextTests.cs @@ -0,0 +1,120 @@ +/// +/// The start of a row, which is all of it this head hands to GDI+: no more than its pane has cells +/// for, cut where the grid would cut it, and found without reading the rest of the row. +/// +/// Here rather than beside CellGridTests because this head is what asks for it. Nothing in +/// it is about WinForms. +/// +/// +public class RowTextTests +{ + /// + /// Whatever a row holds and wherever in it the cut falls, what is kept is what flattening all + /// of it and cutting at the grid's boundary keeps. So nothing downstream can tell the start + /// from the whole row: a tab is its four cells, a wide character or a character and its marks + /// is whole or absent, and a pair of surrogates is never read as its first half. + /// + [Test] + public async Task TheStartOfARowIsTheWholeRowCutAtACellBoundary() + { + var wrong = new List(); + foreach (var row in rows) + { + var flattened = RowText.Flatten(row); + for (var cells = -1; cells <= CellGrid.Cells(flattened) + 2; cells++) + { + var expected = flattened[..CellGrid.Index(flattened, cells)]; + var shown = RowText.Shown(row, cells); + if (shown != expected) + { + wrong.Add($"{Escaped(row)} at {cells} cells: {Escaped(shown)} rather than {Escaped(expected)}"); + } + } + } + + await Assert.That(wrong).IsEmpty(); + } + + // Every character that cannot be seen in a source file is written as the long form of its + // escape: marks, joiners, and the two that take no cell and sit on nothing. + static readonly string[] rows = + [ + "", + "x", + "plain text, which is nearly every row there is", + "\tindented\twith\ttabs, each four cells", + // Flattened away, so the characters read are more than the cells they fill + "\r\r\r\r\r\rreturns before the text", + "a\rb\nc", + "ends in a return\r", + // Wide: a cut inside one moves past it + "中文 and 한국어 and full", + new('中', 40), + // A mark belongs to the character before it, however many there are + "e\U00000301\U00000302x and a\U00000308", + "z" + new string('\U00000301', 100) + "algo", + "\U00000301", + new('\U00000301', 50), + // No cell and no character to sit on: a soft hyphen and a zero width space + "soft\U000000ADhyphen and zero\U0000200Bwidth", + // Outside the basic plane, so two units to a cell + "\U0001D400\U0001D401\U0001D402 narrow, and \U0001F600\U0001F601 wide", + string.Concat(Enumerable.Repeat("\U0001D400", 30)), + // A joiner takes what follows it into the same cell + "\U0001F468\U0000200D\U0001F469\U0000200D\U0001F467 and a\U0000200Db and a trailing one\U0000200D", + // A mark that is a surrogate pair. Read as far as its first half alone, it would be a + // character of its own and the cut would come between it and the one it marks + "a\U000E0100b\U000E0101c", + string.Concat(Enumerable.Repeat("a\U000E0100", 20)), + // Halves with no other half: one unit and one cell each + "x\uD83D", + "\uDC00y", + "a\uD83Db\uDC00c", + // Long, as the rows this exists for are, with the one character that is not ASCII far along + new string('x', 500) + "中" + new string('y', 500) + ]; + + /// + /// A megabyte of one line, starting with a tab. Flattening all of it to find its first sixty + /// cells copied the megabyte, on every paint of every such row. Counted in what is allocated, + /// which is the same number on a busy machine. + /// + [Test] + public async Task TheStartOfARowIsFoundWithoutReadingTheRest() + { + var row = "\t" + new string('x', megabyte); + // Once before it is measured, so nothing compiled on first use is counted + RowText.Shown(row, 60); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var shown = RowText.Shown(row, 60); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + await Assert.That(shown).IsEqualTo(" " + new string('x', 56)); + await Assert.That(allocated).IsLessThan(4096); + } + + /// + /// And one that is nothing but marks on one character, which fills a single cell however much + /// of it is read. It is read twice over at most rather than once for every cell asked for. + /// + [Test] + public async Task ARowThatNeverFillsItsCellsIsReadTwiceAtMost() + { + var row = "e" + new string('\U00000301', megabyte); + RowText.Shown(row, 60); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var shown = RowText.Shown(row, 60); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + await Assert.That(shown).IsEqualTo(row); + // Each read is of a copy, two bytes a character + await Assert.That(allocated).IsLessThan(2 * 2 * (megabyte + 1024)); + } + + const int megabyte = 1024 * 1024; + + static string Escaped(string text) => + string.Concat(text.Select(_ => _ is >= ' ' and <= '~' ? _.ToString() : $"\\u{(int) _:X4}")); +} diff --git a/src/DiffEngineViewer.Windows.Tests/ViewerFormRaiseTests.cs b/src/DiffEngineViewer.Windows.Tests/ViewerFormRaiseTests.cs index d02bd99d4..bfde1efa6 100644 --- a/src/DiffEngineViewer.Windows.Tests/ViewerFormRaiseTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/ViewerFormRaiseTests.cs @@ -37,6 +37,31 @@ public async Task Leaves_a_maximised_window_maximised() await Assert.That(form.WindowState).IsEqualTo(FormWindowState.Maximized); } + /// + /// Minimised from maximised, it comes back maximised, as it would from the taskbar. Put back to + /// normal instead, it was also remembered that way: the next hide or close saved a window the + /// reader had maximised as one they had not. + /// + [Test] + public async Task Restores_a_window_minimised_from_maximised_as_maximised() + { + // Shown, since what it was minimised from is learnt from the window as it resizes, and + // transparent, so a maximised window does not flash over whoever is running the tests + using var form = new ViewerForm("title", 800, 600) + { + Opacity = 0, + ShowInTaskbar = false + }; + form.Show(); + form.WindowState = FormWindowState.Maximized; + form.WindowState = FormWindowState.Minimized; + + form.Raise(); + + await Assert.That(form.WindowState).IsEqualTo(FormWindowState.Maximized); + await Assert.That(form.Placement!.Value.Maximized).IsTrue(); + } + [Test] public async Task Shows_a_hidden_window() { diff --git a/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs b/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs index 499069abb..522ab00c4 100644 --- a/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs @@ -89,6 +89,42 @@ public async Task AWindowTooSmallToHaveBeenLeftIsNotUsed() await Assert.That(restored).IsNull(); } + /// + /// A first window, with nothing remembered, is scaled to its display once its handle exists, + /// and WinForms has centred it by then for the size it was before. It grew down and to the + /// right from there: at 125% the margins came out left 1161 and right 886, and at 150% on a + /// 1080p display the footer was under the taskbar. That placement was then the one remembered. + /// + [Test] + public async Task AFirstWindowIsCentredForTheSizeItOpensAt() + { + // Asked for larger than any display, so the size it opens at is not the size it was + // centred for whatever this machine's scaling is: a test host that is not DPI aware + // scales nothing, and a window of 1100 by 700 would be left exactly as it was made + using var form = new ViewerForm("title", 9000, 6000); + _ = form.Handle; + + var area = System.Windows.Forms.Screen.FromControl(form).WorkingArea; + await Assert.That(form.Width).IsLessThan(area.Width); + await Assert.That(form.Location).IsEqualTo(ViewerForm.Centred(area, form.Size)); + await Assert.That(form.Left).IsGreaterThan(area.Left); + } + + [Test] + public async Task CentredIsTheMiddleOfTheDisplayItIsOn() + { + await Assert.That(ViewerForm.Centred(display, new(1100, 700))).IsEqualTo(new Point(410, 170)); + await Assert.That(ViewerForm.Centred(second, new(1500, 900))).IsEqualTo(new Point(2450, 250)); + } + + /// + /// Too large for the display, it starts at the top left rather than hanging off it evenly: the + /// title bar is what it is moved by. + /// + [Test] + public async Task AWindowLargerThanTheDisplayIsCentredFromItsTopLeft() => + await Assert.That(ViewerForm.Centred(second, new(3000, 1600))).IsEqualTo(new Point(1920, 0)); + [Test] public async Task AFormOpensAtTheBoundsItIsGiven() { diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPage.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPage.verified.png index cf8d28542..6ea1788e4 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPage.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPage.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageBeingDrawn.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageBeingDrawn.verified.png index fe9e13573..54bf1595d 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageBeingDrawn.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageBeingDrawn.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageEnlarged.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageEnlarged.verified.png index dfbe80949..eece36134 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageEnlarged.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.DocumentPageEnlarged.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png index 66c88e875..469d3eceb 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.ImagesEnlarged.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.ImagesEnlarged.verified.png index 7bb9eb975..b723300fd 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.ImagesEnlarged.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.ImagesEnlarged.verified.png differ diff --git a/src/DiffEngineViewer.Windows/ImageCache.cs b/src/DiffEngineViewer.Windows/ImageCache.cs index 5fc660cf3..2192dfe0d 100644 --- a/src/DiffEngineViewer.Windows/ImageCache.cs +++ b/src/DiffEngineViewer.Windows/ImageCache.cs @@ -15,7 +15,10 @@ /// held the window for over 100 ms. And the picture as painted, at the size it was painted /// (): scaling it and drawing the /// checkerboard under it on every paint cost 46 to 66 ms a paint for that pair, where copying the -/// result costs almost nothing. +/// result costs almost nothing. One such at a time for a picture: fitted, or, while it is enlarged +/// to no more than half its own size, the whole of it at that size for the part that shows to be +/// copied out of. Past that nothing more is held, since the whole of it would be up to the +/// decoded picture's size again. /// /// /// Both are made off the UI thread when there is somewhere to post the result, which the window @@ -67,6 +70,22 @@ sealed class Entry(Stamp stamp, Image? image) /// public Bitmap? Composite { get; set; } + /// + /// What made . A picture is composed two ways, fitted over its + /// checkerboard and enlarged with nothing under it, and one of those at the size the + /// other is asked for is still not the other. + /// + public Func? Composer { get; set; } + + /// + /// Whether is what makes at + /// . + /// + public bool Holds(Size size, Func build) => + Composite is { } composite && + composite.Size == size && + Composer == build; + /// /// The size a compose on the pool is making, or null when none is. /// @@ -208,9 +227,9 @@ public void Keep(IReadOnlyCollection paths) /// from two threads at once throws. A compose only ever starts on the UI thread, so one cannot /// begin between this answering and the caller drawing. /// - /// For a picture enlarged past its pane, which is drawn a part at a time straight from the - /// decoded picture. A composite of the whole of it at sixteen times the size that fits would - /// be hundreds of megabytes to show the corner of it that is on screen. + /// For a picture enlarged past half its own size, which is drawn a part at a time straight + /// from the decoded picture. A composite of the whole of it at sixteen times the size that fits + /// would be hundreds of megabytes to show the corner of it that is on screen. /// /// public Image? Idle(string path) => @@ -220,9 +239,9 @@ entry.Composing is null : null; /// - /// The picture at as it was last painted fitted, at whatever size that - /// was, or null when it never has been. Something to draw from while has - /// nothing to give. + /// The picture at as it was last composed, fitted or enlarged, at + /// whatever size that was, or null when it never has been. Something to draw from while + /// has nothing to give. /// public Bitmap? Composited(string path) => entries.TryGetValue(path, out var entry) ? entry.Composite : null; @@ -243,14 +262,24 @@ void Loaded(string path, Stamp stamp, Image? image, Action loaded) // since moved past, would put back the picture the newer decode is replacing if (disposed || !pending.TryGetValue(path, out var started) || - started != stamp || - (wanted is not null && !wanted.Contains(path))) + started != stamp) { image?.Dispose(); return; } + // The decode the path was waiting on has landed, so it is waiting no longer, whatever is + // done with the result. Thrown away for a picture that had left the screen, it used to + // leave the path marked as on its way: a reader who came back to it had nothing started + // for it again, and a spinner in each pane until the file changed. pending.Remove(path); + if (wanted is not null && + !wanted.Contains(path)) + { + image?.Dispose(); + return; + } + Forget(path); entries.Add(path, new(stamp, image)); loaded(); @@ -258,8 +287,9 @@ void Loaded(string path, Stamp stamp, Image? image, Action loaded) /// /// The picture at as it is painted at , built - /// by from the decoded picture the first time that size is asked for - /// and kept until another size is, or the picture goes. Null when the picture is not decoded. + /// by from the decoded picture the first time that size is asked of + /// it and kept until another size is, or another way of building it, or the picture goes. Null + /// when the picture is not decoded. /// Built here and now: for a caller that has to have it this frame, such as a capture. /// public Bitmap? Composite(string path, Size size, Func build) @@ -270,24 +300,24 @@ void Loaded(string path, Stamp stamp, Image? image, Action loaded) return null; } - if (entry.Composite is { } composite && - composite.Size == size) + if (entry.Holds(size, build)) { - return composite; + return entry.Composite; } entry.Composite?.Dispose(); entry.Composite = build(entry.Image, size); + entry.Composer = build; Composed++; return entry.Composite; } /// /// The picture at as it is painted at , composed - /// on the pool when that size has not been, after which is called on - /// the UI thread. Meanwhile the composite at whatever size it was last made, for the caller to - /// stretch into place, or null when there has never been one. With nowhere to post the result - /// this composes here and now. + /// on the pool when it has not been at that size and by , after which + /// is called on the UI thread. Meanwhile the composite at whatever + /// size it was last made, for the caller to stretch into place, or null when there has never + /// been one. With nowhere to post the result this composes here and now. /// /// One compose at a time per picture. A resize asks for a new size every frame, and the one /// asked for when the compose in hand finishes is the one composed next, so a drag ends with @@ -307,10 +337,9 @@ void Loaded(string path, Stamp stamp, Image? image, Action loaded) return null; } - if (entry.Composite is { } composite && - composite.Size == size) + if (entry.Holds(size, build)) { - return composite; + return entry.Composite; } if (entry.Composing is null && @@ -331,13 +360,13 @@ void Loaded(string path, Stamp stamp, Image? image, Action loaded) entry.EndRead(); } }, - built => Landed(path, entry, built, loaded)); + built => Landed(path, entry, built, build, loaded)); } return entry.Composite; } - void Landed(string path, Entry entry, Bitmap? built, Action loaded) + void Landed(string path, Entry entry, Bitmap? built, Func build, Action loaded) { entry.Composing = null; // Only into the entry it was made from. One the cache has since let go of, for a picture @@ -360,6 +389,7 @@ void Landed(string path, Entry entry, Bitmap? built, Action loaded) entry.Composite?.Dispose(); entry.Composite = built; + entry.Composer = build; Composed++; loaded(); } diff --git a/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs b/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs index 798ae4064..8ab346eb5 100644 --- a/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs +++ b/src/DiffEngineViewer.Windows/InternalsVisibleTo.cs @@ -1 +1,3 @@ [assembly: InternalsVisibleTo("DiffEngineViewer.Windows.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] + +[assembly: InternalsVisibleTo("DiffEngineViewer.Windows.Benchmarks, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs index e8c12cb6c..c0ab63cb0 100644 --- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs +++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs @@ -514,8 +514,13 @@ protected override void OnPaint(PaintEventArgs e) // Under the rows rather than instead of them. The rows are what every head draws — format, // size and byte count, coloured against the other side — and this head can afford to also // show the thing they describe. + // Both in the same width, not the pixel more an odd width leaves the right pane. Two + // pictures of one size are then fitted to one size, rather than a pixel apart. And one + // picture on both sides, which a page two identical documents share is, is composed once: + // the cache keeps a composite per picture, so asked for at two sizes it composed each in + // turn for as long as the entry was on screen, every landing throwing the other away. DrawImage(graphics, screen.Left, panesLeft, half, bodyTop, bodyBottom, lineHeight); - DrawImage(graphics, screen.Right, panesLeft + half, panesWidth - half, bodyTop, bodyBottom, lineHeight); + DrawImage(graphics, screen.Right, panesLeft + half, half, bodyTop, bodyBottom, lineHeight); if (hasQueue) { @@ -570,7 +575,13 @@ void DrawImage(Graphics graphics, Pane pane, int left, int width, int bodyTop, i var bounds = placement.Bounds; if (image.Zoom > 1) { - DrawEnlarged(graphics, image, placement); + // From a copy of the whole of it at that size while one is small enough to keep, and + // otherwise straight from the picture + if (!DrawScaled(graphics, image, placement, available, lineHeight)) + { + DrawEnlarged(graphics, image, placement); + } + return; } @@ -612,12 +623,135 @@ void DrawImage(Graphics graphics, Pane pane, int left, int width, int bodyTop, i } /// - /// A picture the reader has zoomed into: the part of it that shows, drawn straight from the - /// decoded picture rather than from a composite of the whole of it at that size, which at the - /// last step would be hundreds of megabytes to show one corner. + /// A picture the reader has zoomed into, but only as far as half its own size or less: the + /// part of it that shows, copied out of the whole of it scaled to that size. False when there + /// is no such copy to draw from and none coming, which leaves it to + /// : the picture is further in than that, or could not be scaled. + /// + /// Drawn straight from the picture, this far out, the part that shows is many times the + /// pane's pixels, and the filter a reduction needs reads every one of them on every paint: a + /// pair of 4000 by 3000 pictures at 150% was 54 ms a paint, on every frame of a drag, and is 2 + /// copied. The whole of it at this size is at most a quarter of the picture's own pixels, so + /// it is kept as the fitted one is, made on the pool and replaced when another size is asked + /// for. Where the picture is dragged to is not part of what is kept, so a drag copies a + /// different part of the same thing. + /// /// - /// On this thread, on every paint. It is the pane's worth of pixels however far in it is, and a - /// drag asks for a different part on every frame, so there is nothing a cache could keep. + /// Past half its size the whole of it is too much to keep a second copy of, up to the + /// picture's own size again, and the part that shows is under four times the pane's pixels. + /// + /// + bool DrawScaled(Graphics graphics, ImagePane image, PicturePlacement placement, Rectangle available, int lineHeight) + { + if (image.Width < placement.Size.Width * 2) + { + return false; + } + + var size = new Size( + (int) Math.Round(placement.Size.Width), + (int) Math.Round(placement.Size.Height)); + var scaled = Synchronous + ? images.Composite(image.Path, size, Scale) + : images.Composite(image.Path, size, Scale, Invalidate); + var landed = scaled is not null && + scaled.Size == size; + if (!landed && + images.Idle(image.Path) is not null) + { + // Not at this size, and nothing on the pool is making it: it could not be made + return false; + } + + if (scaled is null) + { + DrawSpinner(graphics, available, lineHeight); + return true; + } + + var bounds = placement.Bounds; + FillChecker(graphics, bounds); + var interpolation = graphics.InterpolationMode; + var offset = graphics.PixelOffsetMode; + graphics.PixelOffsetMode = PixelOffsetMode.Half; + if (landed) + { + // Pixel for pixel, from a whole pixel of it + graphics.InterpolationMode = InterpolationMode.NearestNeighbor; + graphics.DrawImage( + scaled, + bounds, + new Rectangle( + Math.Clamp(Whole(placement.Source.X * size.Width), 0, size.Width - bounds.Width), + Math.Clamp(Whole(placement.Source.Y * size.Height), 0, size.Height - bounds.Height), + bounds.Width, + bounds.Height), + GraphicsUnit.Pixel); + } + else + { + // Whatever was last composed, at the size it was, with the same part of it stretched + // into place: rough for the frame or two until this size lands and repaints + graphics.InterpolationMode = InterpolationMode.Bilinear; + graphics.DrawImage( + scaled, + bounds, + new RectangleF( + placement.Source.X * scaled.Width, + placement.Source.Y * scaled.Height, + placement.Source.Width * scaled.Width, + placement.Source.Height * scaled.Height), + GraphicsUnit.Pixel); + } + + graphics.InterpolationMode = interpolation; + graphics.PixelOffsetMode = offset; + + using var pen = new Pen(Palette.Rule); + graphics.DrawRectangle(pen, bounds.X - 1, bounds.Y - 1, bounds.Width + 1, bounds.Height + 1); + return true; + } + + /// + /// The pixel of a scaled picture that the part showing starts at: the nearest to where the + /// placement puts it, which is anywhere between two. Half way goes up, and so does a little + /// short of half way. A picture centred in its pane starts on a whole pixel or exactly half + /// way to the next, and a drag moves it a pixel at a time from there, so half way is where it + /// stays for the whole of the drag. Rounded on that line it fell either way with whatever the + /// arithmetic left in its last digit, and the picture stood still for one pixel of the drag + /// and jumped two for the next. + /// + static int Whole(float position) => + (int) Math.Floor(position + 0.51f); + + /// + /// The whole picture at , with nothing under it: the checkerboard + /// behind an enlarged picture stays where the pane is while the picture is dragged across it, + /// so it is not part of what is kept. Scaled with the filter the part that shows was drawn + /// with when it was scaled on every paint, so what a reader sees at a size is what they saw. + /// + /// Runs on the pool for the window, as does. + /// + /// + static Bitmap Scale(Image picture, Size size) + { + var scaled = new Bitmap(size.Width, size.Height, PixelFormat.Format32bppPArgb); + using var graphics = Graphics.FromImage(scaled); + graphics.InterpolationMode = InterpolationMode.HighQualityBilinear; + graphics.PixelOffsetMode = PixelOffsetMode.Half; + graphics.DrawImage(picture, new Rectangle(Point.Empty, size)); + return scaled; + } + + /// + /// A picture the reader has zoomed into past half its own size: the part of it that shows, + /// drawn straight from the decoded picture rather than from a copy of the whole of it at that + /// size, which at the last step would be hundreds of megabytes to show one corner. + /// + /// On this thread, on every paint. Past its own size it is the pane's worth of the picture's + /// pixels however far in it is, and between that and half its size no more than four times + /// the pane's worth, where has the rest. A drag asks for a different + /// part on every frame, so there is nothing a cache of what shows could keep. /// /// void DrawEnlarged(Graphics graphics, ImagePane image, PicturePlacement placement) @@ -638,11 +772,13 @@ void DrawEnlarged(Graphics graphics, ImagePane image, PicturePlacement placement placement.Source.Width * picture.Width, placement.Source.Height * picture.Height); - graphics.FillRectangle(Checker(bounds.Location), bounds); + FillChecker(graphics, bounds); var interpolation = graphics.InterpolationMode; var offset = graphics.PixelOffsetMode; // Its pixels as they are once it is past its own size, which is what zooming that far in // is for: smoothed, a one pixel difference between the two sides is a blur on both. + // Short of that, the filter that reads every pixel it reduces. Plain bilinear would do for + // a reduction of under two, and is the one GDI+ takes longer over: 14 ms a pane to 9. graphics.InterpolationMode = placement.Size.Width >= picture.Width ? InterpolationMode.NearestNeighbor : InterpolationMode.HighQualityBilinear; @@ -656,29 +792,30 @@ void DrawEnlarged(Graphics graphics, ImagePane image, PicturePlacement placement } /// - /// The checkerboard as a brush, its squares starting at as the - /// composed one's do. A tile rather than a rectangle a square, because this is filled on every - /// paint of an enlarged picture, where the composed one is drawn once per size. + /// The checkerboard under an enlarged picture, its squares starting at the corner of + /// as the composed one's do, and staying there while the picture is + /// dragged across them. + /// + /// Drawn on every paint of an enlarged picture, where the composed one is drawn once per size. + /// It was a brush tiling one pair of squares for that reason, and the brush was the dear way: + /// GDI+ took over 3 ms to fill a pane from a texture, and takes half a millisecond to fill it + /// with a colour and half its squares with another. Once the picture over it is a copy rather + /// than a rescale, that was most of what a frame of a drag cost. + /// /// - TextureBrush Checker(Point origin) - { - if (checkerBrush is null) - { - using var tile = new Bitmap(checker * 2, checker * 2, PixelFormat.Format32bppPArgb); - using (var graphics = Graphics.FromImage(tile)) - { - DrawChecker(graphics, new(0, 0, checker * 2, checker * 2)); - } - - checkerBrush = new(tile); - } - - checkerBrush.ResetTransform(); - checkerBrush.TranslateTransform(origin.X, origin.Y); - return checkerBrush; - } + void FillChecker(Graphics graphics, Rectangle bounds) => + DrawChecker( + graphics, + bounds, + Painter.Brush(Palette.CheckerLight), + Painter.Brush(Palette.CheckerDark), + ref darkSquares); - TextureBrush? checkerBrush; + /// + /// Where the dark squares of the last checkerboard painted were, kept so that the next paint + /// has somewhere to list its own. + /// + Rectangle[] darkSquares = []; /// /// Where the last paint put each side's picture, or the space one is on its way to. What the @@ -813,7 +950,19 @@ static void DrawChecker(Graphics graphics, Rectangle bounds) { using var light = new SolidBrush(Palette.CheckerLight); using var dark = new SolidBrush(Palette.CheckerDark); + Rectangle[] squares = []; + DrawChecker(graphics, bounds, light, dark, ref squares); + } + + /// + /// One colour over all of it and the other over every second square, those as one list handed + /// over together: a call a square is most of what a square costs. The list is written into + /// , which is made longer when it has to be. + /// + static void DrawChecker(Graphics graphics, Rectangle bounds, Brush light, Brush dark, ref Rectangle[] squares) + { graphics.FillRectangle(light, bounds); + var count = 0; for (var y = bounds.Y; y < bounds.Bottom; y += checker) { for (var x = bounds.X; x < bounds.Right; x += checker) @@ -823,11 +972,21 @@ static void DrawChecker(Graphics graphics, Rectangle bounds) continue; } - graphics.FillRectangle( - dark, - Rectangle.Intersect(new(x, y, checker, checker), bounds)); + if (count == squares.Length) + { + Array.Resize(ref squares, Math.Max(64, count * 2)); + } + + squares[count++] = Rectangle.Intersect(new(x, y, checker, checker), bounds); } } + + // None at all under a picture no larger than one square, and GDI+ takes a list of nothing + // as a mistake rather than as nothing to do + if (count > 0) + { + graphics.FillRectangles(dark, squares.AsSpan(0, count)); + } } void DrawTitle(Graphics graphics, int lineHeight) @@ -911,10 +1070,17 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds) font, Palette.Dim, Cellular(bounds.X, bounds.Y, gutter, bounds.Height)); + // No more of the row than the pane has cells for. GDI+ lays out every character it is + // handed before it clips any of them, so a row handed over whole cost by its length + // rather than by what of it showed: 72 rows of long lines were 18 ms a paint where the 54 + // characters of each that show are 3.5, and every wheel notch and every frame of a + // selection drag is a paint. Cut before it is segmented as well, which walked the whole + // of a row that was not all ASCII: a megabyte of one was 14 ms a row. + var text = RowText.Shown(row.Text, CellsAcross(bounds.Width - gutter)); + // Each segment at its column on the grid rather than the row as one string, so a character // the font draws wider or narrower than a cell moves nothing after it: see CellGrid. A row // of plain text is one segment at column 0, drawn exactly as the whole row was. - var text = RowText.Flatten(row.Text); foreach (var segment in CellGrid.Segments(text)) { var left = bounds.X + gutter + Offset(segment.Column); @@ -925,14 +1091,30 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds) Painter.Draw( graphics, - // No wider than the pane can show in pixels, which no line of characters can exceed - RowText.Clip(text.Substring(segment.Start, segment.Length), bounds.Right - left), + // A character takes its marks into its cell with it, however many it has, so the + // cells a pane holds do not bound what is in them. As many characters as the pane + // is pixels wide does, which only a pile of marks reaches. Not as many as there + // are pixels left of it, which this was: an emoji is two, so one whose first + // column of pixels was the pane's last was cut to nothing, and a joined sequence + // within its own length of the edge lost the last of what it joins + RowText.Clip(text.Substring(segment.Start, segment.Length), bounds.Width), font, Palette.Foreground(row.Kind), Cellular(left, bounds.Y, bounds.Right - left, bounds.Height)); } } + /// + /// How many cells of a row's text pixels show any part of, and one + /// more. A glyph is not confined to its cell: GDI+ fits each to whole pixels, which can start + /// one in the last pixel of the cell before its own, so the cell after the last one showing + /// can still put ink in the pane. With that one kept, the first cell left out starts a whole + /// cell past the edge. And where GDI+ puts a glyph does not depend on what follows it in the + /// string, which is what makes the cut one that cannot be seen. + /// + int CellsAcross(int width) => + Math.Max(0, (int) Math.Ceiling(width / Advance)) + 1; + void DrawRule(Graphics graphics, int top) => graphics.FillRectangle(Painter.Brush(Palette.Rule), padding, top, Width - padding * 2, 1); @@ -1205,7 +1387,6 @@ protected override void Dispose(bool disposing) font.Dispose(); tips.Dispose(); images.Dispose(); - checkerBrush?.Dispose(); } base.Dispose(disposing); diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs index 11ca0f71f..c68e56233 100644 --- a/src/DiffEngineViewer.Windows/ViewerForm.cs +++ b/src/DiffEngineViewer.Windows/ViewerForm.cs @@ -237,7 +237,18 @@ protected override void OnHandleCreated(EventArgs e) if (!sized) { sized = true; - ClientSize = InitialClientSize(ClientSize, DeviceDpi, System.Windows.Forms.Screen.FromControl(this).WorkingArea.Size); + var area = System.Windows.Forms.Screen.FromControl(this).WorkingArea; + ClientSize = InitialClientSize(ClientSize, DeviceDpi, area.Size); + + // Centred again, for the size it has now. WinForms centres a window as it creates it, + // which is before this, so it was centred for the size asked for in logical pixels and + // then grew down and to the right from there: at 150% on a 1080p display the footer + // was under the taskbar, and that was the placement remembered for every run after. + if (StartPosition == FormStartPosition.CenterScreen && + WindowState == FormWindowState.Normal) + { + Location = Centred(area, Size); + } } else if (restored is { } bounds && WindowState == FormWindowState.Normal && @@ -373,7 +384,8 @@ static List WorkingAreas() => /// /// The size asked for is in logical pixels, and the window is per monitor aware, so it is - /// scaled to the display it opens on - once, before it is shown and centred. Unscaled, it was + /// scaled to the display it opens on - once, before it is shown, and centred again for the + /// size that comes to, since WinForms had already centred it for the other. Unscaled, it was /// 1100 by 700 device pixels while the text grew with the display: at 200% each pane had room /// for four characters. Moving to another display afterwards is Windows' to scale. /// @@ -391,6 +403,16 @@ internal static Size InitialClientSize(Size logical, int dpi, Size workingArea) return new(Math.Min(width, maxWidth), Math.Min(height, maxHeight)); } + /// + /// Where a window of sits to be in the middle of + /// . One too large for it starts at the area's top left, so its title + /// bar is still in reach. + /// + internal static Point Centred(Rectangle area, Size size) => + new( + Math.Max(area.X, area.X + (area.Width - size.Width) / 2), + Math.Max(area.Y, area.Y + (area.Height - size.Height) / 2)); + protected override void OnDpiChanged(DpiChangedEventArgs e) { base.OnDpiChanged(e); @@ -484,7 +506,10 @@ public void Raise() Visible = true; if (WindowState == FormWindowState.Minimized) { - WindowState = FormWindowState.Normal; + // Back to what it was minimised from, as the taskbar would put it. Always to normal, + // a window the reader had maximised came back at its restored size, and was then + // remembered as one they had not maximised. + WindowState = maximized ? FormWindowState.Maximized : FormWindowState.Normal; } BringToFront(); @@ -493,10 +518,20 @@ public void Raise() public void Apply(Screen screen) { - // ScreenBuilder allocates a fresh Screen every frame, so record equality would never hit. - // Without this the window repaints sixty times a second while sitting idle. + // The loop hands over the screen it handed over last frame for as long as nothing has + // happened (ScreenCache), which is nearly every frame, and those stop here. + if (ReferenceEquals(last, screen)) + { + return; + } + + // A new screen need not be a different one: a state can change in a way that does not + // show, and record equality stops at the lists, which compare by reference. Without this + // such a frame repainted the whole window. Kept as the last one either way, so the frames + // after it stop at the reference above. if (Same(last, screen)) { + last = screen; return; } diff --git a/src/DiffEngineViewer/AcceptAllRunner.cs b/src/DiffEngineViewer/AcceptAllRunner.cs index 22f1c9677..23ff1b2f8 100644 --- a/src/DiffEngineViewer/AcceptAllRunner.cs +++ b/src/DiffEngineViewer/AcceptAllRunner.cs @@ -52,7 +52,7 @@ public void Start() while (true) { var claimed = host.Mutate(ViewerSession.ClaimNext); - if (claimed.Batch?.Current is not { } entry) + if (claimed.Batch?.Current is null) { return claimed.Message; } @@ -60,11 +60,11 @@ public void Start() Func record; try { - record = ViewerSession.ApplyClaimed(entry, actions); + record = ViewerSession.ApplyClaimed(claimed, actions); } catch (Exception exception) { - record = ViewerSession.FailClaimed(entry, exception.Message); + record = ViewerSession.FailClaimed(claimed, exception.Message); } host.Mutate(record); diff --git a/src/DiffEngineViewer/AcceptBatch.cs b/src/DiffEngineViewer/AcceptBatch.cs index 72e8b8834..265f9ee51 100644 --- a/src/DiffEngineViewer/AcceptBatch.cs +++ b/src/DiffEngineViewer/AcceptBatch.cs @@ -16,6 +16,17 @@ /// How many the batch set out with, for the progress it reports. record AcceptBatch(IReadOnlyList Remaining, int Total) { + /// + /// The keys a group's batch is over, conflicted members included, or null for a batch over the + /// whole queue. "Accept all in" a header is the same batch with fewer entries in it, and what + /// it reports as still needing review is its own members and nobody else's. + /// + public IReadOnlySet? Only { get; init; } + + public bool Covers(string key) => + Only is null || + Only.Contains(key); + /// /// How the snapshots have gone, which is the first half of what the batch says when it is done. /// @@ -38,6 +49,20 @@ record AcceptBatch(IReadOnlyList Remaining, int Total) /// public QueueEntry? Current { get; init; } + /// + /// The other snapshots claimed with : every one the batch still had to + /// do in the same source file, to be written with it in one write. + /// + /// A snapshot applied on its own reads, patches and rewrites its whole source file, and the + /// rewrite is what costs: a file written a moment ago is scanned by whatever watches the drive + /// before the next thing can open it, so five hundred snapshots in one file were half a + /// minute of writes around a second of patching. Claimed together they are one read and one + /// write (), each still with an outcome of its own. So a + /// batch goes a file at a time where its snapshots are, and an entry at a time otherwise. + /// + /// + public IReadOnlyList Together { get; init; } = []; + public AcceptProgress Progress => - new(Total - Remaining.Count - (Current is null ? 0 : 1), Total); + new(Total - Remaining.Count - (Current is null ? 0 : 1 + Together.Count), Total); } diff --git a/src/DiffEngineViewer/CellGrid.cs b/src/DiffEngineViewer/CellGrid.cs index d15c67c0d..f0ea57a14 100644 --- a/src/DiffEngineViewer/CellGrid.cs +++ b/src/DiffEngineViewer/CellGrid.cs @@ -146,14 +146,22 @@ public static int Index(string flattened, int cell) => } /// - /// Printable ASCII throughout, which every head draws one cell a character with nothing to - /// work out. + /// Characters every head draws one cell each with nothing to work out, throughout: a row that + /// is one run, as many cells as it has characters, each character at the cell of its index. + /// Printable ASCII, which is nearly every row, and whatever else the embedded font draws a + /// cell wide, so a row of box drawing costs what a row of letters does rather than a walk + /// through its clusters. /// static bool IsPlain(string text) { foreach (var character in text) { - if (character is < ' ' or > '~') + if (character is >= ' ' and <= '~') + { + continue; + } + + if (!SimpleUnit(character)) { return false; } @@ -162,6 +170,41 @@ static bool IsPlain(string text) return true; } + /// + /// Whether one UTF-16 unit is a character that can be part of a run: see . + /// Never half of a surrogate pair, so a row of these is as many characters as it is long. + /// + static bool SimpleUnit(char character) => + (simple.Value[character >> 6] & (1UL << (character & 63))) != 0; + + // The basic plane's answer to Simple as a bit a character, worked out the first time a row + // that is not ASCII is measured: asking costs a category lookup, a walk of the wide ranges + // and the font's own answer, and every character of every such row asks. + static readonly Lazy simple = new(SimpleUnits); + + static ulong[] SimpleUnits() + { + var units = new ulong[1024]; + for (var value = 0x20; value <= 0xFFFF; value++) + { + if (value is >= 0xD800 and <= 0xDFFF) + { + continue; + } + + var rune = new Rune(value); + // Printable ASCII whatever the font says, since a row of it is drawn as one string + // without asking, by a head left with a font of its own as much as by one with ours + if (value <= 0x7E || + (!ZeroWidth(rune) && !Wide(rune) && FontCoverage.Has(value))) + { + units[value >> 6] |= 1UL << (value & 63); + } + } + + return units; + } + readonly record struct Cluster(int Start, int Length, int Width, bool Simple); static IEnumerable Clusters(string text) @@ -173,8 +216,14 @@ static IEnumerable Clusters(string text) var rune = Read(text, ref index); // A mark with nothing before it for it to sit on still takes a cell, or it could be // neither drawn anywhere nor selected - var width = !ZeroWidth(rune) && Wide(rune) ? 2 : 1; - var simple = Simple(rune); + var mark = ZeroWidth(rune); + var wide = !mark && Wide(rune); + var width = wide ? 2 : 1; + // A surrogate with no partner is read as the replacement character, which the font + // has. What is in the text is still half a pair, which no font has, so it is drawn on + // its own like anything else a head has to find a glyph for. + var lone = char.IsSurrogate(text[start]) && index - start == 1; + var simple = !mark && !wide && !lone && Simple(rune); while (index < text.Length) { var next = index; @@ -223,24 +272,20 @@ UnicodeCategory.EnclosingMark or /// /// A character the embedded monospace font has, and so draws exactly one cell wide in every - /// head, which lets a run of them be drawn as one string: Latin with its extensions, Greek and - /// Cyrillic. Everything else is drawn on its own, at its column, because where a fallback font - /// would put the character after it is not something the grid can know. + /// head, which lets a run of them be drawn as one string: Latin, Greek and Cyrillic as far as + /// the font goes, and its box drawing, arrows, mathematical operators and typographic + /// punctuation. Everything else is drawn on its own, at its column, because where a fallback + /// font would put the character after it is not something the grid can know. + /// + /// Asked of the font () rather than listed. Only of a character that + /// takes one cell: a mark takes none, and a character the grid gives two cells is given them + /// whatever the font would have drawn it in, so neither can be part of a run. + /// /// - static bool Simple(Rune rune) - { - var value = rune.Value; - if (ZeroWidth(rune)) - { - return false; - } - - return value is - >= 0x20 and <= 0x7E or - >= 0xA0 and <= 0x24F or - >= 0x370 and <= 0x3FF or - >= 0x400 and <= 0x52F; - } + static bool Simple(Rune rune) => + rune.IsBmp + ? SimpleUnit((char) rune.Value) + : FontCoverage.Has(rune.Value); /// /// East Asian Wide and Fullwidth, and the emoji blocks, by range. Not the whole Unicode diff --git a/src/DiffEngineViewer/Documents/DocumentWatch.cs b/src/DiffEngineViewer/Documents/DocumentWatch.cs index 75568dee8..fdaea6a04 100644 --- a/src/DiffEngineViewer/Documents/DocumentWatch.cs +++ b/src/DiffEngineViewer/Documents/DocumentWatch.cs @@ -1,14 +1,22 @@ using System.Globalization; +using System.Runtime.ExceptionServices; /// /// Reads the text of the queue's documents and draws their pages, on its own thread, for the entry /// on screen and no other. /// /// Not the entry after it as well, though stepping to it would then find it ready. A call into -/// Morph or PDFium cannot be stopped once it has started, so a document drawn ahead was one the -/// reader then waited behind whenever they picked any other entry, and a core spent on something -/// nobody had opened. One that is stepped to is read and drawn then, with the spinner the heads draw -/// for standing in for its page meanwhile. +/// Morph or PDFium cannot be stopped part way through a conversion or a page, so a document drawn +/// ahead was one the reader then waited behind whenever they picked any other entry, and a core +/// spent on something nobody had opened. One that is stepped to is read and drawn then, with the +/// spinner the heads draw for standing in for its page meanwhile. +/// +/// +/// Both sides of that entry are drawn at once, by a call each. Drawn one after the other, the right +/// pane was a spinner for as long as every page of the left took, and which pages differ was not +/// known until both were done. Two Office files take a core each. Two PDFs take turns at PDFium's +/// lock a page at a time, but it is held only while a page is rasterised: encoding the png is most +/// of what a page costs and is done outside it, so two PDFs also take little longer than one. /// /// /// The other half of 's bargain. Reading a file has to stay cheap, because it @@ -28,9 +36,19 @@ sealed class DocumentWatch(SessionHost host, DocumentPlugin documents) public static TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(200); /// - /// How long one document may take to read or draw before it is reported as failed and left - /// behind. Code in this process cannot be stopped, so this does not stop it: it only keeps the - /// documents after it moving. + /// How long a document may go with nothing coming of it - its text, or its next page - before it + /// is reported as failed and left behind. A call cannot be stopped part way through a page, so + /// this does not stop it: it keeps the documents after it moving, and the call ends where its + /// next page would have landed. + /// + /// Not how long the whole document may take. A long one lands a page every so often for longer + /// than any one limit, and counted from its start it was given up on part way through, with + /// pages still arriving. + /// + /// + /// Counted by each call for itself, since the two sides of an entry are drawn at once: one side + /// landing pages says nothing about the other having stopped. + /// /// public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(2); @@ -41,53 +59,125 @@ sealed class DocumentWatch(SessionHost host, DocumentPlugin documents) public bool Hidden { get; set; } /// - /// Moved on when a job is left behind, so pages it lands afterwards are dropped rather than - /// published over whatever replaced it. + /// How many PDFs that were left behind have not returned yet. PDFium is serialized behind one + /// lock, which a call holds while it rasterises a page, so a PDF started while there is one + /// could wait on that call with nothing to bound it. A count rather than a flag, because both + /// sides of an entry can be left behind, and the first of them to return says nothing about + /// the second. Taken down from the pool as each call returns. /// - int generation; + int pdfiumHeld; /// - /// Set once a PDF has been left behind. PDFium is serialized behind one lock, which the call left - /// behind still holds, so every later PDF would wait on it: they fail at once instead. + /// When a PDF that was left behind last returned, which is when PDFium was last given back. + /// Written from the pool. /// - bool pdfiumHeld; + long pdfiumReleased; + + /// + /// What the last turn that could do nothing said, so that it is said once. + /// + string? said; public void Run(Cancel cancel) { while (!cancel.IsCancellationRequested) { - bool worked; - try + // Straight on while there is work, back to waiting once there is none. + if (Turn() is { } wait) { - worked = !Hidden && Pump(); - } - catch (Exception exception) - { - // As TrackedWatch: on a task nothing awaits, so a fault is said out loud rather than - // leaving documents silently never read. - host.Mutate(_ => _ with - { - Message = $"Could not read the documents: {exception.Message}" - }); - return; + cancel.WaitHandle.WaitOne(wait); } + } + } - // Straight on while there is work, back to the interval once there is none. - if (!worked) - { - cancel.WaitHandle.WaitOne(Interval); - } + /// + /// One turn of the loop: a job, and how long to wait before the next, or null to go straight + /// on. Never throws, whatever the job did. Public for the tests, as is. + /// + /// A turn that fails says why and is tried again. It used to end the loop, since this runs on a + /// task nothing awaits and a fault there has to be said out loud - which it was, once, in a + /// status line the next message replaced, and after it no document was read or drawn until the + /// viewer was restarted. What fails a turn is mostly nothing to do with the document: a copy + /// that could not be written because a scanner had the file or the disk was full, a cache + /// directory something cleared away. + /// + /// + public TimeSpan? Turn() + { + try + { + var worked = !Hidden && Pump(); + Unsay(); + return worked ? null : Interval; + } + catch (Exception exception) + { + Say(Reason(exception)); + } + + // Longer than an idle pass waits: what was in the way is usually still there a moment + // later, and trying again reads and hashes the document before it finds that out. + return Interval * 5; + } + + /// + /// What is in the way of a job, as the status line says it. A job that has only to wait says + /// why in its own words; anything else thrown is something that stopped it being read at all. + /// + static string Reason(Exception exception) => + exception is NotYetException + ? exception.Message + : $"Could not read the documents: {exception.Message}"; + + /// + /// Why a turn could do nothing, in the status line. Once for as long as it stays the reason: the + /// turn comes round again every second, and a message set on each would take the status line + /// from everything else that has something to say there. + /// + void Say(string message) + { + if (message == said) + { + return; + } + + said = message; + host.Mutate(_ => _ with { Message = message }); + } + + /// + /// A turn got through, so whatever was in the way has gone and what was said about it goes + /// too: left there, the status line went on giving a reason for a document that had since been + /// read. Only when it is still what the status line says, since anything the reader has done + /// in between has had its own to say. + /// + void Unsay() + { + if (said is not { } message) + { + return; } + + said = null; + host.Mutate(_ => _.Message == message ? _ with { Message = null } : _); } /// - /// One job: the first thing the entry on screen wants that is not there yet. True when there was - /// one, so the loop goes again without waiting. Public for the tests, which drive it directly - /// rather than waiting on a thread. + /// A job that cannot be started yet, as opposed to one that failed: nothing is recorded against + /// the document, and the next turn tries it again. + /// + sealed class NotYetException(string message) : + Exception(message); + + /// + /// One job: the first thing the entry on screen wants that is not there yet, which is its text + /// and then its pages. True when there was one, so the loop goes again without waiting. Public + /// for the tests, which drive it directly rather than waiting on a thread. /// /// The state is read afresh for every job, so a reader who steps on part way through a document - /// has the next one started as soon as the job in hand is done, rather than after the rest of the - /// one they left. + /// has the next one started as soon as the job in hand is done. Not before: the calls in hand + /// cannot be stopped, and a reader stepping through the queue would leave two more drawing + /// behind them at every entry, each taking its turn at PDFium ahead of the one on screen. /// /// public bool Pump() @@ -140,16 +230,30 @@ bool ReadText(SessionState state, QueueEntry entry) Extraction Extract(DocumentFile side) { + AwaitPdfium(side); if (Copy(side) is not { } source) { return new(null, "the file changed while it was being read."); } string? text = null; - var failure = Run(side.Format, () => text = documents.Text(source)); + string? failure = null; + // Reading text lands nothing, so its wait is counted from the start + var call = new Call(side.Format, _ => failure = _); + call.Start(() => text = documents.Text(source)); + Await([call]); return failure is null ? new(text, null) : new(null, failure); } + /// + /// The pages of both sides, a call each, waited on together. The same bytes on both sides are + /// one drawing. + /// + /// A side that cannot be started yet says nothing about the other, which is drawn all the same. + /// Why it could not be is thrown once the other is done, as it would have been on its own, so + /// the turn comes round again for it. + /// + /// bool Draw(SessionState state, QueueEntry entry) { if (state.Drawing == DrawingView.Text) @@ -157,73 +261,145 @@ bool Draw(SessionState state, QueueEntry entry) return false; } + var sides = new List<(DocumentFile Side, string Key)>(); foreach (var side in Sides(entry)) { if (DocumentPages.Key(side, state.Projection) is { } key && - !state.Renders.ContainsKey(key)) + !state.Renders.ContainsKey(key) && + sides.All(_ => _.Key != key)) { - Render(side, key, state.Projection); - return true; + sides.Add((side, key)); + } + } + + if (sides.Count == 0) + { + return false; + } + + ExceptionDispatchInfo? refused = null; + + Call? Start((DocumentFile Side, string Key) drawing) + { + try + { + return Render(drawing.Side, drawing.Key, state.Projection); + } + catch (Exception exception) + { + if (refused is null) + { + refused = ExceptionDispatchInfo.Capture(exception); + // Said now rather than when the turn hears of it, which is after the other + // side's last page + Say(Reason(exception)); + } + + return null; } } - return false; + if (sides is [var first, var second] && + first.Side.Format == DocumentFormat.Pdf && + second.Side.Format == DocumentFormat.Pdf) + { + // Two PDFs are started one behind the other, the second once the first has landed a + // page. They share PDFium's lock, and when one of them stops inside it, which one is + // told from whose pages stopped first: see LeaveBehind. Started together, neither + // has a page to go by, and a second that stopped inside PDFium at once would leave + // the first, waiting on the lock since it began, as the one that ran out of time. + var lead = Start(first); + Await(lead is null ? [] : [lead], () => Start(second)); + } + else + { + Await([.. sides.Select(Start).OfType()]); + } + + refused?.Throw(); + return true; } + /// + /// Starts drawing one side, and returns the call that is drawing it. Null when there was nothing + /// to start: the file no longer holds the bytes the side describes, which is recorded as why it + /// was not drawn. + /// /// /// What the pages are kept under, which for a map is its hash and the projection it is drawn /// in: the same bytes are drawn again for each one the reader switches to. /// - void Render(DocumentFile side, string key, MapProjection projection) + Call? Render(DocumentFile side, string key, MapProjection projection) { + // Everything that can say "not now" comes before anything is recorded. A rendering marked + // as started is never started again, so one that then could not be - its copy not written, + // PDFium not free - was a spinner turning for as long as its entry stayed in the queue. + AwaitPdfium(side); + if (Copy(side) is not { } source) + { + host.Mutate(_ => ViewerSession.Rendered(_, key, new([], true, "the file changed while it was being drawn."))); + return null; + } + + var directory = Path.GetDirectoryName(source)!; + // A folder per projection the reader chose, under the document's own. A page is a path to + // the heads, which keep what they decoded from one, so the same map drawn another way has + // to be another file rather than the same one rewritten. + if (key != side.Hash) + { + directory = Directory.CreateDirectory(Path.Combine(directory, projection.ToString())).FullName; + } + host.Mutate(_ => ViewerSession.Rendered(_, key, Rendering.Started)); - var token = Volatile.Read(ref generation); var pages = new List(); + var call = new Call(side.Format, Ended, Withdraw); + call.Start(() => documents.Render(source, directory, Landed, projection)); + return call; void Landed(string file) { - if (token != Volatile.Read(ref generation)) + var landed = call.Land(() => { - return; - } - - var page = Page(file); - RenderedPage[] landed; - lock (pages) + pages.Add(Page(file)); + var soFar = pages.ToArray(); + host.Mutate(_ => ViewerSession.Rendered(_, key, new(soFar, false))); + }); + if (!landed) { - pages.Add(page); - landed = pages.ToArray(); + // Nothing more is wanted of a call that was left behind, and between two pages is + // the one place it can be stopped. It used to run on to its last page, and a PDF + // kept every other PDF waiting until it had drawn all of them. + throw new OperationCanceledException($"{Path.GetFileName(side.Path)} was left behind, so no more of it is drawn."); } - - host.Mutate(_ => ViewerSession.Rendered(_, key, new(landed, false))); } - string? failure; - if (Copy(side) is not { } source) + // Both on the loop's thread, once the call has returned or been left behind. Nothing + // lands after either, so the pages are no longer anyone else's to change. + void Ended(string? failure) { - failure = "the file changed while it was being drawn."; + var complete = pages.ToArray(); + host.Mutate(_ => ViewerSession.Rendered(_, key, new(complete, true, failure))); } - else - { - var directory = Path.GetDirectoryName(source)!; - // A folder per projection the reader chose, under the document's own. A page is a - // path to the heads, which keep what they decoded from one, so the same map drawn - // another way has to be another file rather than the same one rewritten. - if (key != side.Hash) - { - directory = Directory.CreateDirectory(Path.Combine(directory, projection.ToString())).FullName; - } - failure = Run(side.Format, () => documents.Render(source, directory, Landed, projection)); - } + void Withdraw() => + host.Mutate(_ => Withdrawn(_, key)); + } - RenderedPage[] complete; - lock (pages) + /// + /// A drawing put back to not having been started. No rendering at all is what says a side is + /// still to be drawn, to and to the spinner alike, so the pages it had go + /// with it and are drawn again from the first. + /// + static SessionState Withdrawn(SessionState state, string key) + { + if (!state.Renders.ContainsKey(key)) { - complete = pages.ToArray(); + return state; } - host.Mutate(_ => ViewerSession.Rendered(_, key, new(complete, true, failure))); + var renders = new Dictionary(state.Renders); + renders.Remove(key); + return state with { Renders = renders }; } /// @@ -246,6 +422,12 @@ static RenderedPage Page(string file) /// The viewer's own copy of a side, made once per content hash, and only when the file still /// holds the bytes the side describes: what is read and drawn is then exactly what the hash /// keys. Null when the file has changed or gone since, which the next read of it will show. + /// + /// Throws when the copy could not be made for any other reason: the file held by something + /// else for a moment, or the cache not written to because a scanner had the new file, the disk + /// was full or the directory had been cleared away. None of those is about the document, so + /// none is recorded against it. says what was thrown and tries again. + /// /// string? Copy(DocumentFile side) { @@ -262,7 +444,7 @@ static RenderedPage Page(string file) bytes = FileSide.ReadBytes(side.Path); } catch (Exception exception) - when (exception is IOException or UnauthorizedAccessException) + when (exception is FileNotFoundException or DirectoryNotFoundException) { return null; } @@ -280,39 +462,269 @@ static RenderedPage Page(string file) } /// - /// One call into the documents assembly, bounded by . Null when it - /// finished, otherwise why it did not. + /// A PDF waits while one that was left behind has not returned, here, where the reader can be + /// told why, rather than on PDFium's lock with nothing to bound it. It is read once every such + /// call has returned. + /// + /// Not a failure of this document, so nothing is recorded against it. It used to be one: every + /// PDF after a slow one failed at once, for the life of the window, PDFium long since free. + /// /// - string? Run(DocumentFormat format, Action job) + void AwaitPdfium(DocumentFile side) { - if (format == DocumentFormat.Pdf && - pdfiumHeld) + if (side.Format == DocumentFormat.Pdf && + Volatile.Read(ref pdfiumHeld) > 0) { - return "an earlier PDF is still being read, and PDFium reads one at a time. Restart the viewer to read PDFs again."; + throw new NotYetException($"{Path.GetFileName(side.Path)} is waiting for an earlier PDF that is still being read, since PDFium reads one at a time. Restart the viewer if that one never finishes."); } + } - // On the pool, whose threads are background ones, so a call left behind cannot keep the - // process alive once the window has closed. - var task = Task.Run(job); - try + /// + /// One call into the documents assembly, and what the loop knows of it from outside: when + /// something last came of it, and whether it has been left behind. A call has these to itself, + /// because the two sides of an entry are drawn at once: one side landing pages says nothing + /// about the other having stopped, and giving up on one must not drop what the other goes on + /// to land. + /// + /// + /// The call finished, with why not when it threw, or was given up on, with that as why. Called + /// on the loop's thread. + /// + /// + /// The call was left behind for no reason of its own, so what it had started is put back + /// rather than failed. Null for a call with nothing to put back. + /// + sealed class Call(DocumentFormat format, Action ended, Action? withdraw = null) + { + readonly Lock gate = new(); + readonly TaskCompletionSource landed = new(TaskCreationOptions.RunContinuationsAsynchronously); + long progressed; + bool leftBehind; + + public DocumentFormat Format => format; + + public Action Ended => ended; + + public Action? Withdraw => withdraw; + + public Task Task { get; private set; } = Task.CompletedTask; + + /// + /// Done once the call has landed its first page. + /// + public Task Landed => landed.Task; + + /// + /// When something last came of it: when it started, then as each page landed. + /// + public long Progressed => Volatile.Read(ref progressed); + + public void Start(Action work) + { + Volatile.Write(ref progressed, Stopwatch.GetTimestamp()); + // On the pool, whose threads are background ones, so a call left behind cannot keep the + // process alive once the window has closed. + Task = Task.Run(work); + } + + /// + /// Something came of the call, which makes known, and the wait + /// for the next thing starts over. False, with nothing published, once the call has been + /// left behind. + /// + /// Under the lock takes, so a page cannot reach the state after + /// its call was reported as given up on. One that did would put the pane back to drawing + /// a document nothing more is coming of. + /// + /// + public bool Land(Action publish) { - if (task.Wait(Timeout)) + lock (gate) { - return null; + if (leftBehind) + { + return false; + } + + publish(); + Volatile.Write(ref progressed, Stopwatch.GetTimestamp()); + } + + landed.TrySetResult(); + return true; + } + + public void LeaveBehind() + { + lock (gate) + { + leftBehind = true; + } + } + } + + /// + /// Waits on calls until each has returned or been left behind, and tells each how it ended as + /// it does, so a side that is done is not held up by one that is not. + /// + /// + /// Starts one more call to wait on, once the first has landed a page, or has returned without + /// one. Never, when the first is left behind before either: what it would start is a PDF, and + /// PDFium is then not free for it. The next turn finds it still to be drawn, and says so. + /// + void Await(IReadOnlyList calls, Func? follow = null) + { + var running = calls.ToList(); + var lead = running.FirstOrDefault(); + while (true) + { + if (follow is not null && + (lead is null || + lead.Landed.IsCompleted || + lead.Task.IsCompleted)) + { + if (follow() is { } followed) + { + running.Add(followed); + } + + follow = null; + } + + if (running.Count == 0) + { + return; + } + + if (running.Find(_ => _.Task.IsCompleted) is { } returned) + { + running.Remove(returned); + returned.Ended(Thrown(returned.Task)); + continue; + } + + // Asked before the clocks are read. A PDF that returns in between has noted when + // before it is counted as gone, so a call is never found out of time against a PDFium + // that was given back that instant. + var pdfiumWasHeld = Volatile.Read(ref pdfiumHeld) > 0; + var (call, remaining) = running + .Select(_ => (Call: _, Remaining: Remaining(_))) + .MinBy(_ => _.Remaining); + if (remaining > TimeSpan.Zero) + { + // Until one of them returns, or the one nearest to running out of time would, or + // the first lands the page another is waiting to be started behind. Any other + // page landing does not end the wait, so how long is left is worked out afresh + // after it. + var wake = running + .Select(_ => _.Task) + .ToList(); + if (follow is not null) + { + wake.Add(lead!.Landed); + } + + Task.WaitAny([.. wake], remaining); + continue; + } + + running.Remove(call); + LeaveBehind(call, pdfiumWasHeld); + if (call == lead) + { + follow = null; } } + } + + /// + /// How long a call has left before it is left behind: from the last thing + /// to come of it. For a PDF, from when PDFium was last given back if that is later, since until + /// then it may have been waiting on the call that had it. + /// + TimeSpan Remaining(Call call) + { + var since = call.Progressed; + if (call.Format == DocumentFormat.Pdf) + { + since = Math.Max(since, Volatile.Read(ref pdfiumReleased)); + } + + return Timeout - Stopwatch.GetElapsedTime(since); + } + + /// + /// Why a call that has returned did not finish, or null when it did. + /// + static string? Thrown(Task task) + { + try + { + task.Wait(); + return null; + } catch (AggregateException exception) { return (exception.InnerException ?? exception).Message; } + } - Interlocked.Increment(ref generation); - if (format == DocumentFormat.Pdf) + /// + /// Stops waiting on a call that has gone with nothing coming of it. It is + /// reported as given up on, what it lands from here on is dropped, and it is stopped where its + /// next page lands. + /// + /// Except a PDF that ran out of time while another, left behind before it, had still not + /// returned. The two sides of an entry take turns at PDFium's lock, so when one of them stops + /// inside PDFium the other stops at the lock, and from here both have only stopped landing + /// pages. The one inside is taken to be the one that ran out of time first: the other was + /// still landing a page, or had only just been started, when the first took the lock, and + /// came to it afterwards. So the second is not given up on. It is put back as though it had + /// not been started, to be drawn again once PDFium is free, with nothing recorded against it, + /// since what stopped it was not about it. + /// + /// + void LeaveBehind(Call call, bool pdfiumWasHeld) + { + call.LeaveBehind(); + var pdf = call.Format == DocumentFormat.Pdf; + if (pdf) { - pdfiumHeld = true; + // Counted first, so a call that returns in between is one the continuation still + // takes off + Interlocked.Increment(ref pdfiumHeld); + } + + call.Task.ContinueWith(_ => Returned(call), TaskScheduler.Default); + + if (pdf && + pdfiumWasHeld && + call.Withdraw is { } withdraw) + { + withdraw(); + return; + } + + call.Ended($"gave up after {Timeout.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} seconds."); + } + + /// + /// A call that was left behind has returned. If it was a PDF, PDFium is free of it, and once + /// every such call has returned PDFs can be read again. + /// + void Returned(Call call) + { + // Read, so a call that ended by throwing is not left as a fault nothing observed. One that + // was stopped where its next page landed always ends that way. + _ = call.Task.Exception; + if (call.Format != DocumentFormat.Pdf) + { + return; } - return $"gave up after {Timeout.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} seconds."; + // Noted before the count comes down, so whoever finds PDFium free also finds since when + Volatile.Write(ref pdfiumReleased, Stopwatch.GetTimestamp()); + Interlocked.Decrement(ref pdfiumHeld); } /// diff --git a/src/DiffEngineViewer/FontCoverage.cs b/src/DiffEngineViewer/FontCoverage.cs new file mode 100644 index 000000000..948797726 --- /dev/null +++ b/src/DiffEngineViewer/FontCoverage.cs @@ -0,0 +1,249 @@ +using System.Buffers.Binary; + +/// +/// Which characters the embedded font draws a cell wide: the ones it has a glyph for, at the +/// advance every other glyph of a monospace font has. Read out of the font's own tables, once. +/// +/// This is what needs to know to draw a run of characters as one string, +/// and it used to be a guess written as four ranges: Latin with its extensions, Greek, Cyrillic. +/// The guess was wrong both ways. It left out everything else the font has, so a row of box +/// drawing, arrows or typographic punctuation was cut into a segment a character, each laid out on +/// its own by every head. And it took in characters the font does not have, a ƀ or a Ѡ, which a +/// head then drew from some other font at some other width in the middle of a run, moving +/// everything after it off the grid. +/// +/// +/// Asked of the bytes rather than written out as a table, so replacing the font file cannot leave +/// a list behind that describes the old one. +/// +/// +static class FontCoverage +{ + static readonly Lazy embedded = new(() => Read(EmbeddedFont.Bytes())); + + /// + /// Whether the embedded font has a glyph for the code point that is one cell wide. + /// + public static bool Has(int codePoint) => + embedded.Value.Has(codePoint); + + /// + /// The basic plane as a bit a code point, which is where nearly everything asked about is, and + /// the few ranges past it in order. + /// + internal sealed class Coverage(ulong[] plane, (int First, int Last)[] beyond) + { + public static readonly Coverage None = new(new ulong[1024], []); + + public bool Has(int codePoint) + { + if (codePoint < 0) + { + return false; + } + + if (codePoint <= 0xFFFF) + { + return (plane[codePoint >> 6] & (1UL << (codePoint & 63))) != 0; + } + + foreach (var (first, last) in beyond) + { + if (codePoint < first) + { + return false; + } + + if (codePoint <= last) + { + return true; + } + } + + return false; + } + } + + /// + /// The coverage of a font file. Nothing, rather than a throw, for bytes that are not a font + /// this can read: every character is then drawn on its own at its column, which is slower and + /// still right. Internal so the tests can hand it bytes that are not the embedded font. + /// + internal static Coverage Read(byte[] font) + { + try + { + return Parse(font); + } + catch (Exception exception) + when (exception is ArgumentOutOfRangeException or IndexOutOfRangeException or InvalidDataException) + { + return Coverage.None; + } + } + + static Coverage Parse(byte[] font) + { + var cmap = Table(font, "cmap"); + var hhea = Table(font, "hhea"); + var hmtx = Table(font, "hmtx"); + // The advances are a list one per glyph for the first so many glyphs, and the last of + // them stands for every glyph after that + var metrics = U16(font, hhea + 34); + if (metrics == 0) + { + throw new InvalidDataException("The font has no horizontal metrics."); + } + + int Advance(int glyph) => + U16(font, hmtx + 4 * Math.Min(glyph, metrics - 1)); + + var plane = new ulong[1024]; + var beyond = new List<(int First, int Last)>(); + var mapped = new List<(int CodePoint, int Glyph)>(); + Mappings(font, cmap, mapped); + var cell = -1; + foreach (var (codePoint, glyph) in mapped) + { + if (codePoint == ' ') + { + cell = Advance(glyph); + } + } + + foreach (var (codePoint, glyph) in mapped) + { + if (glyph == 0 || + Advance(glyph) != cell) + { + continue; + } + + if (codePoint <= 0xFFFF) + { + plane[codePoint >> 6] |= 1UL << (codePoint & 63); + } + else if (beyond.Count > 0 && + beyond[^1].Last == codePoint - 1) + { + beyond[^1] = (beyond[^1].First, codePoint); + } + else + { + beyond.Add((codePoint, codePoint)); + } + } + + return new(plane, beyond.ToArray()); + } + + /// + /// Every code point the font maps and the glyph it maps to, in ascending order, from the + /// widest subtable there is: the one that can name code points past the basic plane when the + /// font has one, and the basic plane's own otherwise. + /// + static void Mappings(byte[] font, int cmap, List<(int CodePoint, int Glyph)> mapped) + { + var tables = U16(font, cmap + 2); + var segmented = -1; + for (var index = 0; index < tables; index++) + { + var subtable = cmap + (int) U32(font, cmap + 4 + index * 8 + 4); + var format = U16(font, subtable); + if (format == 12) + { + Groups(font, subtable, mapped); + return; + } + + if (format == 4) + { + segmented = subtable; + } + } + + if (segmented < 0) + { + throw new InvalidDataException("The font has no character map this reads."); + } + + Segments(font, segmented, mapped); + } + + // Format 12: runs of code points, each run mapped to a run of glyphs. + static void Groups(byte[] font, int subtable, List<(int CodePoint, int Glyph)> mapped) + { + var groups = (int) U32(font, subtable + 12); + for (var index = 0; index < groups; index++) + { + var group = subtable + 16 + index * 12; + var first = (int) U32(font, group); + // No further than Unicode goes, so a table that says otherwise is not walked + var last = (int) Math.Min(U32(font, group + 4), 0x10FFFF); + var glyph = (int) U32(font, group + 8); + for (var codePoint = Math.Max(first, 0); codePoint <= last; codePoint++) + { + mapped.Add((codePoint, glyph + codePoint - first)); + } + } + } + + // Format 4: segments of the basic plane, each mapped by an offset or through a table. + static void Segments(byte[] font, int subtable, List<(int CodePoint, int Glyph)> mapped) + { + var count = U16(font, subtable + 6) / 2; + var ends = subtable + 14; + var starts = ends + count * 2 + 2; + var deltas = starts + count * 2; + var offsets = deltas + count * 2; + for (var index = 0; index < count; index++) + { + var first = U16(font, starts + index * 2); + var last = U16(font, ends + index * 2); + var delta = U16(font, deltas + index * 2); + var offset = U16(font, offsets + index * 2); + for (var codePoint = first; codePoint <= last && codePoint != 0xFFFF; codePoint++) + { + int glyph; + if (offset == 0) + { + glyph = (codePoint + delta) & 0xFFFF; + } + else + { + glyph = U16(font, offsets + index * 2 + offset + (codePoint - first) * 2); + if (glyph != 0) + { + glyph = (glyph + delta) & 0xFFFF; + } + } + + mapped.Add((codePoint, glyph)); + } + } + } + + static int Table(byte[] font, string tag) + { + var tables = U16(font, 4); + for (var index = 0; index < tables; index++) + { + var record = 12 + index * 16; + if (font[record] == tag[0] && + font[record + 1] == tag[1] && + font[record + 2] == tag[2] && + font[record + 3] == tag[3]) + { + return (int) U32(font, record + 8); + } + } + + throw new InvalidDataException($"The font has no {tag} table."); + } + + static int U16(byte[] font, int offset) => + BinaryPrimitives.ReadUInt16BigEndian(font.AsSpan(offset, 2)); + + static uint U32(byte[] font, int offset) => + BinaryPrimitives.ReadUInt32BigEndian(font.AsSpan(offset, 4)); +} diff --git a/src/DiffEngineViewer/ILoopHooks.cs b/src/DiffEngineViewer/ILoopHooks.cs index 9620d0f27..62f5b1025 100644 --- a/src/DiffEngineViewer/ILoopHooks.cs +++ b/src/DiffEngineViewer/ILoopHooks.cs @@ -5,8 +5,10 @@ /// a scrollbar thumb and for moving or sizing the window, and where a logoff ends the session. The /// loop's next step never comes in either case, so the head is handed those steps to run itself. /// -/// Optional, and only WinForms takes it: the native heads pump their own events and have no modal -/// loops to be caught in. +/// Optional, and only WinForms takes it. The native heads pump their own events. The Linux one has +/// no modal loops to be caught in. AppKit has: a menu, which is deliberate, and a window being +/// resized, which still draws the rows sliced for its old size until the mouse comes up. Handing +/// that head a frame from inside the loop would take a callback in the C ABI. /// /// interface ILoopHooks diff --git a/src/DiffEngineViewer/InternalsVisibleTo.cs b/src/DiffEngineViewer/InternalsVisibleTo.cs index ae24ed561..5bc4e1aff 100644 --- a/src/DiffEngineViewer/InternalsVisibleTo.cs +++ b/src/DiffEngineViewer/InternalsVisibleTo.cs @@ -9,3 +9,7 @@ // name. They need it to reach ViewerProgram and their own IViewerWindow, which stay internal // rather than being made public for the sake of a project boundary inside one application. [assembly: InternalsVisibleTo("DiffEngineViewer, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] + +// The benchmarks, which measure the model the way the tests assert it: from inside. +[assembly: InternalsVisibleTo("DiffEngineViewer.Benchmarks, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] +[assembly: InternalsVisibleTo("DiffEngineViewer.Windows.Benchmarks, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] diff --git a/src/DiffEngineViewer/Model/RowText.cs b/src/DiffEngineViewer/Model/RowText.cs index 6a148abaa..43c95a26b 100644 --- a/src/DiffEngineViewer/Model/RowText.cs +++ b/src/DiffEngineViewer/Model/RowText.cs @@ -29,6 +29,47 @@ public static string Clip(string text, int cells) return text[..length]; } + /// + /// The start of a row as a renderer draws it: flattened, and cut where its first + /// cells end. Exactly what flattening the whole row and cutting it at + /// gives, so a wide character, or a character and its marks, is + /// kept whole or not at all, and whatever is segmented and drawn from it lands where it would + /// have from the whole row. + /// + /// For a renderer that knows how many cells its pane holds, where counts + /// characters and is handed a row already flattened. Read from the front and only as far as + /// it takes, because this is asked of every row on every paint and a row can be a megabyte: + /// flattening all of one copies it when it holds a tab, and finding its cells walks all of it + /// when it holds anything but ASCII. + /// + /// + public static string Shown(string text, int cells) + { + // One character past the cells asked for, which is what says the cut has a character + // after it rather than being wherever the text read so far ran out. + var take = Math.Min(Math.Max(cells, 0), text.Length) + 1; + while (take < text.Length) + { + // Not between the halves of a surrogate pair. The first half alone reads as a + // character of its own, where the pair may be a mark on the character before it. + var end = char.IsHighSurrogate(text[take - 1]) ? take + 1 : take; + var start = Flatten(text[..end]); + var cut = CellGrid.Index(start, cells); + if (cut < start.Length) + { + return start[..cut]; + } + + // Not enough yet, which takes characters that fill no cell: marks, joiners, a + // carriage return. Twice as much next, so a row of nothing else costs two reads of + // it at most rather than one for every cell. + take *= 2; + } + + var whole = Flatten(text); + return whole[..CellGrid.Index(whole, cells)]; + } + public static string Flatten(string text) { if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0) diff --git a/src/DiffEngineViewer/Native/ScreenPayload.cs b/src/DiffEngineViewer/Native/ScreenPayload.cs index 84bd14b85..d48bbfc82 100644 --- a/src/DiffEngineViewer/Native/ScreenPayload.cs +++ b/src/DiffEngineViewer/Native/ScreenPayload.cs @@ -21,9 +21,29 @@ sealed class ScreenPayload int menuRow; int menuPane; int pendingCount; + Screen? built; + + /// + /// What was encoded, for the tests: a shim reads these through pointers, and nothing here on + /// the managed side reads them at all. + /// + internal ReadOnlySpan Strings => CollectionsMarshal.AsSpan(strings); + + internal IReadOnlyList Rows => rows; + + internal IReadOnlyList Segments => segments; public void Build(Screen screen) { + // The loop hands over the screen it handed over last frame for as long as nothing has + // happened (ScreenCache), and a screen is immutable, so the buffers already hold this one. + // Encoding it again was most of what an idle frame cost here. + if (ReferenceEquals(screen, built)) + { + return; + } + + built = screen; strings.Clear(); rows.Clear(); segments.Clear(); @@ -220,19 +240,30 @@ DeviewPane AddPane(Pane pane, int columns) /// /// The row's , as byte ranges of the UTF-8 the row's text was /// just written as at , so no text is written twice. + /// + /// Segments come in the order of the text, so where one starts in bytes is where the last one + /// ended plus whatever lies between them, and the row is measured once. Each used to count the + /// row's bytes again from its start, which for a row of CJK, a segment a character, was the + /// row's length squared. + /// /// void AddSegments(string text, int textOffset) { + var measured = 0; + var bytes = textOffset; foreach (var segment in CellGrid.Segments(text)) { - var start = textOffset + Encoding.UTF8.GetByteCount(text.AsSpan(0, segment.Start)); + bytes += Encoding.UTF8.GetByteCount(text.AsSpan(measured, segment.Start - measured)); + var length = Encoding.UTF8.GetByteCount(text.AsSpan(segment.Start, segment.Length)); segments.Add( new() { - TextOffset = start, - TextLength = Encoding.UTF8.GetByteCount(text.AsSpan(segment.Start, segment.Length)), + TextOffset = bytes, + TextLength = length, Column = segment.Column }); + bytes += length; + measured = segment.Start + segment.Length; } } diff --git a/src/DiffEngineViewer/QueueEntry.cs b/src/DiffEngineViewer/QueueEntry.cs index 04ab79260..8b136dca2 100644 --- a/src/DiffEngineViewer/QueueEntry.cs +++ b/src/DiffEngineViewer/QueueEntry.cs @@ -70,6 +70,19 @@ record QueueEntry( ? null : DiffView.Build(DocumentRows.Build(LeftDocument, RightDocument), fold: false).Full; + // Once as well, and copied by a `with` as the views are. Every entry is asked for it each time + // the queue is ordered or projected, and building it is a lower cased path and a string a time. + readonly string? testGroup = Kind == QueueEntryKind.Inline && TestName is not null && Patch is not null + ? $"{Patch.SourceFile.ToLowerInvariant()}|{TestName}" + : null; + + /// + /// What makes two snapshots one test's: the file and the test's name, or null for an entry + /// that is in no test group. One test is one group only within one file, so two tests that + /// merely share a name in different files do not coalesce. + /// + public string? TestGroup => testGroup; + static (DiffView Full, DiffView Minimal) Views( string leftText, string rightText, diff --git a/src/DiffEngineViewer/QueueProjection.cs b/src/DiffEngineViewer/QueueProjection.cs index 45e1300bd..a21d59c4c 100644 --- a/src/DiffEngineViewer/QueueProjection.cs +++ b/src/DiffEngineViewer/QueueProjection.cs @@ -99,7 +99,21 @@ public static IReadOnlyList Order(IReadOnlyList entries) /// The full row list: headers inserted, labels indented, collisions disambiguated, conflicts /// marked. Assumes the queue is already ed, which every mutation ensures. /// - public static IReadOnlyList Rows(SessionState state) + public static IReadOnlyList Rows(SessionState state) => + Rows(state, described: true); + + /// + /// The one walk that decides which rows there are: what gets a header, and what a fold hides. + /// + /// The state whose queue is projected. + /// + /// Whether the rows are to be drawn, and so want their labels, tooltips and group members. Not + /// for , which asks only which entries have a row: that is asked + /// on every step through the queue and after every entry a batch records, and describing every + /// row to answer it was a kilobyte and more an entry each time. The same walk either way, so + /// what is visible cannot be decided twice. + /// + static IReadOnlyList Rows(SessionState state, bool described) { var entries = state.Queue; if (state.Mode == ViewerMode.File || @@ -118,7 +132,7 @@ public static IReadOnlyList Rows(SessionState state) } var showSolutions = solutions.Count >= 2; - var labels = Labels(entries); + var labels = described ? Labels(entries) : null; var rows = new List(); var index = 0; @@ -137,12 +151,15 @@ public static IReadOnlyList Rows(SessionState state) if (header) { var folded = state.Collapsed.Contains(bucketKey); - rows.Add(new($"{Marker(folded)} {bucket} ({bucketEnd - index})", false, null, QueueRowKind.Header) - { - GroupName = bucket, - GroupKey = bucketKey, - GroupMembers = Enumerable.Range(index, bucketEnd - index).ToList() - }); + rows.Add( + described + ? new($"{Marker(folded)} {bucket} ({bucketEnd - index})", false, null, QueueRowKind.Header) + { + GroupName = bucket, + GroupKey = bucketKey, + GroupMembers = Enumerable.Range(index, bucketEnd - index).ToList() + } + : undescribedHeader); if (folded) { @@ -168,12 +185,15 @@ public static IReadOnlyList Rows(SessionState state) { var groupKey = $"test|{group}"; var folded = state.Collapsed.Contains(groupKey); - rows.Add(new($"{indent}{Marker(folded)} {entries[index].TestName} ({groupEnd - index})", false, null, QueueRowKind.Header) - { - GroupName = entries[index].TestName, - GroupKey = groupKey, - GroupMembers = Enumerable.Range(index, groupEnd - index).ToList() - }); + rows.Add( + described + ? new($"{indent}{Marker(folded)} {entries[index].TestName} ({groupEnd - index})", false, null, QueueRowKind.Header) + { + GroupName = entries[index].TestName, + GroupKey = groupKey, + GroupMembers = Enumerable.Range(index, groupEnd - index).ToList() + } + : undescribedHeader); if (folded) { index = groupEnd; @@ -184,13 +204,19 @@ public static IReadOnlyList Rows(SessionState state) { // Under a test header the test name would repeat, so the entry falls back // to its call site — and its tip leaves the name out for the same reason. - rows.Add(EntryRow(entries[index], index, $"{indent} ", entries[index].Name, state, true)); + rows.Add( + labels is null + ? Undescribed(index) + : EntryRow(entries[index], index, $"{indent} ", entries[index].Name, state, true)); } continue; } - rows.Add(EntryRow(entries[index], index, indent, labels[index], state)); + rows.Add( + labels is null + ? Undescribed(index) + : EntryRow(entries[index], index, indent, labels[index], state)); index++; } } @@ -198,6 +224,13 @@ public static IReadOnlyList Rows(SessionState state) return rows; } + // What a walk that is not to be drawn puts where a row would be: that there is one, and for + // an entry which. + static readonly QueueItem undescribedHeader = new("", false, null, QueueRowKind.Header); + + static QueueItem Undescribed(int index) => + new("", false, null, QueueRowKind.Entry, index); + /// /// A disclosure marker, in both states. One that appeared only when folded would leave nothing /// on screen saying a group can be folded at all. @@ -217,7 +250,7 @@ static string Marker(bool collapsed) => public static List VisibleEntries(SessionState state) { var visible = new List(); - foreach (var row in Rows(state)) + foreach (var row in Rows(state, described: false)) { if (row.EntryIndex >= 0) { @@ -344,13 +377,11 @@ static QueueItem EntryRow( } /// - /// One test is one group only within one file: two tests that merely share a name in - /// different files must not coalesce. + /// See , which works it out once an entry rather than once + /// an asking. /// static string? TestGroup(QueueEntry entry) => - entry is { Kind: QueueEntryKind.Inline, TestName: not null, Patch: not null } - ? $"{entry.Patch.SourceFile.ToLowerInvariant()}|{entry.TestName}" - : null; + entry.TestGroup; /// /// The path an entry's label can be grown from: the source file for an inline entry, and the diff --git a/src/DiffEngineViewer/ScreenCache.cs b/src/DiffEngineViewer/ScreenCache.cs new file mode 100644 index 000000000..643bd9899 --- /dev/null +++ b/src/DiffEngineViewer/ScreenCache.cs @@ -0,0 +1,36 @@ +/// +/// The screen for the session's state, built once per state rather than once per frame. +/// +/// The loop presents sixty times a second, and a state changes when something happens, which for a +/// window somebody is reading is now and then. is immutable and only +/// ever replaced, so the same reference is the same screen. Building it again every frame was the +/// whole queue's labels, groups and tooltips, and a walk over every selected row, to arrive at the +/// screen already in hand: 2,000 pending entries were two megabytes of garbage a frame, from a +/// window nobody was touching. +/// +/// +/// Handing back the same is also how a head learns that nothing changed +/// without comparing two of them or encoding one: the WinForms head and +/// both stop at the reference. +/// +/// +/// One thread's. The loop and a head's modal loop both run on the thread that owns the window. +/// +/// +sealed class ScreenCache +{ + SessionState? state; + Screen? screen; + + public Screen For(SessionState current) + { + if (screen is null || + !ReferenceEquals(state, current)) + { + screen = ScreenBuilder.Build(current); + state = current; + } + + return screen; + } +} diff --git a/src/DiffEngineViewer/SelectionText.cs b/src/DiffEngineViewer/SelectionText.cs index 32682f24d..24eff5f34 100644 --- a/src/DiffEngineViewer/SelectionText.cs +++ b/src/DiffEngineViewer/SelectionText.cs @@ -137,10 +137,16 @@ public static string All(QueueEntry entry, PaneSide side) => /// selection: the heads that can draw a highlight also draw this, and the one that cannot /// still says a selection exists and how much of one. /// - /// Counted from the spans rather than by building the text, because this runs every frame for - /// as long as a selection exists: ctrl+a over a large file built megabytes of string sixty - /// times a second only to measure it. The counts are what would produce - one - /// line per non-filler row, joined by one newline each. + /// Counted from the spans rather than by building the text, because this runs for every screen + /// built while a selection exists, which a drag makes one a frame: ctrl+a over a large file + /// built megabytes of string only to measure it. The counts are what would + /// produce - one line per non-filler row, joined by one newline each. + /// + /// + /// Only the two rows a selection ends in are measured against its columns. Every row between + /// them is selected whole, so what it adds is the characters it flattens to, which can be + /// counted off the row as it is. Flattening each of them, twice, to find the span that covered + /// all of it was what made a selection of 100,000 lines cost a whole frame to describe. /// /// public static string Summary(TextSelection selection, QueueEntry entry) @@ -159,6 +165,13 @@ public static string Summary(TextSelection selection, QueueEntry entry) } lines++; + if (index > startRow && + index < endRow) + { + length += FlattenedCharacters(row.Text); + continue; + } + // Characters rather than cells, which a wide character is two of var text = RowText.Flatten(row.Text); var span = Span(selection, selection.Side, index, row.Text); @@ -217,6 +230,30 @@ static int Characters(string text, int from, int to) return count; } + /// + /// The code points would leave of a row, without making the row: + /// a tab is the four spaces it is drawn as, a carriage return is dropped, and a line feed is + /// the space it becomes. + /// + static int FlattenedCharacters(string text) + { + var count = 0; + foreach (var character in text) + { + if (character == '\t') + { + count += 4; + } + else if (character != '\r' && + !char.IsLowSurrogate(character)) + { + count++; + } + } + + return count; + } + static int ClampRow(int row, IReadOnlyList rows) => Math.Clamp(row, 0, Math.Max(0, rows.Count - 1)); diff --git a/src/DiffEngineViewer/TrackedWatch.cs b/src/DiffEngineViewer/TrackedWatch.cs index abdc99f57..f2fc67a01 100644 --- a/src/DiffEngineViewer/TrackedWatch.cs +++ b/src/DiffEngineViewer/TrackedWatch.cs @@ -13,9 +13,12 @@ /// image snapshots is not work to do between two frames. /// /// -/// Deliberately not a file system watcher. The stat is what the attached path already pays, it -/// needs no handle per directory and no debounce, and a queue is small enough that the difference -/// is not measurable. +/// Deliberately not a file system watcher. The stat is what the attached path already pays, and +/// it needs no handle per directory and no debounce. What it costs is kept small instead: a pass +/// looks at the entry on screen and at of the others, taking those in turn, +/// and the passes come a second apart while the window is hidden. A thousand pending pairs, which +/// one serializer setting can produce, were two thousand stats five times a second for as long as +/// the viewer ran, and behind a tray that is days. /// /// sealed class TrackedWatch(SessionHost host, DocumentPlugin? documents = null) @@ -26,11 +29,33 @@ sealed class TrackedWatch(SessionHost host, DocumentPlugin? documents = null) /// public static TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(200); + /// + /// Whether the window is hidden, set by the render loop as it hides and shows it. Nobody is + /// reading a hidden window's rows, so its files are looked at as often as an attached viewer + /// lists its owner while hidden. Not stopped, because an entry whose received file has gone + /// is still pending for as long as it is in the queue, and the queue is what gets staged. + /// + public bool Hidden { get; set; } + + public static TimeSpan HiddenInterval { get; set; } = OwnerLink.HiddenInterval; + + /// + /// How many pending files one pass looks at, besides the one on screen. A queue of no more + /// than this is looked at whole every pass, as every queue used to be. A longer one is looked + /// at this many at a time, so a row that is not on screen follows its file within a few + /// seconds rather than within one pass. + /// + public int Budget { get; init; } = 100; + + // Where in the queue the next pass takes up. A position rather than a key, so an entry that + // leaves moves it by one, which costs some entry a pass and no more. + int next; + public void Run(Cancel cancel) { while (!cancel.IsCancellationRequested) { - cancel.WaitHandle.WaitOne(Interval); + cancel.WaitHandle.WaitOne(Hidden ? HiddenInterval : Interval); if (cancel.IsCancellationRequested) { return; @@ -63,22 +88,39 @@ public void Run(Cancel cancel) /// under the same key in between: dropped by key, the new pair went with the old one's missing /// file, and stayed gone until the test failed again. /// + /// + /// The entry on screen every pass, since it is the one being read, and then the others from + /// where the last pass left off, until of them have been looked at or + /// the queue has been gone round once. + /// /// public void Pump() { var gone = new List(); var changed = new List<(QueueEntry Seen, QueueEntry Fresh)>(); - foreach (var entry in host.State.Queue) + // One read, so the entry on screen and the queue it is in are of the same moment + var state = host.State; + var queue = state.Queue; + var current = state.Current; + if (current is not null) { - if (entry.Kind == QueueEntryKind.Move) - { - Move(entry, gone, changed); - continue; - } + Look(current, gone, changed); + } + + if (next >= queue.Count) + { + next = 0; + } - if (entry.Kind == QueueEntryKind.Delete) + var looked = 0; + for (var visited = 0; visited < queue.Count && looked < Budget; visited++) + { + var entry = queue[next]; + next = (next + 1) % queue.Count; + if (!ReferenceEquals(entry, current) && + Look(entry, gone, changed)) { - Delete(entry, gone, changed); + looked++; } } @@ -91,6 +133,27 @@ public void Pump() host.Mutate(_ => ViewerSession.Refresh(_, gone, changed)); } + /// + /// Whether the entry is one with files to look at, which is what a pass has a budget of. A + /// snapshot has none: it is in the queue as the patch it arrived as. + /// + bool Look(QueueEntry entry, List gone, List<(QueueEntry Seen, QueueEntry Fresh)> changed) + { + if (entry.Kind == QueueEntryKind.Move) + { + Move(entry, gone, changed); + return true; + } + + if (entry.Kind == QueueEntryKind.Delete) + { + Delete(entry, gone, changed); + return true; + } + + return false; + } + readonly ReadRetry retry = new(); void Move(QueueEntry entry, List gone, List<(QueueEntry Seen, QueueEntry Fresh)> changed) diff --git a/src/DiffEngineViewer/ViewerActions.cs b/src/DiffEngineViewer/ViewerActions.cs index 7ffcd9034..8bff7fc80 100644 --- a/src/DiffEngineViewer/ViewerActions.cs +++ b/src/DiffEngineViewer/ViewerActions.cs @@ -24,13 +24,43 @@ record ViewerActions( /// public Action DeleteFile { get; init; } = Missing; + /// + /// Several snapshots of one source file, written with one read and one write, and an outcome + /// for each in the order they were given. What a batch calls for the snapshots it claimed + /// together: see . + /// + /// Null applies them one at a time through , which is what a caller + /// that supplied only that gets: a test's applier is asked about every snapshot, whether or + /// not a batch took them together. + /// + /// + public Func, IReadOnlyList>? ApplyInlineTogether { get; init; } + + public IReadOnlyList ApplyTogether(IReadOnlyList patches) + { + if (ApplyInlineTogether is { } together && + patches.Count > 1) + { + return together(patches); + } + + var results = new List(patches.Count); + foreach (var patch in patches) + { + results.Add(ApplyInline(patch)); + } + + return results; + } + public static readonly ViewerActions Real = new( InlineApplier.Apply, static (source, destination) => File.Copy(source, destination, true), RevealFile.Show) { MoveFile = Move, - DeleteFile = File.Delete + DeleteFile = File.Delete, + ApplyInlineTogether = InlineApplier.ApplyAll }; /// diff --git a/src/DiffEngineViewer/ViewerPreferences.cs b/src/DiffEngineViewer/ViewerPreferences.cs index a76b29a1d..68db014f7 100644 --- a/src/DiffEngineViewer/ViewerPreferences.cs +++ b/src/DiffEngineViewer/ViewerPreferences.cs @@ -17,13 +17,24 @@ sealed class ViewerPreferences { readonly string? path; readonly Lock gate = new(); - Dictionary values; + + /// + /// What this viewer holds: what the file said when it started, and what it has set since. Its + /// own view rather than the file's, which another viewer can have changed. + /// + readonly Dictionary values; + + /// + /// The keys set here that are not in the file yet, because the write that would have put them + /// there failed. Empty whenever the file can be written. + /// + readonly HashSet unsaved = new(StringComparer.Ordinal); /// The file to keep them in, or null to keep them for this process only. public ViewerPreferences(string? path = null) { this.path = path; - values = Read(); + values = Read() ?? new(StringComparer.Ordinal); } /// @@ -53,6 +64,14 @@ public static ViewerPreferences ForUser() /// /// Null forgets the key. Nothing is written when the value is the one already held, which is /// every close of a window nobody moved. + /// + /// What is written is the file as it is now with this one key changed, since two viewers can + /// be open at once and each knows only what the file said when it started. Everything held + /// here used to be laid back over the file as well, wherever the file had no value for it - + /// and a setting at its default is stored as no value. So one viewer putting the projection + /// back to automatic was undone by the next thing the other remembered, its window's position + /// say, which wrote back the projection it had read hours before. + /// /// public void Set(string key, string? value) { @@ -63,25 +82,33 @@ public void Set(string key, string? value) return; } - // Read again first, so what another viewer wrote since this one started is kept: two - // can be open at once, and each writes only the keys it changed. - var merged = Read(); - foreach (var (held, heldValue) in values) - { - merged.TryAdd(held, heldValue); - } + Store(values, key, value); + unsaved.Add(key); - if (value is null) + // With no file to read - none kept, or one that cannot be read just now - what is + // held here is all there is to go on + var merged = Read() ?? new(values, StringComparer.Ordinal); + foreach (var name in unsaved) { - merged.Remove(key); + Store(merged, name, values.GetValueOrDefault(name)); } - else + + if (Write(merged)) { - merged[key] = value; + unsaved.Clear(); } + } + } - values = merged; - Write(); + static void Store(Dictionary target, string key, string? value) + { + if (value is null) + { + target.Remove(key); + } + else + { + target[key] = value; } } @@ -196,14 +223,19 @@ public void Remember(SessionState state) return null; } - Dictionary Read() + /// + /// The file as it is now. Empty when there is no file, which is nothing remembered. Null when + /// there is nowhere to keep one, or when it could not be read, which says nothing about what + /// it holds. + /// + Dictionary? Read() { - var read = new Dictionary(StringComparer.Ordinal); if (path is null) { - return read; + return null; } + var read = new Dictionary(StringComparer.Ordinal); string[] lines; try { @@ -217,7 +249,7 @@ Dictionary Read() catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - return read; + return null; } foreach (var line in lines) @@ -234,13 +266,13 @@ Dictionary Read() /// /// Beside the file and moved over it, so a viewer killed part way through leaves the last - /// whole file rather than half of this one. + /// whole file rather than half of this one. False when it could not be written. /// - void Write() + bool Write(Dictionary content) { if (path is null) { - return; + return true; } // Named for the process, so two viewers writing at once do not write the same one @@ -250,10 +282,11 @@ void Write() Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllLines( temp, - values + content .OrderBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => $"{_.Key}={_.Value}")); File.Move(temp, path, overwrite: true); + return true; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) @@ -266,6 +299,8 @@ void Write() when (cleanup is IOException or UnauthorizedAccessException) { } + + return false; } } } diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs index fa1b42a29..f5f5e5771 100644 --- a/src/DiffEngineViewer/ViewerProgram.cs +++ b/src/DiffEngineViewer/ViewerProgram.cs @@ -285,9 +285,11 @@ internal static int Run( return 4; } + // One for the loop and for a head's modal loop, which draw the same window on one thread. + var screens = new ScreenCache(); if (window is ILoopHooks hooks) { - hooks.Frame = () => ModalFrame(host, window, link, preferences); + hooks.Frame = () => ModalFrame(host, window, link, preferences, screens); hooks.SessionEnding = () => { host.Mutate(_ => _ with {Closing = true}); @@ -310,9 +312,10 @@ internal static int Run( : Task.Run(() => link.Run(cancel.Token), Cancel.None); // Only for a queue this process owns. A displayed one is re-read by OwnerLink already, and // its files belong to the owner, which is what decides when an entry stops being pending. - var watching = server is null + var watch = server is null ? null : new TrackedWatch(host, documents); + var watching = watch is null ? null - : Task.Run(() => new TrackedWatch(host, documents).Run(cancel.Token), Cancel.None); + : Task.Run(() => watch.Run(cancel.Token), Cancel.None); // Whoever owns the queue: every viewer reads and draws the documents it shows itself, since // the files are on this machine and the wire carries only their paths. var reader = documents is null ? null : new DocumentWatch(host, documents); @@ -326,7 +329,7 @@ internal static int Run( { using (window) { - Loop(host, window, link, reader, windowCommands, runner, preferences); + Loop(host, window, link, reader, watch, windowCommands, runner, preferences, screens); // While there is still a window to ask. Not from the finally: a loop that threw // has a window in no state worth opening the next one from. Remember(window, preferences); @@ -367,7 +370,7 @@ internal static int Run( /// A frame from inside a head's modal loop (): what the loop does /// with input, without the present, which the head is already inside of. /// - static Screen ModalFrame(SessionHost host, IViewerWindow window, OwnerLink? link, ViewerPreferences preferences) + static Screen ModalFrame(SessionHost host, IViewerWindow window, OwnerLink? link, ViewerPreferences preferences, ScreenCache screens) { var state = host.State; var input = window.Poll(); @@ -377,7 +380,7 @@ static Screen ModalFrame(SessionHost host, IViewerWindow window, OwnerLink? link preferences.Remember(state); } - return ScreenBuilder.Build(state); + return screens.For(state); } /// @@ -417,9 +420,11 @@ static void Loop( IViewerWindow window, OwnerLink? link, DocumentWatch? reader, + TrackedWatch? watch, ConcurrentQueue windowCommands, AcceptAllRunner? runner, - ViewerPreferences preferences) + ViewerPreferences preferences, + ScreenCache screens) { while (true) { @@ -436,6 +441,7 @@ static void Loop( // A focus shows the window as well as raising it link?.Hidden = hide; reader?.Hidden = hide; + watch?.Hidden = hide; if (command == WindowCommand.Focus) { @@ -461,8 +467,10 @@ static void Loop( return; } + // The screen of the state last presented, for as long as that is still the state: see + // ScreenCache. A frame in which nothing happened builds nothing. var state = host.State; - if (!window.Present(ScreenBuilder.Build(state))) + if (!window.Present(screens.For(state))) { return; } @@ -521,6 +529,7 @@ static void Loop( window.SetHidden(true); link?.Hidden = true; reader?.Hidden = true; + watch?.Hidden = true; continue; } @@ -700,11 +709,19 @@ static SessionState Dispatch(SessionState state, Command command, OwnerLink? lin if (link is null) { // Only begun here. Applying every entry inside this frame held the window for as long - // as the queue was long; the loop hands the batch to a worker instead. - if (command.Kind == CommandKind.AcceptAll && - state.Mode == ViewerMode.Inline) + // as the queue was long; the loop hands the batch to a worker instead. A group's + // accept the same, since in a queue of one solution its header's group is the queue. + if (state.Mode == ViewerMode.Inline) { - return ViewerSession.BeginAcceptAll(state); + if (command.Kind == CommandKind.AcceptAll) + { + return ViewerSession.BeginAcceptAll(state); + } + + if (command.Kind == CommandKind.AcceptGroup) + { + return ViewerSession.BeginAcceptGroup(state); + } } return ViewerSession.Apply(state, command, ViewerActions.Real); diff --git a/src/DiffEngineViewer/ViewerSession.cs b/src/DiffEngineViewer/ViewerSession.cs index 6f1b58647..514bba579 100644 --- a/src/DiffEngineViewer/ViewerSession.cs +++ b/src/DiffEngineViewer/ViewerSession.cs @@ -42,16 +42,21 @@ public static SessionState EnqueueInline(SessionState state, InlinePatch patch) var selected = current is null ? 0 : IndexOf(queue, current.Key); if (selected < 0) { - selected = 0; + // Enqueueing takes no entry away, so one that is no longer under its key has gone with + // its call site to the line this patch reports: an accept above it moved the call, and + // the queue took the re-run for what it was (InlineQueue.Enqueue). It is still the + // entry being read, and the first in the list is not + selected = Math.Max(IndexOf(queue, key), 0); } // Start the reader over, at the first change, only when the text under them changed. // Folding into an entry further down the list is not it, and neither is a re-send of what // is already there: Fold reports an identical patch as unchanged and Project hands back // the same entry, so a continuous runner re-sending the same failing snapshot every few - // seconds used to bounce the reader to the top on every run. + // seconds used to bounce the reader to the top on every run. Asked of the entry rather + // than of the key, since a patch can reach the entry on screen under a key that is not the + // patch's own: the one it had before its call site moved var replaced = current is not null && - current.Key == key && !ReferenceEquals(queue[selected], current); var next = state with @@ -140,6 +145,12 @@ public static SessionState Settle(SessionState state, string key, string? origin /// does that on the listener thread, which is the same seam /// takes the tray's through. /// + /// + /// A re-send of what is already queued leaves the reader where they are, as + /// does. A test that keeps failing the same way sends its pair on + /// every run, and each one used to open the entry again: back to its first change and its + /// first page, fitted, with the menu closed. + /// /// public static SessionState EnqueueTracked(SessionState state, QueueEntry entry) { @@ -149,6 +160,13 @@ public static SessionState EnqueueTracked(SessionState state, QueueEntry entry) return state; } + var existing = IndexOf(state.Queue, entry.Key); + if (existing >= 0 && + SameContent(state.Queue[existing], entry)) + { + return Restaged(state, existing, entry); + } + var replacedCurrent = state.Current?.Key == entry.Key; var kept = state.Queue.Where(_ => _.Key != entry.Key); var queue = QueueProjection.Order([..kept, entry]); @@ -172,6 +190,77 @@ public static SessionState EnqueueTracked(SessionState state, QueueEntry entry) return Clamp(next); } + /// + /// The entry already queued for a pair that arrived again saying the same thing, with the + /// files' new stamps and nothing else about the window changed. + /// + /// A new entry all the same, never the one that was there. applies + /// what a pass found by reference, and a pass that looked while the run had cleared its + /// received file found it gone: that must not take the pair the run has since staged again. + /// + /// + /// The queued entry's content rather than the arrival's, because it can be further along: a + /// document's text is read after it arrives, and the arrival may not have it yet. + /// + /// + static SessionState Restaged(SessionState state, int index, QueueEntry entry) + { + var queue = new List(state.Queue); + queue[index] = queue[index] with + { + LeftStamp = entry.LeftStamp, + RightStamp = entry.RightStamp + }; + return Clamp(state with + { + Queue = queue, + // As any arrival: a reason to stay + Exit = false + }); + } + + /// + /// Whether two entries for one key show the same thing: the same files, holding the same + /// text, pictures or documents. Stamps are left out, since a run that rewrites a file with + /// what it already held changes those and nothing a reader can see. + /// + static bool SameContent(QueueEntry queued, QueueEntry arrived) => + queued.Kind == arrived.Kind && + queued.Name == arrived.Name && + queued.Solution == arrived.Solution && + queued.LeftFile == arrived.LeftFile && + queued.TargetFile == arrived.TargetFile && + queued.LeftHeader == arrived.LeftHeader && + queued.RightHeader == arrived.RightHeader && + queued.Warning == arrived.Warning && + SameSide(queued.LeftText, queued.LeftImage, queued.LeftDocument, arrived.LeftText, arrived.LeftImage, arrived.LeftDocument) && + SameSide(queued.RightText, queued.RightImage, queued.RightDocument, arrived.RightText, arrived.RightImage, arrived.RightDocument); + + static bool SameSide( + string text, + ImageFile? image, + DocumentFile? document, + string arrivedText, + ImageFile? arrivedImage, + DocumentFile? arrivedDocument) + { + if (document is { } held && + arrivedDocument is { } sent) + { + // By its bytes. Its text follows from them, and is read after the entry arrives, so + // one of the two may hold it while the other is still waiting for it. + return held.Path == sent.Path && + held.Format == sent.Format && + held.Length == sent.Length && + held.Hash == sent.Hash; + } + + return document is null && + arrivedDocument is null && + image == arrivedImage && + text == arrivedText; + } + /// /// Replaces the queue with what its owner reports, for a viewer that is displaying rather /// than owning: the inline entries plus the owner's tracked moves and deletes, already @@ -207,6 +296,13 @@ public static SessionState Sync( var key = state.Current?.Key; var selected = key is null ? -1 : IndexOf(queue, key); + if (selected < 0) + { + // Gone from under its key is not always gone. An accept above a call site moves it, + // and the owner takes its entry to the line the re-run reports it at + selected = IndexOfMoved(queue, state.Current); + } + var next = state with { Queue = queue, @@ -693,76 +789,72 @@ static SessionState AcceptInline(SessionState state, ViewerActions actions) } /// - /// Accepts every member of the group a header's menu described, skipping conflicted entries - /// the way accept-all does. By key rather than index, because each accept rebuilds the queue - /// underneath the next. + /// A whole group accept in one call, for a caller that already holds the state: what + /// is to an accept-all, and the same batch underneath. + /// + static SessionState AcceptGroup(SessionState state, MenuState menu, ViewerActions actions) + { + // For the reason AcceptAllInline gives + if (state.Batch is not null) + { + return state; + } + + state = BeginAcceptGroup(state, menu); + while ((state = ClaimNext(state)).Batch?.Current is not null) + { + state = ApplyClaimed(state, actions)(state); + } + + return state; + } + + /// + /// Starts an accept of every member of the group the open menu's header describes, without + /// applying anything yet: an accept-all over those members and nothing else, carried out the + /// way says. The state as it is, less the menu, when there is no + /// group to accept. /// - /// A solution header spans tracked moves and deletes as well as snapshots, so the sweep does + /// It used to be one transition that applied every member before it returned, on the render + /// thread and under the lock every arrival waits on. In a queue of one solution that header's + /// group is the whole queue, so it was the freeze the batch was written to remove. + /// + /// + /// A solution header spans tracked moves and deletes as well as snapshots, so the batch does /// too. Skipping them would make "Accept all in ..." quietly mean "accept the snapshots in /// ...", which is the divergence the unqualified accept-all already avoids. /// + /// + /// Being the same batch, it goes by the batch's rules: a snapshot the applier would not take + /// stays in the queue with what the applier said, where a group accept used to drop it as a + /// single accept does. That holds this group's deletes either way, and counts only this + /// group's own attempts in doing so. + /// /// - static SessionState AcceptGroup(SessionState state, MenuState menu, ViewerActions actions) + public static SessionState BeginAcceptGroup(SessionState state) { - var all = Members(state, menu); - var members = all - .Where(_ => _.Kind == QueueEntryKind.Inline) - .ToList(); - var queue = Pending(state); - var accepted = 0; - var notWritten = 0; - var failed = 0; - var conflicted = 0; - string? failure = null; - foreach (var member in members) + if (state.Menu is not { } menu) { - if (member.Conflicted) - { - conflicted++; - continue; - } - - var before = queue.Count; - // The applier's own answer, kept as it goes past. A group accept goes one entry at a - // time, so a stale one leaves the queue here the way a single accept does, and the - // count of accepts would otherwise include a snapshot written nowhere - which is - // exactly what the sweep below must not take as licence to delete anything. - InlineApplyResult? applied = null; - queue = queue.Accept(member.Key, patch => applied = actions.ApplyInline(patch), out var outcome); - if (queue.Count < before) - { - if (applied?.Status == InlineApplyStatus.NotFound) - { - notWritten++; - failure = outcome; - continue; - } - - accepted++; - continue; - } + return state; + } - if (outcome is not null) - { - failed++; - failure = outcome; - } + state = state with { Menu = null }; + if (state.Mode != ViewerMode.Inline) + { + return state; } - return SweepTracked( - state, - Rebuild(state, queue), - // The wording accept-all uses, from where accept-all gets it, so a group sweep and a - // full sweep cannot read differently - InlineQueue.AcceptAllMessage(accepted, notWritten, failed, conflicted, failure), - actions, - discarding: false, - TrackedKeysOf(all), - // Its own members only: a stale one has left the queue, and one that failed is still - // in it, beside other entries' statuses from other accepts - refused: notWritten + failed > 0); + return BeginAcceptGroup(state, menu); } + // By key rather than index, because each accept rebuilds the queue underneath the next. + static SessionState BeginAcceptGroup(SessionState state, MenuState menu) => + BeginAccept( + state, + Members(state, menu) + .Select(_ => _.Key) + .ToList()); + static SessionState DiscardGroup(SessionState state, MenuState menu, ViewerActions actions) { var all = Members(state, menu); @@ -776,12 +868,11 @@ static SessionState DiscardGroup(SessionState state, MenuState menu, ViewerActio queue = queue.Discard(key, out _); } - return SweepTracked( + return DiscardTrackedIn( state, Rebuild(state, queue), $"Discarded {keys.Count}", actions, - discarding: true, TrackedKeysOf(all)); } @@ -888,9 +979,9 @@ static SessionState AcceptAllInline(SessionState state, ViewerActions actions) } state = BeginAcceptAll(state); - while ((state = ClaimNext(state)).Batch?.Current is { } entry) + while ((state = ClaimNext(state)).Batch?.Current is not null) { - state = ApplyClaimed(entry, actions)(state); + state = ApplyClaimed(state, actions)(state); } return state; @@ -907,7 +998,20 @@ static SessionState AcceptAllInline(SessionState state, ViewerActions actions) /// honest way to pick a side. They are counted into the message once the batch has finished. /// /// - public static SessionState BeginAcceptAll(SessionState state) + public static SessionState BeginAcceptAll(SessionState state) => + BeginAccept(state, null); + + /// + /// A batch over the whole queue or over some of it. One method with a name of its own, rather + /// than an optional argument on , so that one is still a + /// transition a host can be handed as it is. + /// + /// The state to start it in. + /// + /// The keys to accept, for a group header acting on its own members. Null accepts everything, + /// which is what the unqualified accept-all means. + /// + static SessionState BeginAccept(SessionState state, IReadOnlyCollection? only) { if (state.Mode != ViewerMode.Inline || state.Batch is not null) @@ -915,10 +1019,15 @@ public static SessionState BeginAcceptAll(SessionState state) return state; } + var batch = new AcceptBatch([], 0) + { + Only = only?.ToHashSet() + }; var keys = new List(); foreach (var entry in state.Queue) { - if (entry is { Kind: QueueEntryKind.Inline, Conflicted: false }) + if (entry is { Kind: QueueEntryKind.Inline, Conflicted: false } && + batch.Covers(entry.Key)) { keys.Add(entry.Key); } @@ -926,13 +1035,18 @@ public static SessionState BeginAcceptAll(SessionState state) foreach (var entry in state.Queue) { - if (entry.Kind is QueueEntryKind.Move or QueueEntryKind.Delete) + if (entry.Kind is QueueEntryKind.Move or QueueEntryKind.Delete && + batch.Covers(entry.Key)) { keys.Add(entry.Key); } } - var batch = new AcceptBatch(keys, keys.Count); + batch = batch with + { + Remaining = keys, + Total = keys.Count + }; // Nothing to apply, so nothing to report progress on: finished where it started if (keys.Count == 0) { @@ -956,7 +1070,22 @@ public static SessionState BeginAcceptAll(SessionState state) /// Entries with nothing left to apply are passed over rather than claimed: one that has gone /// since the batch began - settled, discarded, its file taken away - and one a second /// framework has since made a conflict of. A delete is held rather than claimed once a - /// snapshot in the batch was not written, for the reason gives. + /// snapshot in the batch was not written. + /// + /// + /// A snapshot moving inline arrives as two unrelated entries: the patch that writes the literal + /// into the source, and a delete of the verified file it replaces. A bulk accept ran the delete + /// whether or not the patch landed, so a patch the applier would not take — a call site that + /// cannot host a Snapshot call, a source that moved since the run — cost the snapshot both + /// copies at once. Nothing ties a delete to the patch it belongs to, so every delete of the + /// batch waits on every patch of it. Blunt, and deliberately so — the entries held are still + /// queued, still shown, and still acceptable one at a time. Moves are left alone: a received + /// file promoted over a verified one is the snapshot arriving, not the last copy of it leaving. + /// + /// + /// Counted from the attempts the batch made (), never read + /// off the queue. Every status an entry carries looks the same there, and reading them held a + /// group's deletes over a failure in another solution, left by an accept long before this one. /// /// public static SessionState ClaimNext(SessionState state) @@ -991,14 +1120,16 @@ public static SessionState ClaimNext(SessionState state) continue; } + var remaining = batch.Remaining.Skip(position + 1).ToList(); return state with { Queue = queue, Batch = batch with { - Remaining = batch.Remaining.Skip(position + 1).ToList(), Kept = kept, - Current = entry + Current = entry, + Together = TakeSameFile(queue, entry, remaining), + Remaining = remaining } }; } @@ -1013,42 +1144,127 @@ batch with } /// - /// Applies a claimed entry - the one piece of IO in a batch, done without the lock - and hands - /// back the transition that records how it went, for the caller to take the lock for. + /// The other snapshots the batch still has to do in the same source file as the one just + /// claimed, taken out of to be claimed with it: see + /// . None for a move or a delete, which is a file of its own. + /// + /// Claimed, rather than looked ahead to by whoever applies, so that nothing can settle, + /// discard or replace one of them between its patch being written and its outcome being + /// recorded without the record noticing, as it notices for a single entry. + /// + /// + /// Asked of the queue first, and without making anything, since most claims find no other + /// snapshot in their file and a batch makes one claim an entry. + /// /// - public static Func ApplyClaimed(QueueEntry entry, ViewerActions actions) + static IReadOnlyList TakeSameFile(IReadOnlyList queue, QueueEntry claimed, List remaining) { - if (entry.Kind == QueueEntryKind.Inline) + if (claimed is not { Kind: QueueEntryKind.Inline, Patch: { } patch } || + remaining.Count == 0) + { + return []; + } + + List? sameFile = null; + foreach (var entry in queue) + { + if (!ReferenceEquals(entry, claimed) && + entry is { Kind: QueueEntryKind.Inline, Conflicted: false, Patch: not null } && + InlineKey.SamePath(entry.Patch.SourceFile, patch.SourceFile)) + { + sameFile ??= []; + sameFile.Add(entry); + } + } + + if (sameFile is null) + { + return []; + } + + // Only the ones this batch set out to do: a group's batch is some of the queue, and an + // entry that arrived after it began is not part of it + var waiting = new HashSet(remaining); + sameFile.RemoveAll(_ => !waiting.Contains(_.Key)); + if (sameFile.Count == 0) { - var result = actions.ApplyInline(entry.Patch!); - return _ => RecordInline(_, entry, result); + return []; } - var failure = TryApplyTracked(entry, actions, discarding: false); - return _ => RecordTracked(_, entry, failure); + var taken = new HashSet(sameFile.Select(_ => _.Key)); + remaining.RemoveAll(taken.Contains); + return sameFile; } /// - /// The transition for a claimed entry whose apply threw rather than answering. InlineApplier - /// answers every failure it knows of, so this is an applier that did not, and a batch left - /// holding a claimed entry would never finish. + /// Applies what a state has claimed - the one piece of IO in a batch, done without the lock - + /// and hands back the transition that records how it went, for the caller to take the lock for. + /// Snapshots claimed together are written together, and each still has an outcome of its own. /// - public static Func FailClaimed(QueueEntry entry, string failure) + /// The state returned, which says what was claimed. + /// What applies it. + public static Func ApplyClaimed(SessionState claimed, ViewerActions actions) { - if (entry.Kind == QueueEntryKind.Inline) + if (claimed.Batch is not { Current: { } entry } batch) + { + return static _ => _; + } + + if (entry.Kind != QueueEntryKind.Inline) { - return _ => RecordInline(_, entry, InlineApplyResult.Failed(failure)); + var failure = TryApplyTracked(entry, actions, discarding: false); + return _ => RecordTracked(_, entry, failure); } - return _ => RecordTracked(_, entry, failure); + List entries = [entry, ..batch.Together]; + var results = actions.ApplyTogether(entries.Select(_ => _.Patch!).ToList()); + if (results.Count != entries.Count) + { + throw new InvalidOperationException($"{entries.Count} snapshots were applied together and {results.Count} outcomes came back."); + } + + return _ => RecordInline(_, entries, results); } /// - /// A snapshot's outcome, by the batch's rules rather than a single accept's: see - /// . An entry that changed while its patch was applying, - /// because a re-run replaced it, keeps its new content and is not counted. + /// The transition for a claim whose apply threw rather than answering. InlineApplier answers + /// every failure it knows of, so this is an applier that did not, and a batch left holding + /// what it claimed would never finish. Every snapshot of the claim is failed with it, since + /// which of them were written is not known. /// - static SessionState RecordInline(SessionState state, QueueEntry entry, InlineApplyResult result) + public static Func FailClaimed(SessionState claimed, string failure) + { + if (claimed.Batch is not { Current: { } entry } batch) + { + return static _ => _; + } + + if (entry.Kind != QueueEntryKind.Inline) + { + return _ => RecordTracked(_, entry, failure); + } + + List entries = [entry, ..batch.Together]; + var failed = InlineApplyResult.Failed(failure); + var results = entries.Select(_ => failed).ToList(); + return _ => RecordInline(_, entries, results); + } + + /// + /// The outcomes of the snapshots a claim applied, by the batch's rules rather than a single + /// accept's: see . An entry that changed while its + /// patch was applying, because a re-run replaced it, keeps its new content and is not counted. + /// + /// The rules are still asked of an , but of one holding the entry + /// alone, and what it says is done to the list as it stands: the entry taken out, or given the + /// status. Every other inline transition rebuilds the whole list from the whole queue, and a + /// batch did that once an entry, so its own bookkeeping grew with the square of the queue: + /// 2,000 snapshots were 3.7 seconds and 8.8 GB of garbage beside the applying. Taking an entry + /// out of a list that is in order leaves it in order, and no other entry is touched, so there + /// is nothing for a rebuild to find. + /// + /// + static SessionState RecordInline(SessionState state, IReadOnlyList entries, IReadOnlyList results) { if (state.Batch is not { } batch) { @@ -1056,17 +1272,43 @@ static SessionState RecordInline(SessionState state, QueueEntry entry, InlineApp } var tally = batch.Tally; - var pending = Pending(state).AcceptInBatch(new(entry.Variants, entry.Status), result, ref tally); + var queue = state.Queue.ToList(); + for (var claim = 0; claim < entries.Count; claim++) + { + var entry = entries[claim]; + // By its variants, which is how the batch finds what it started on. A snapshot's + // only: every move and delete has none, and may well share the one empty list. + var index = queue.FindIndex(_ => _.Kind == QueueEntryKind.Inline && ReferenceEquals(_.Variants, entry.Variants)); + if (index < 0) + { + continue; + } + + var held = queue[index]; + var outcome = InlineQueue + .From([new(held.Variants, held.Status)]) + .AcceptInBatch(new(entry.Variants, entry.Status), results[claim], ref tally); + if (outcome.Count == 0) + { + queue.RemoveAt(index); + } + else + { + queue[index] = held with { Status = outcome.Items[0].Status }; + } + } + return Remove( state with { Batch = batch with { Tally = tally, - Current = null + Current = null, + Together = [] } }, - Rebuild(state, pending), + queue, state.Message); } @@ -1117,14 +1359,16 @@ state with /// /// The batch done: the sentence a bulk accept ends with, and nothing left saying one is /// running. Conflicts are counted here, as whatever the queue still holds with more than one - /// variant, which includes any a second framework made while the batch ran. + /// variant, which includes any a second framework made while the batch ran. A group's batch + /// counts its own members only. /// static SessionState Finish(SessionState state, AcceptBatch batch) { var conflicted = 0; foreach (var entry in state.Queue) { - if (entry is { Kind: QueueEntryKind.Inline, Conflicted: true }) + if (entry is { Kind: QueueEntryKind.Inline, Conflicted: true } && + batch.Covers(entry.Key)) { conflicted++; } @@ -1158,46 +1402,33 @@ static List Without(IReadOnlyList queue, int index) => static SessionState DiscardAllInline(SessionState state, ViewerActions actions) { var discarded = Pending(state).DiscardAll(out var message); - return SweepTracked(state, Rebuild(state, discarded), message, actions, discarding: true); + return DiscardTrackedIn(state, Rebuild(state, discarded), message, actions); } /// - /// The tracked half of a bulk command, worded the way an owning tray words its own: the inline + /// The tracked half of a bulk discard, worded the way an owning tray words its own: the inline /// summary, then ", plus n files" with what stayed pending counted rather than hidden. Both - /// sweeps say the same thing about the same files, whichever process is holding them. - /// - /// - /// The keys to sweep, for a group header acting on its own members. Null sweeps every tracked - /// entry, which is what the unqualified bulk commands mean. - /// - /// - /// Whether the inline accept this sweep follows left a snapshot unwritten, which holds every - /// delete it would carry out. + /// say the same thing about the same files, whichever process is holding them. /// - /// A snapshot moving inline arrives as two unrelated entries: the patch that writes the literal - /// into the source, and a delete of the verified file it replaces. The sweep ran the delete - /// whether or not the patch landed, so a patch the applier would not take — a call site that - /// cannot host a Snapshot call, a source that moved since the run — cost the snapshot both - /// copies at once. Nothing ties a delete to the patch it belongs to, so the whole sweep of - /// deletes waits on the whole batch of patches. Blunt, and deliberately so — the entries held - /// are still queued, still shown, and still acceptable one at a time. Moves are left alone: a - /// received file promoted over a verified one is the snapshot arriving, not the last copy of it - /// leaving. - /// - /// - /// Counted by the caller from the attempts it made, never read off the queue. Every status an - /// entry carries looks the same there, and reading them held a group's deletes over a failure - /// in another solution, left by an accept long before this one. + /// Only discards go this way now. A bulk accept is an , an entry at a + /// time and outside the lock, because accepting is where the time goes: a discard throws a + /// received file away or untracks a delete, and neither waits on anything. /// + /// + /// The state the discard was asked of. + /// Its queue, with the snapshots already discarded. + /// What the snapshots' half of the discard said. + /// What throws a received file away. + /// + /// The keys to discard, for a group header acting on its own members. Null discards every + /// tracked entry, which is what the unqualified discard-all means. /// - static SessionState SweepTracked( + static SessionState DiscardTrackedIn( SessionState state, IReadOnlyList queue, string message, ViewerActions actions, - bool discarding, - IReadOnlyCollection? only = null, - bool refused = false) + IReadOnlyCollection? only = null) { var remaining = new List(queue.Count); var swept = 0; @@ -1211,15 +1442,7 @@ static SessionState SweepTracked( continue; } - if (refused && - entry.Kind == QueueEntryKind.Delete) - { - kept++; - remaining.Add(entry with { Status = deleteHeld }); - continue; - } - - if (TryApplyTracked(entry, actions, discarding) is not { } failure) + if (TryApplyTracked(entry, actions, discarding: true) is not { } failure) { swept++; continue; @@ -1234,7 +1457,7 @@ static SessionState SweepTracked( /// /// The files clause of a bulk command, after the inline summary: ", plus n files", with what - /// stayed pending counted rather than hidden. Shared by the group sweeps and the accept-all + /// stayed pending counted rather than hidden. Shared by the bulk discards and the accept-all /// batch, so the two cannot word the same files differently. /// static string WithFiles(string message, int swept, int kept) @@ -1496,6 +1719,37 @@ static int IndexOf(IReadOnlyList queue, string key) return -1; } + /// + /// Where the entry for 's call site is once the owner has taken it to + /// another line, by the test recognised the move with. -1 + /// when none answers to it, or more than one does. + /// + static int IndexOfMoved(IReadOnlyList queue, QueueEntry? current) + { + if (current?.Patch is not { } patch) + { + return -1; + } + + var found = -1; + for (var index = 0; index < queue.Count; index++) + { + if (queue[index].Patch?.IsSameCallSite(patch) != true) + { + continue; + } + + if (found >= 0) + { + return -1; + } + + found = index; + } + + return found; + } + /// /// Drops whatever is no longer in the queue and leaves the reader where they were. /// @@ -1683,6 +1937,14 @@ static SessionState Toggle(SessionState state, string key) /// static int? NearestVisible(SessionState state) { + // Nothing folded, nothing hidden: every entry has a row, the selected one among them. The + // answer the walk below would give, without the walk, which is every entry of the queue + // and is asked after each entry a batch takes out. + if (state.Collapsed.Count == 0) + { + return null; + } + var visible = QueueProjection.VisibleEntries(state); if (visible.Count == 0 || visible.Contains(state.Selected)) diff --git a/src/FakeDiffTool/Program.cs b/src/FakeDiffTool/Program.cs index b0d9f35d0..c9b028ce5 100644 --- a/src/FakeDiffTool/Program.cs +++ b/src/FakeDiffTool/Program.cs @@ -5,8 +5,15 @@ class Program { [STAThread] - static void Main(string[] args) + static int Main(string[] args) { + // Copied to DiffEngineViewer.exe, this plays a viewer from before 20.5.0, to which + // --payload is an argument it does not know: that one says so and exits with 2 + if (Array.IndexOf(args, "--payload") >= 0) + { + return 2; + } + // If --windowed is passed, create a simple form that can be closed gracefully if (args.Length > 0 && args[0] == "--windowed") { @@ -24,5 +31,7 @@ static void Main(string[] args) // Default behavior: just sleep (no main window) Thread.Sleep(5000); } + + return 0; } } \ No newline at end of file diff --git a/todo.md b/todo.md index bba7cab55..5b96d1d1e 100644 --- a/todo.md +++ b/todo.md @@ -2,86 +2,182 @@ Findings from a review of `main` at 991bc480 (2026-10-03). The list from the review at 4244ebe6 is closed, so this one weights what has landed since: documents and maps, the text diff, zoom and pan, the remembered window and views, and pictures decoded off the UI thread. -- **reproduced**: run and seen. Either the API involved, or the repo's own sources compiled into a scratch console project outside the repo (the same files `DiffEngineViewer.csproj` links, plus a `Program.cs`). No test in the repo yet. -- **measured**: timed in that same project. Release, net10.0, this machine. -- **read**: confirmed by reading the code path end to end, not run. -- **cannot verify here**: needs macOS or Linux; the item says what would settle it. +Most of it came from six reviews run alongside, one per area: the library, the inline patcher, the tray, and the Windows, Linux and macOS heads. Every bug found is fixed and gone from this list: the five in the viewer's model, and the twenty seven the six reviews found. So is every performance item, all fourteen. What is left is what those fixes did not reach, and the smaller ones. + +File and line references are as of a01dfc1d, before the second round of fixes and before the performance ones. The code they point at is unchanged, but lines below an edit have moved, so go by the names. + +- **reproduced**: run and seen by me. Either the API involved, or the repo's own sources compiled into a scratch console project outside the repo. No test in the repo yet. +- **measured**: timed by me in that same project. Release, net10.0, this machine. +- **read**: confirmed by me reading the code path, not run. +- **reported**: found by one of the six reviews and not rerun by me. What follows the word is the reviewer's own evidence: *ran* is a probe of theirs outside the repo, *measured* a timing of theirs, *read* a trace through the code, and *plausible* a reading that rests on something they could not run, with what would settle it. For each of these I checked that the code it quotes is in the tree as quoted, and nothing more. +- **left by the fix**: said by whoever fixed the bug it sits under, about the part the fix did not reach. +- Nothing under a macOS heading has been run by a person: the Swift cannot be built from Windows. CI's `macos-14` job compiles it and runs the suite and the pixel snapshots, and it passes there. The Linux fixes were built and run in an `ubuntu:24.04` container, and pass on CI's Linux job too. The two macOS performance changes are in the same position as the fixes, and have not been measured either. ## Bugs -- [ ] **A new snapshot of a document or a map never gets a real comparison** (reproduced) - - `src/DiffEngine/Implementation/DiffEngineViewer.cs:15` declares the viewer `RequiresTarget: true`, so for a snapshot with no verified file `DiffRunner.TryCreate` (`src/DiffEngine/DiffRunner.cs:357`) asks EmptyFiles for a placeholder before the viewer is told anything. The viewer does not need one: `FileSide.Read` gives a missing target an empty side (`src/DiffEngineViewer/FileSide.cs:30`), and `TrackedWatch` already treats "no target" as a new snapshot. - - EmptyFiles 8.19.0 has no template for `.geojson`, `.gpx`, `.kml`, `.topojson`, `.wkt`, `.wkb`, `.fgb` or `.geoparquet` (`AllFiles.TryCreateFile` returns false for all eight). `ShouldExitLaunch` (`DiffRunner.cs:347`) then returns `NoEmptyFileForExtension`: no window, and only a plain `AddMove`. Every map format except `.kmz` cannot be reviewed the first time it is verified. - - Where a placeholder is created, it is then read as the expected document: - - `.pdf`: the 212 byte template is refused by PDFium ("Not a readable PDF: file is not a PDF or is corrupt"). The right side is `Unreadable`, so `QueueEntry.HasText` is false and both panes show the two property rows instead of the received text (`src/DiffEngineViewer/QueueEntry.cs:89`). The header says `verified.pdf (not drawn)`. With the target deleted, the same entry shows every received line as added. - - `.svg`: three bytes, a BOM. "Not a readable SVG: Root element is missing." - - `.kmz`: "The map has no features to draw." - - `.docx`, `.xlsx`, `.pptx` draw one blank page, and an image gets a tiny valid one, so those read "differ" where "only the received file exists" is the truth. - - `FileTypeLaunchTests` writes a verified file in every case (`:220`, `:285`, `:344`), so nothing launches the viewer on a pair with no target. - - Fix: `RequiresTarget: false` for the viewer. Then add a no-target case per extension to `FileTypeLaunchTests`. - -- [ ] **A failing file snapshot that runs again throws the reader back to its first change** (reproduced) - - `src/DiffEngineViewer/ViewerSession.cs:152-170` (`EnqueueTracked`): an entry replacing the one on screen always goes through `Open`, which resets the scroll, the page and the zoom, and the menu is cleared either way. `MessageHandler.TrackMove` (`src/DiffEngineViewer/Ipc/MessageHandler.cs:54`) builds a fresh entry for every `Diff` or `Move`, whether or not anything changed. - - Reader scrolled to row 0 of a pair that opens at row 147, with its menu open. The same pair arrives again, byte for byte: row 147, menu closed. The selection goes too, since it is tied to the entry's view. - - `EnqueueInline` avoids exactly this (`ViewerSession.cs:48-55`, "a continuous runner re-sending the same failing snapshot every few seconds used to bounce the reader to the top on every run"), and the same inline patch sent twice keeps the reader at row 179. A watch runner, or re-running one failing test while reading its diff, does this for file snapshots. - - `TrackedWatch` already follows a rewritten file without moving the reader (`Refresh` clamps), so the re-send adds nothing but the reset, plus a second read and diff of both files. - - Fix: when the key is already queued and both texts (or image and document hashes) are equal, keep the existing entry with the new stamps, and open only when the content changed. - -- [ ] **One failed write to the document cache ends document reading for the life of the window** (read) - - `src/DiffEngineViewer/Documents/DocumentWatch.cs:64-72`: any exception out of `Pump` sets a message and returns from `Run`. Nothing restarts it, and the message is replaced by the next thing the status line says. - - `Copy` guards only the read (`:260-268`). `File.WriteAllBytes(partial)` and `File.Move(partial, source)` (`:276-278`) are bare, as are `Cache.For` and, in `Prune`, `RenderCache.Hashes`. A scanner holding the file just written, a full temp drive, or a cleaner that removed the cache directory under a viewer hidden for days all throw there. - - After that every document stepped to shows "reading text" and a spinner until the viewer is restarted. - - Fix: catch inside the loop, say it, wait `Interval` and carry on. For the copy, treat a failed write as "not copied this pass" the way a failed read is. - -- [ ] **A PDF that takes longer than two minutes disables PDFs until restart, even once it finishes** (read) - - `DocumentWatch.cs:286-316` (`Run`): the timeout is on the whole document, and `pdfiumHeld` is set when it passes and never cleared. The reason given is that the call left behind still holds PDFium's lock, which stops being true when that call returns. - - A page of one line of text takes 43 ms to draw here (measured, 100 A4 pages in 4.3 s), so the limit is a few hundred dense pages on a slow machine. The render was making progress the whole time; pages had been landing. - - Fix: clear the flag in a continuation on the abandoned task, and measure the timeout from the last page that landed rather than from the start. - -- [ ] **A setting one viewer put back to its default is restored by another viewer's next write** (reproduced) - - `src/DiffEngineViewer/ViewerPreferences.cs:68-72`: `Set` merges what this process holds over a fresh read with `TryAdd`. `Auto` and `Both` are stored as the key being absent, so a key another viewer removed is one this viewer adds back. - - Two `ViewerPreferences` on one file holding `projection=Goode`. The second sets `Auto`: the file is empty. The first remembers its window: the file is `projection=Goode; window=...`. - - Needs two viewers alive at once, which a viewer hidden behind the tray plus a `DiffEngineViewer left right` is. - - Fix: keep the set of keys this instance has changed, and lay only those over the fresh read. +Nothing that was found as a bug is open. What follows is what the fixes left. + +### Library + +- [ ] A viewer that is alive and never binds the port is still reported as launched after `BindWait`, and its payload file stays. That is the apphost's "install .NET" dialog, which does not exit. At the gate it cannot be told from a viewer that is only slow. (left by the fix) +- [ ] A viewer that exits 1 or 4 has staged the patch itself, and the caller, told the launch failed, now stages it too: two trios in different `VerifyInline` folders until a passing run clears both. (left by the fix) +- [ ] A failed launch still spends a `MaxInstance` slot, so after five in one process the cap answers instead. (left by the fix) +- [ ] On macOS and Linux a tool started without ShellExecute still inherits the test host's streams. Nothing in the definitions tells a terminal tool, which needs them, from a windowed one: Neovim is declared `UseShellExecute: true` like the rest. (left by the fix) +- [ ] `DiffRunner.LaunchProcess` still starts a third party tool in the test host's working directory, which then cannot be deleted while the tool is open. Left alone because a tool resolves relative arguments against it and `ProcessCleanup` matches on those same strings. (left by the fix) +- [ ] None of the four tools now started through `WindowsProcess.StartInheritingNothing` (Word and Excel comparers, Cursor, VS Code) was itself run. A console exe, a windowed exe and a `.cmd` stood in for them. (left by the fix) + +### Inline snapshots + +- [ ] Verify has to change for the staged trios of a multi-targeted project to be cleared per framework: in `InlineEngine.Settle()`, call `InlineStaging.Settle(MappedSourceFile, inline.Line, inline.MemberName, VerifierSettings.IntermediateDir, SnapshotInSource)` in place of `ClearStaged(...)`. DiffEngine's half is done: the trios are labelled, and `Settle` clears only the running framework's. (left by the fix) +- [ ] A queued entry is still found by its line, with the member asked second. Three cases remain: a test that carries on past a failed verification and has two call sites only the line tells apart folds them into one; an entry the reporting framework has no content in is not moved, so a multi-target run can keep a stale duplicate; and a settle from the same member is believed on a key hit. Rebasing the hints of a file's remaining entries when one is accepted would close all three, and was not done because a batch accept finds entries by their `Variants` reference. (left by the fix) +- [ ] An `Append` with a stale hint takes the first call in the member that has no `Snapshot` call. Where an earlier call there is verified through files, or through `settings.Snapshot`, that is the wrong call, as it already was whenever such a call came first. (left by the fix) +- [ ] A `Remove` with a stale hint can answer AlreadyApplied while its anchored call is still there, because `RemovedAtHint` is asked first. (left by the fix: seen in its fuzzing, and present before it) + +### Tray + +- [ ] "Accept all in" a group from an attached viewer sends one `Accept` per key (`OwnerLink.AcceptGroup`), so the guard that leaves a delete whose file a move in the same sweep wrote does not apply there. (left by the fix) +- [ ] A delete held back that way stays in the menu with only a log line to say why, and a second "Accept all" carries it out. (left by the fix) +- [ ] An owning viewer's own batch looks to have the shape the tray's had: `ViewerSession.EnqueueTracked` replaces by key only, and `BeginAcceptAll` takes moves and deletes in queue order, so a delete can follow the move that wrote its file. (left by the fix: read, not run) +- [ ] A batch that begins between Verify raising a delete and queueing its patch can still carry out the delete without the patch. Closing that needs the two tied together on the wire. (left by the fix) +- [ ] A logoff also skips `TrayVersionFile.Delete()`, for the reason it skipped the staging. (left by the fix) +- [ ] The session ending was confirmed by sending the tray-shaped process `WM_QUERYENDSESSION` and `WM_ENDSESSION`, not by logging off. (left by the fix) + +### Viewer, Linux head + +- [ ] The body is not told about a taller footer. The model's eight chrome lines leave room for two rows of buttons and a status line; a paged document in a window under about 450 px wide needs four, and the last body rows are then hidden. Fixing it means the shim reporting fewer `rows`, which changes what `deview.h` says that field is. (left by the fix) +- [ ] The machine's fonts are drawn, not shaped: Arabic is unjoined and right to left text is in stored order. Colour emoji fonts and CFF2 variable fonts cannot be read by stb_truetype and are passed over, so a machine whose only CJK font is the variable Noto still shows replacement glyphs. At most fifteen fonts are merged. (left by the fix) +- [ ] Accept-all reads the Shift key's physical state, since raylib gives no modifiers with a character, so a latched Shift (sticky keys) is a plain accept. (left by the fix) + +### Viewer, macOS head + +- [ ] The four macOS fixes compile and the suite passes on CI, but none has been run by a person, and two are event handling that no capture exercises. What would confirm each on a Mac: + - Keys and clicks queued: with three entries queued, `pkill -STOP -x DiffEngineViewer`, press Down twice, `pkill -CONT`: the panes scroll two rows. Stopped, Tab then `a`: the second entry is the one accepted. + - The scroller's knob: dragging it scrolls the panes while the button is down, and the scroller stays on the right edge through a resize. + - The footer: `DiffEngineViewer --diff a.png b.png` at the default size has "images differ" on a line of its own with nothing over "Zoom in"; a PDF pair has two rows of buttons, all of which click. + - Ligatures: `!= <= => -> == ...` in a text file are each drawn as separate characters. +- [ ] A live resize still draws the rows sliced for the old size until the mouse comes up. That half of the tracking loop bug needs a frame callback in the C ABI. A press in the scroller's slot followed by a drag is still AppKit's loop too. (left by the fix) +- [ ] The managed side still slices the body for a footer of one row. This head has 64 pt to spare, which is three rows of buttons or two and a status line; past that the last one or two body rows are not drawn. (left by the fix) +- [ ] Every auto-repeat of a held `a` or `d` is now handed over, including ones queued during a stall. `event.isARepeat` would drop them. The Windows item under Smaller is the same hazard. (left by the fix) +- [ ] The title row has the footer's old shape: the subtitle is drawn over a long title. (left by the fix) ## Performance -- [ ] **The screen is rebuilt every frame, including frames where the state is the same object** (measured) - - `src/DiffEngineViewer/ViewerProgram.cs:464-465` (and `ModalFrame`, `:380`): `ScreenBuilder.Build(host.State)` runs sixty times a second, ten when hidden. `SessionState` is immutable and only replaced by `SessionHost.Mutate`, so an unchanged reference is an unchanged screen. - - `QueueProjection.Rows` builds a label, a group key and a tooltip for every entry in the queue before `Visible` slices out the few on screen (`src/DiffEngineViewer/QueueProjection.cs:102-199`, `:237`). Per call: 100 entries 0.1 ms and 123 KB, 500 entries 0.57 ms and 618 KB, 2,000 entries 0.72 ms and 2.3 MB. At sixty frames that is 7, 36 and 136 MB of garbage a second from a window nobody is touching. - - `SelectionText.Summary` walks every selected row every frame (`src/DiffEngineViewer/SelectionText.cs:146`), flattening each twice. Ctrl+A on a tab indented file: 20,000 lines 3.1 ms and 5.6 MB a frame (330 MB a second), 100,000 lines 15.8 ms and 28 MB a frame, which is the whole frame budget for as long as the selection stands. Nothing selected: 0.001 ms. - - Downstream of it, the WinForms head compares the new screen with the last field by field (`src/DiffEngineViewer.Windows/ViewerForm.cs:789`) and the native heads re-encode it (`src/DiffEngineViewer/Native/NativeViewerWindow.cs:101`). - - Fix: keep the last state and its screen in the loop and rebuild only when the reference changed. `ViewerForm.Same` and `ScreenPayload.Build` can then return early on the same screen reference. That removes all of the above without touching what any of it computes. - -- [ ] **The text diff is quadratic when the two sides share little, and the viewer runs it before it answers** (measured) - - `src/DiffEngine/TextDiff/MyersDiff.cs:159`: the search runs to `maxD` with no bound, and `LineDiff.Build` (`src/DiffEngine/TextDiff/LineDiff.cs:68`) hands it every line. Nothing in common: 10,000 lines a side 226 ms, 20,000 866 ms, 40,000 3.5 s. One percent changed, 400,000 lines: 175 ms. - - "Nothing in common" is an ordinary snapshot change: a serializer setting that re-indents every line. A 40,000 line re-indented JSON takes `TrackedEntry.ForMove` 3.5 s, and 80,000 lines 14 s. - - `MessageHandler.TrackMove` builds the entry, diff included, before the `Diff` or `Move` is answered (`src/DiffEngineViewer/Ipc/MessageHandler.cs:54`). The synchronous client gives up at 3 s (`src/DiffEngine/Protocol/ViewerClient.cs:64`), so `PendingFiles.AddDiff` (`src/DiffEngine/Tray/PendingFiles.cs:134-144`) falls to the launch gate, finds the port owned, sends again, times out again and returns `NoDiffToolFound`, while the viewer diffs the pair twice on two pool threads and then replaces the first entry with the second. The async path has 30 s. - - Fix, in order of value: - - Before Myers, drop the lines that occur on one side only and mark them changed. `LineInterner` already says which: a received id of `expectedLines.Length` or more never occurs in expected, and one pass over the received ids marks the expected ones that do occur. The longest common subsequence is unchanged, so the result is still minimal. Both cases above become linear. - - A cost cap for what is left. The same lines in another order (40,000 lines: 6.9 s) have nothing unique to drop. Past the cap, report the remaining block as removed then added. - - Answer the pair before diffing it: queue the entry with its sides unread and fill it in on `TrackedWatch`'s thread, the way a document arrives `Reading`. - -- [ ] **"Accept all in" a group still applies the whole group in one transition on the render thread** (read) - - `src/DiffEngineViewer/ViewerProgram.cs:700-711`: only `AcceptAll` is handed to `AcceptAllRunner`. `AcceptGroup` goes to `ViewerSession.Apply` inside `host.Mutate`, where `AcceptGroup` (`src/DiffEngineViewer/ViewerSession.cs:705-764`) runs `InlineApplier` for every member and `SweepTracked` (`:1193`) moves or deletes every file, each move retrying for up to a second when the target is held (`src/DiffEngineViewer/ViewerActions.cs:71-87`). - - In a queue with one solution, that header's "Accept all in" is the whole queue: the freeze `AcceptBatch` was written to remove, with the lock held so every arriving `Inline`, `Diff` and listing waits behind it. - - Fix: let `BeginAcceptAll` take the keys to sweep, and send a group through the same runner. - -- [ ] **The right side of a document waits for every page of the left** (measured) - - `src/DiffEngineViewer/Documents/DocumentWatch.cs:160-168`: `Draw` renders the first side without pages to completion and returns. The right side starts on the next pass. - - 100 A4 pages take 4.3 s here and the first lands after 87 ms. So the left page is on screen at once, and the right pane spins for 4.3 s, and which pages differ is unknown until both are done. - - Fix: draw both sides at once on two tasks. Two PDFs then take turns at PDFium's lock a page at a time, and two Office files use two cores. The timeout and `generation` become per job. - -- [ ] **Every pending file is statted five times a second, and the owning watch never slows down** (measured) - - `src/DiffEngineViewer/TrackedWatch.cs:27-55` and `src/DiffEngineViewer/Ipc/OwnerLink.cs:344-432`. One pass over 1,000 pending moves is 2,000 stats and takes 25 ms here, so 125 ms of every second. The class doc's "a queue is small enough that the difference is not measurable" holds to about a hundred. - - `OwnerLink` drops to one pass a second when the window is hidden and `DocumentWatch` stops. `TrackedWatch` has no `Hidden`, and an owning viewer hidden behind a tray keeps its 200 ms for days. - - Fix: give `TrackedWatch` the hidden interval. If large queues matter, stat the entry on screen every pass and the rest in turn. +Nothing that was found as a performance item is open. Each was measured before and after by a benchmark that is now in the repository, in `src/DiffEngine.Benchmarks`, `src/DiffEngineViewer.Benchmarks` and `src/DiffEngineViewer.Windows.Benchmarks`, and the numbers are in the commits that made the changes. The two Linux items were measured in the `ubuntu:24.04` container, by `NativeFrameBenchmarks` and `NativeIdleBenchmarks`, which are left out of a run anywhere else. Two were not measured, because nothing here can run them: both macOS items. What follows is what the fixes left. + +### Viewer model + +- [ ] Past its budget a diff is correct and may not be the smallest: two texts of more than 10,000 lines between them with 8,000 or more of the lines they share out of place. A block moved whole is found whatever its size. The same lines shuffled come out as nearly everything changed, where the longest run still in order could be kept: anchoring on the lines that occur once on each side, by the longest increasing run of them, would find it in the time of a sort. (left by the fix) +- [ ] A pair is still read and diffed before the viewer answers the test process that sent it (`MessageHandler.TrackMove`). The diff is bounded now, so what is left is two reads: a million lines a side, shuffled, is about two of the sender's three seconds. Answering first and filling the entry in afterwards, as a document arrives `Reading`, was not done. (left by the fix) +- [ ] A screen is built when the state changes, which a scroll or a drag does every frame, and `QueueProjection.Rows` describes every row of the queue to draw the forty that fit: 0.55 ms and 1.1 MB at 2,000 entries, for each such frame. Slicing before describing would make it the visible rows'. (left by the fix) +- [ ] `ScreenPayload` clips a row to the window's width in cells rather than the pane's, so about twice what a pane can show is encoded for the macOS and Linux heads: 3.6 ms a changed frame for a 4K window of 300 character CJK lines. `RowText.Shown`, which the WinForms head now cuts with, would serve, once the model knows how many cells a pane has. (left by the fix) +- [ ] A queue of more than a hundred pending files is looked at a hundred a pass, so a row that is not on screen follows its file within `count / 100` passes: two seconds for a thousand, and five times that while the window is hidden. The entry on screen is still looked at every pass. (left by the fix) +- [ ] Only a batch's record step stopped rebuilding the whole list from the whole queue. Every arrival (`EnqueueInline`), settle and single accept still does, under the lock: a dictionary of the queue, two orderings and a key an entry. The two `Smaller` items on `QueueProjection.Order` and `PendingInline.Key` are parts of it. (left by the fix) +- [ ] The bulk discards are still one transition on the render thread: `DiscardGroup` and `DiscardAll` delete each received file under the lock. A discard waits on nothing, so they were left. (left by the fix) +- [ ] A snapshot discarded, or settled by a test that started passing, while its own source file is being written by a bulk accept was handed over with the rest of the file and is written with them. It is not counted, and a discard still takes it out of the queue. Before, that moment was the snapshot's own apply rather than its file's. Closing it would take the applier asking, before its one write, which of the patches are still wanted. (left by the fix) +- [ ] Both sides of a document are drawn at once, and four things about that are as they are for a reason and could be better: a drawing is not stopped when the reader leaves its entry, though between two pages of a PDF it now could be; the pages of a PDF that is put back because the other side stopped inside PDFium are dropped, and drawn again once PDFium is free; a PDF pair's right side waits for the left's first page, which is what lets the two be told apart when both stop; and `Withdrawn`, which takes a rendering back out of the state, lives in `DocumentWatch` where it belongs beside `ViewerSession.Rendered`. (left by the fix) +- [ ] Which of two PDFs stopped inside PDFium is inferred from whose pages stopped first, not known. A thread descheduled between landing a page and asking for the lock, at the moment the other side hangs, would have the innocent side given up on and the culprit put back. (left by the fix) + +### Library + +- [ ] The listener table is every connection the machine has, filtered, so reading it grows with them: 0.45 ms at 86 connections and 7.9 ms at 3,102. `ViewerClient` skips it for a port that answered in the last second; `PiperClient.PortIsHeld` reads it on every send, as it did before. A listener-only table by P/Invoke would not grow. (left by the fix) +- [ ] `RecheckUnownedAfter` is still ten minutes, though a recheck on Windows is now a read of the table rather than two seconds, and could come down. (left by the fix) +- [ ] Off Windows the connect is still the only question asked, since a refusal there is immediate. Whether the table reads as empty under WSL1 was not checked. (left by the fix) +- [ ] `PiperClient.PortIsHeld` now takes any exception from the table as "may be held", where it took two kinds. (left by the fix) + +### Inline snapshots + +- [ ] Lexing is still once a patch, in a batch as well: each patch is applied to what the one before it left, and one scan for all of them would not give the outcomes of applying in turn. 500 patches to a 600 KB file are 0.8 s of patching around one write. (left by the fix) +- [ ] `CanAnchor` still reads and lexes the whole file for each call site a run has not seen before: 0.7 s for 500 call sites in the 600 KB file. (left by the fix) +- [ ] A batch's one write that fails fails every patch from the first edit on, including one judged already applied or not found after it, and the file's mutex is held from the read to the write. (left by the fix) +- [ ] A `VerifyInline` directory another process creates can be found up to a second late by `InlineStaging.Clear`. (left by the fix) +- [ ] Two Windows-only tests assert that a send to a free port returns in under a second, where the refusal it avoids takes two. (left by the fix) + +### Viewer, Windows head + +- [ ] Between half its own size and its own size an enlarged picture is still scaled on every paint: 15 ms for a 4000 by 3000 pair at 400%. A copy there would cost up to the decoded picture again, 96 MB for that pair. Decoding premultiplied (`Format32bppPArgb` in `ImageCache.Load`) measured 9 ms, and was left out because it moves translucent pixels by one level in five pixel scenes. (left by the fix) +- [ ] A picture drawn from its scaled copy sits on whole pixels, up to half a pixel from its exact placement, so two pictures of different sizes can be a pixel apart relative to each other while zoomed below half size. (left by the fix) +- [ ] `Uncomposable` is a picture's rather than a size's, so a scale that failed also stops the fitted copy being made again at a new size. (left by the fix) +- [ ] `RowText.Shown`'s tests are in `DiffEngineViewer.Windows.Tests`, beside its one caller. They belong beside `CellGridTests`. (left by the fix) +- [ ] One of the fixes changes what is drawn. A character of two UTF-16 units whose first column of pixels is its pane's last, an emoji at the very edge, used to be cut to nothing and is now drawn. (left by the fix) + +### Viewer, Linux head + +- [ ] An idle window still turns sixty times a second. Each turn compares the screen's bytes with the last one's, asks after the files behind the pictures on it and waits out its sixtieth: 3 to 9 ms of processor a second, where it was half a second to ten. Waiting on the window system instead would take the managed loop, which also asks after its owner and its files each turn, being told when to wake. (left by the fix) +- [ ] What is not built rests on the list of what a frame is built from being whole: the screen, the pointer, the keys, the window, the decoder, the font finder, a tooltip's delay and the files behind the pictures. Anything `BuildFrame` comes to read that is none of those has to be asked in `deview_present` before a window is left alone, or the window shows the frame before until something else arrives. A frame that is built and comes out the same is not drawn whatever it read, so that half needs no such care. (left by the fix) +- [ ] Nothing in `DiffEngineViewer.Tests` fails if the window goes back to drawing every frame. `NativeIdleBenchmarks` shows it, in its Drawn column, and is run by hand in the container. (left by the fix) +- [ ] A hidden window is still built and drawn when its screen changes, which an arrival in the queue does. (left by the fix) +- [ ] Run only under Xvfb with Mesa's software rasteriser, with no window manager and under openbox. Not on a GPU, under a compositor, on Wayland or over forwarded X, where what the window system keeps of a window that is not being drawn may differ, and where leaving one alone matters most. (left by the fix) +- [ ] `deview_capture` makes its ImGui context without `ImGuiBackendFlags_RendererHasVtxOffset`, which the window's declares, so a capture whose draw list passes 65,535 vertices comes out scrambled. The old checkerboard took a 4K capture of two large pictures past it, which is how it was found. Nothing captures at that size, and the checkerboard no longer takes a capture there, but dense text could. (left by the fix: ran, with the flag added to the shim from before the fix) + +### Viewer, macOS head + +- [ ] Neither macOS change has been compiled by a person or run at all. CI's `macos-14` job compiles them, and its captures draw text from the kept lines. Nothing there runs the clip test or the scaled copy. What would confirm each on a Mac: + - The premise: break in `Renderer.draw` while a spinner turns and print `context.boundingBoxOfClipPath`. The spinner's 44 pt square, or the whole view. + - A turn: Time Profiler on a pair with a long PDF. `CTLineCreateWithAttributedString` under `Renderer.draw` for each turn: about 150 before, none after, whichever clip AppKit hands over. + - Text outside Latin: `--diff` two files of Chinese and hold Down. The same symbol for each frame: every character before, the new row alone after. + - An enlarged picture: `--diff` two 2880 by 1800 screenshots, `+` once, and drag. Time under `Renderer.enlarged` for each frame: a `.high` resample of both panes before, a blit after, and one 1560 by 975 bitmap a pane about a tenth of a second after the step. +- [ ] Since macOS 11 a view with an automatic backing store is handed its whole bounds whatever was invalidated, clip included, so the clip test is safe and probably leaves nothing out on any supported macOS. What makes a spinner's turn cheap there is the kept lines. Two routes would make the clip test pay: `layer.contentsFormat = .RGBA8Uint` in `viewWillDraw`, which changes how the whole window is stored, or a view of the spinner's own. (left by the fix: read, in Apple's developer forums) +- [ ] A byte-equal pair of documents names one page's png in both panes. With one scaled copy a picture and panes a point apart in width, `fitted` looks to make the copy again for each pane in turn without end. The enlarged path stays out of it by drawing such a pair from the picture. (left by the fix: read, not run) ## Smaller -- [ ] `QueueProjection.Order` runs twice per transition: `Project` orders (`ViewerSession.cs:1446`) and `Rebuild` (`:1406`) and `Sync` (`:195`) order its result again. (read) +### Viewer model + +- [ ] A pair sent again unchanged is still read and diffed before it is found to be unchanged: `MessageHandler.TrackMove` builds the whole entry, and `ViewerSession.EnqueueTracked` compares after. The reader is no longer moved, but a large pair pays the diff on every run. Fix: read the two sides, compare them with the queued entry's, and build an entry only when they differ. (read) +- [ ] `QueueProjection.Order` runs twice per transition: `Project` orders (`ViewerSession.cs:1530`) and `Rebuild` (`:1490`) and `Sync` (`:279`) order its result again. (read) - [ ] Opening a context menu builds each side's whole text to ask whether it is empty: `SelectionText.All(entry, side).Length > 0` in `src/DiffEngineViewer/MenuState.cs:98` and `:117`. Megabytes per right-click on a large file. (read) - [ ] `FileSide.ReadBytes` copies every file twice, through a growing `MemoryStream` and then `ToArray` (`src/DiffEngineViewer/FileSide.cs:105`). The length is known. (read) + +### Library + +- [ ] Linux: an exported `COLUMNS` truncates every command line `ProcessCleanup` reads. `LinuxOsxProcess.cs:92` runs `ps -o pid,command -x`, and procps lets `COLUMNS` override the unlimited width it uses when stdout is not a terminal, so with `COLUMNS=80` a running tool is never detected or killed. Fix: add `-ww`, which procps and Apple's `ps` both accept. (reported: read, against the procps source) +- [ ] A `SendAsync` the caller cancelled is recorded as "port unowned": `ViewerClient.cs:372-384`, `:433-444`. `token.Register(() => Abort(client))` closes the client, the exception that follows is not an `OperationCanceledException`, and `Found(endpointPort, false)` silences settles and moves against a live owner for ten minutes. Not reachable from Verify today, which passes no token. Fix: `cancel.ThrowIfCancellationRequested()` on entry, and no `Found(false)` when the token is cancelled. (reported: plausible) +- [ ] With an owner present, every passing inline verification is a TCP connection that leaves a port in TIME_WAIT for two minutes (`DiffRunner_Inline.cs:136-145`, `ViewerClient.cs:276-292`): 0.284 ms each, but about 16,000 settles in two minutes across test processes exhaust the dynamic range, and the failed connect is then remembered as unowned. Fix, only if suites that size matter: list once and skip settles while the owner holds nothing for this framework, or keep one connection open. (reported: measured) +- [ ] `ViewerServer.Listen` (`ViewerServer.cs:89-97`) has `catch (SocketException) { continue; }` with no delay, so an accept failure that persisted would spin a core. Whether one can persist was not established. (reported: plausible) + +### Inline snapshots + +- [ ] A Remove applied twice can take a sibling's literal: `InlinePatcher.cs:652-656`. With `await Verify(a)` over `.Snapshot("dup");` and `await Verify(b).Snapshot("dup");` under it, the first apply pulls the next line up, and the second, from another framework or another case of an `IgnoreParameters` test, finds a Snapshot call on the hint line, so `RemovedAtHint` is false and `Verify(b)` loses its literal. Fix: do not pull the following text up, or have Verify skip the Remove when the source file is newer than the test assembly. (reported: read) +- [ ] The F# lexer disagrees with the compiler inside block comments: `FsLanguage.cs:183-229`. `(* returns "*)" when closed *)`, `(* see "(*" *)` and `(* the (*) operator *)` are each one comment to F#, and the scanner closes or nests on what is inside the string. The usual result is NotFound for calls below. Fix: inside a comment step over string literals and `(*)`, and step over a double backticked identifier whole. (reported: read; what F# does was run under `dotnet fsi`) +- [ ] An F# snapshot whose value is the empty string loses its anchor over the wire: `InlinePatchFile.Build` writes `OriginalValue == ""` as an empty field (`:53-55`), and `TryParse` reads an empty field back as null (`:148-152`). The viewer then heads the pane "expected (new snapshot)", and the patcher falls back to the hint alone. Fix: write a marker for "present and empty", or a separate line saying a value is present. (read) +- [ ] Keys are recomputed per comparison: `PendingInline.Key` lowercases the path and formats a string on every `FindIndex` step (`PendingInline.cs:70`, `InlineQueue.cs:48,244,453,565`, `InlineStaging.cs:366-367`). A few hundred milliseconds across a run with hundreds pending. Fix: compute the key once per entry. (reported: read) + +### Tray + +- [ ] `Process` objects are never disposed for moves that cannot be killed: `Tracker.cs:799-806`, `:945-950`, `ProcessEx.cs:19-32`. `DiffRunner` sends a process id for MDI tools too, `TryGet` forces a handle open, and `KillProcesses` returns at `if (!move.CanKill)` without disposing. One handle per tracked move until a gen2 finaliser. Fix: dispose, without killing, wherever a move finally leaves the dictionary. (reported: read) +- [ ] The scan can drop the wrong move, and one unexpected exception stalls it: `Tracker.cs:66-72`, `:97-114`, `AsyncTimer.cs:25-43`. `moves.TryRemove(tacked.Temp, out var removed)` removes by key, so a re-run that replaced the move between the scan's check and the removal loses its fresh entry and has its tool killed. Only `IOException` is caught around `FilesAreEqual`, and the handler shows a `MessageBox` on the timer thread. Fix: remove by key and value, and catch `UnauthorizedAccessException` beside it. (reported: plausible) +- [ ] "Discard (n)" waits up to 15 s on the UI thread when a viewer owns the queue: `Tracker.cs:836-851` calls `inline.DiscardAll(out var message)` inline, where `Discard` was moved to a worker for this reason. (reported: read) +- [ ] An exception thrown by a hot key action ends the tray: `HotKey/KeyRegister.cs:70-92`. One thrown from `IMessageFilter.PreFilterMessage` comes out of `Application.Run()` rather than reaching `Application.ThreadException`. Fix: try and catch around `action()`, and a catch in `LinkLauncher`. (reported: ran for the mechanism; no trigger found) +- [ ] The process id in a piper payload is trusted as the diff tool: `Tracker.cs:179-183`, `:205-211`. Libraries from before the `ProcessCleanup.StillRunning` fix can send a reused id, and the tray kills whatever holds it now on accept. Fix: compare the process image against the payload's `Exe` first. (reported: plausible) +- [ ] `ListingTag`'s `Fingerprint` (`OwnedInlineHost.cs:317-331`) rebuilds and hashes every tracked move per poll, about 0.5 MB of garbage five times a second while a viewer is attached. (reported: read) + +### Viewer, Windows head + +- [ ] Holding the scroll bar's arrow or its trough freezes the panes until release: `ViewerForm.cs:176-191` enters the modal frame only for `ThumbTrack`, and user32 tracks every part of the bar in the same loop. Fix: `EnterModal()` for any type other than `EndScroll` and `ThumbPosition`. (reported: ran) +- [ ] Alt chords fall through to the plain key commands: `ViewerForm.Map` (`:703-770`) gives Alt+A accept, Alt+D discard and Alt+Q quit. The same leak was closed for Control. Fix: return `CommandKind.None` when `Keys.Alt` is held, ahead of the Control branch. (reported: ran) +- [ ] Accept and Discard auto-repeat, and the repeats are queued: `ViewerForm.cs:703-721`, `:96-105`. Holding `a` past the repeat delay accepts entries the reader has not seen. Fix: drop repeats (bit 30 of `LParam`) for the commands `ViewerSession.ChangesQueue` names. (reported: plausible) +- [ ] The status label shows the middle of a status that does not fit: `ViewerForm.cs:21-32`, `:582-607`. A pdf in a queue leaves it 151 px, and a status of 344 px wraps to three lines in a 30 px label centred vertically. Fix: `AutoEllipsis = true`, with an alignment that keeps the start. (reported: measured) +- [ ] A minimised window still runs the loop at sixty frames a second: `FormsViewerWindow.cs:92` tests `form.Visible`, which stays true when minimised. Fix: `form.Visible && form.WindowState != FormWindowState.Minimized`. (reported: plausible) + +### Viewer, Linux head + +- [ ] Input is sampled as state once a frame, so a press and release that arrive together are not seen and several wheel events collapse into one: `deview.cpp:909-923`, `:2217-2220`. Sent together by xdotool under Xvfb, none of ten clicks was seen and three of ten key presses were. A touchpad two finger tap would not open a context menu, which a tap on a real touchpad would confirm. Fix: chain GLFW's mouse button and scroll callbacks and feed ImGui from them. (reported: plausible, and since run in the container) +- [ ] Ctrl+A and Ctrl+C are still by US key position, arrows and paging do not repeat when held, and no letter shortcut matches on a non-Latin layout: `deview.cpp:929-944`, `:976-985`. Fix: `IsKeyPressedRepeat` for navigation, and resolve the chords through `GetKeyName`. (reported: read) +- [ ] No display scale handling: no `FLAG_WINDOW_HIGHDPI` and no `GetWindowScaleDPI()`, so on a HiDPI X11 display everything is about half size (`deview.cpp:2075-2091`, `:2137`). What would settle it: a display with `Xft.dpi` 192. (reported: plausible) +- [ ] A queue row's context menu is not kept inside the window: the clamp at `deview.cpp:1929-1944` is inside `if (paneMenu)`, so the last row's menu is cut off at the default size. (reported: read) +- [ ] Hover never ends when the pointer leaves the window: `io.AddMousePosEvent(mouse.x, mouse.y)` is unconditional (`deview.cpp:915-916`), so a row stays highlighted and its tooltip appears with the pointer elsewhere. (reported: plausible) +- [ ] A picture larger than `GL_MAX_TEXTURE_SIZE` draws as a black box rather than as nothing (`deview.cpp:613-621`, `:706-712`), and pictures shrunk more than two times are sampled bilinear with no mipmaps (`:464-469`, `:1457-1464`), so thin lines and small text drop out. (reported: plausible, and read) +- [ ] Labels containing `##` are cut short, since ImGui hides everything from there on: pane headers, queue rows, menu items and buttons (`deview.cpp:1687-1688`, `:1716`, `:1738`, `:1961`, `:2003`). (reported: read) + +### Viewer, macOS head + +- [ ] A picture landing during a repaint of the spinner alone is drawn clipped to the spinner's rectangle and never completed: `Renderer.swift:245`, `Runtime.swift:223-239`. A few in a thousand large images. The scaled copy of an enlarged picture lands the same way, and one that lands then leaves the picture drawn at `.low`. Fix: have `draw` report that something landed, and turn that into a full redraw. (reported: read) +- [ ] `[` and `]` never match on layouts where they need Option, since `charactersIgnoringModifiers` yields the digit (`ViewerView.swift:412-433`): German, French, Nordic, Spanish and Italian layouts cannot turn pages by key. Fix: match symbols on `event.characters` first. (reported: read) +- [ ] A pan drag in a pane that cannot move on an axis resets that axis for the other pane: the report is clamped with the dragged pane's own extents (`Renderer.swift:164-174`, `ViewerView.swift:180-187`, and `deview.cpp:1587-1591` on Linux). Fix: report the frame's own centre unchanged on an axis the pane cannot move on. (reported: read) +- [ ] `picturesChanged` never settles when a picture has no room, so a window shorter than about 176 pt redraws at sixty frames a second (`Renderer.swift:485-489`, `:846-848`). Fix: a `contentMinSize`, or record the stamp when nothing is drawn. (reported: read) +- [ ] A notched mouse wheel may do nothing until ten slow clicks add up (`ViewerView.swift:310-322`), and control-click never opens a context menu (`:124-170`). What would settle them: a Mac with a wheel mouse. (reported: plausible) +- [ ] The pump waits out its full 16.7 ms after input, and nothing slows the loop when the window is occluded or miniaturised (`Runtime.swift:209-249`, `:257-276`). (reported: read) +- [ ] `Runtime.open` cannot fail (`Runtime.swift:57-91` always returns true), so with no console session, as over SSH, AppKit aborts the process after the port was bound and the patch is lost. What would settle it: a failing inline snapshot over SSH with nobody logged in. (reported: plausible)