From 838d5b1459965e83f1693ff480fe60d14cff3377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 29 Sep 2026 20:13:20 -0400 Subject: [PATCH] Reload a project changed on disk, fingerprinted by its manifest and image sizes (12.13a) --- AGENTS.md | 1 + CMakeLists.txt | 3 + TASKS.md | 4 +- src/Document/EditorSession+Projects.cpp | 21 ++ src/Document/EditorSession+Projects.h | 2 + src/Document/ProjectWorkspace.cpp | 1 + src/IO/ProjectController+ExternalChanges.cpp | 167 +++++++++ src/IO/ProjectController+ExternalChanges.h | 22 ++ src/IO/ProjectController.cpp | 40 ++- src/IO/ProjectController.h | 16 + src/IO/ProjectDigest.cpp | 37 ++ src/IO/ProjectDigest.h | 9 + src/IO/ProjectWatcher.cpp | 66 ++++ src/IO/ProjectWatcher.h | 34 ++ tests/ExternalChangeFixtures.h | 79 +++++ tests/ExternalChangeGateTests.cpp | 262 ++++++++++++++ tests/ExternalChangeTests.cpp | 338 +++++++++++++++++++ tests/ProjectDigestRaceTests.cpp | 72 ++++ tests/ProjectWatcherTests.cpp | 218 ++++++++++++ tests/Tests.cmake | 4 + 20 files changed, 1388 insertions(+), 8 deletions(-) create mode 100644 src/IO/ProjectController+ExternalChanges.cpp create mode 100644 src/IO/ProjectController+ExternalChanges.h create mode 100644 src/IO/ProjectDigest.cpp create mode 100644 src/IO/ProjectDigest.h create mode 100644 src/IO/ProjectWatcher.cpp create mode 100644 src/IO/ProjectWatcher.h create mode 100644 tests/ExternalChangeFixtures.h create mode 100644 tests/ExternalChangeGateTests.cpp create mode 100644 tests/ExternalChangeTests.cpp create mode 100644 tests/ProjectDigestRaceTests.cpp create mode 100644 tests/ProjectWatcherTests.cpp diff --git a/AGENTS.md b/AGENTS.md index e131f2f..1164fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,7 @@ Platform adaptations, each forced by a missing Qt analog: - Masks painted anywhere (12.12b, Swift 1.2.11: `e753f26`, `8d2eade`, `2ffb092`, `a247676`, taken as their net change from 1.2.10): `BrushStroke` takes `growsMask`, which the Brush, the Gradient and fills (`applyPixelEdit`) pass, so on a mask their grid reaches the canvas as a pixel stroke's does; Spot Healing, Clone Stamp and the Smear keep the mask's grid. A solid placed mask (two pixels a side at most) is painted at a pixel per document pixel of its placement rather than grown from its pixel. `maskBackground` (`LayerMask::background` of the old mask's thumbnail, white or black, 1 without a mask) fills each new tile, the committed snapshot past its base and patches (`RasterSnapshot::fill`, drawn where neither reaches, the box of a mask grown by distant tiles holding such corners) and the asynchronous commit's context (`BrushCommit::Input::fill`). A mask stroke's snapshot keeps every touched tile (`committedBounds`). Committed, a mask grown past its old bounds, or placed already, takes the stroke's transform as its placement (`placedMask`, for the snapshot and the raster commit alike), keeping its place on the document while a linked one still moves with its layer. On the canvas an unplaced mask stroke draws in its possibly grown grid (`paintTransform`), a placed one in the layer's place, and a placed mask's preview and surface map through the stroke's `paintTransform`. `CanvasThumbnail::mask` fills past the mask with `LayerMask::background`, so a stroke touching the edge leaves no gray mean; the thumbnail's own `edgeTone` is gone. `8d2eade`'s canvas fix (the distortion's effects asked with the mask as stored) needs no twin: the twin asks for a layer's effects once, with the mask as shown (9.6c), and its y-flip in `LayerEffectsSurface` has no Qt counterpart. The linked mask surface's white fill (12.12a) stays dead: the layer's pixels lie only within the old mask's rect, however far the grid grows. Equivalent mutant: `placedMask`'s `mask.placement` term (a placed mask keeps its placement through `replacing`, and its transform over unchanged bounds is that placement). Tests: `MaskPaintAnywhereTests` (the brush grows a mask and keeps it linked and in place, a hide-all mask's new area hides, the Gradient and a fill grow it, the Smear's blur does not, a solid placed mask's grid (two pixels a side, a box not square, grown and not), the snapshot's and the commit's fill in uncovered corners, a grown stroke drawn in place, a grown placed mask's tiles mapped under effects), `CanvasThumbnailTests` (Swift's edge case), and `MaskEffectsCanvasTests`' finer placed mask through the stroke's transform. - Scrubbable labels (12.12c1, Swift 1.2.11: `2d36d6e`, `680a522`): `UI/NumericScrub` twins Swift's modifier as a `QObject` living as its label's child and filtering its events; `scrubbable` and `unitSuffix(_:scrubValue:)` are its constructor on the label or unit (`Options`: sensitivity, range, optional step, value, set, onStart, onEnd). A left press on an enabled label, a double click's second press included, is taken (a disabled label's presses reach its filter before Qt drops them, so the filter asks), so the label keeps the drag past its edge; the first move off the press begins (Swift's `minimumDistance` of one, reached by any move of whole points), keeps the start value and calls `onStart`; each move sets the start plus the sideways distance (in the label's own points, which stay exact, where Qt's `notify` rounds global ones) times the sensitivity, snapped to the step when positive, then clamped; the left release calls `onEnd`. Swift's Int overload is a step of one. The cursor is the label's own `SizeHorCursor`: Qt keeps a grabbing widget's cursor through the drag and shows a disabled label's parent's, so Swift's hover and restore dance has no twin. Qt can lose a release: the left release is watched application-wide while pressed, and a move without the left button ends the drag (the label tracks the mouse, so such a move arrives). The tool bars scrub as Swift's: the Brush's Size, Hardness, Opacity or Strength and Smoothing, the Gradient's Opacity, the Magic Wand's Tolerance, Object Select's Edge, the Expand, Contract and Feather units (Expand's and Contract's rest with their rows; Feather's amount and unit stay enabled without a selection, its button alone resting, as Swift's, which the twin's amount had missed), the amount sheet's title (writing its input), the Shape's Width and Radius (a scrub writes its field's text over any typing, as a step does: `showValue` on `SelectionAmountField` and the Type bar's `TextStyleField`), the Type bar's size unit, Tracking and Leading (whole numbers), the Move bar's labels (whole numbers in Swift's new ranges: ±30,000, sides 1 to 30,000, Scale 0.1 to 30,000%, rotation ±360; the scrub writes its field's text over any typing, then the value, so an unfocused field shows what the session keeps; another active layer ends each field's drag under way, `TransformValueField::endScrub`, as it ends the opacity's), the zoom's unit (through the field's `step`) and the layer opacity (one opacity drag, `beginOpacityEdit` to `finishOpacityEdit`; another active layer ends a drag under way through `NumericScrub::end`, since the twin keeps the controls where Swift's `.id` remakes them, and later held moves wait for a fresh press). The sheets and panels follow in 12.12c2. `075a71b`'s narrowing fix in `AdjustPixels.c` is taken (the kernels match Swift's); its other changes and `725b934`'s are Swift's concurrency, plist and CI, with nothing to twin. Swift's `emptyCanvasLeavesTheScrubberCursorAloneAndRestoresTrackingForADocument` has no twin: the canvas never sets a global cursor, only its own. Equivalent mutant: removing the application filter when a drag ends (it then sees nothing to end). Tests: `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableTransformTests` (the Move bar, the zoom unit, the layer opacity), `ScrubFixtures.h`. - Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. Known: a sheet destroyed while one of its fields holds the focus has that field commit into the sheet's destroyed members as Qt moves the focus; the app always hides a panel's content before deleting it, so the focus leaves first, and `ScrubFixtures`' typing helper leaves the field before the sheet goes. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles). +- Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest). - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a5e462..209c43f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,7 @@ add_library(compositor_core STATIC src/IO/ProjectController.cpp src/IO/ProjectController+Resize.cpp src/IO/ProjectController+Export.cpp + src/IO/ProjectController+ExternalChanges.cpp src/IO/ImageFileDrop.cpp src/IO/PSD/PSDChannelCoder.cpp src/IO/PSD/PSDDocumentBuilder.cpp @@ -167,7 +168,9 @@ add_library(compositor_core STATIC src/IO/PSD/PSDText+Engine.cpp src/IO/PSD/PSDTypes.cpp src/IO/PSD/PSDVector.cpp + src/IO/ProjectDigest.cpp src/IO/ProjectStore.cpp + src/IO/ProjectWatcher.cpp src/IO/ProjectStore+Adjustment.cpp src/IO/ProjectStore+Effects.cpp src/IO/ProjectStore+Manifest.cpp diff --git a/TASKS.md b/TASKS.md index 046265f..32d0f62 100644 --- a/TASKS.md +++ b/TASKS.md @@ -386,7 +386,9 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`). Codex review: 1 round; its one finding (two long test comments) fixed. - [x] 12.12c1 Part three: numeric label scrubbing (`2d36d6e`, `680a522`) in the tool bars and the layer opacity; the build-warning fixes (`075a71b`, `725b934`) where they change behaviour. Codex review: 3 rounds. Round 1: focused fields left stale, double-click second presses, Feather's amount wrongly resting, test gaps. Round 2: an opacity scrub outliving its layer, boundary gaps. Round 3: the same for the Move bar. All fixed; closed after the third. - [x] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. Codex review: 3 rounds. Round 1: every Mixer row scrubbed where Swift scrubs the HSL families alone, and test gaps (Image Size limits, Camera Raw bindings, filter decimals, centimetres). Round 2: test gaps (a held drag across `reshape`, titles enabled again, Alt-held Camera Raw scrubs). Round 3 passed. -- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. +- [x] 12.13a Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), images decoded from memory (`cba6efc`). Codex review: 3 rounds. Round 1: a package exchanged and kept aside unseen (Qt watches no IN_MOVE_SELF), a failed manifest read still digested, coarse timers, a failed save's guard released early, and test gaps. Round 2: test gaps (the watch moving, a second change while asking, quiet-time and re-arm counts, an ordered test for a vanished image). Round 3 passed. +- [ ] 12.13b Keep editing while a project saves (`940e188`). +- [ ] 12.13c The guide for AI agents writing projects (`c317306`), README's lines. - [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish. - [ ] 12.15 Version 1.3.3: the Dither filter. - [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun. diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp index f2d27a6..447fd40 100644 --- a/src/Document/EditorSession+Projects.cpp +++ b/src/Document/EditorSession+Projects.cpp @@ -46,6 +46,27 @@ void EditorSession::installProject(const ProjectSnapshot &snapshot, const QStrin notify(); } +void EditorSession::reloadProject(const ProjectSnapshot &snapshot) +{ + if (!m_projectPath) + return; + const CanvasViewport kept = viewport; + const QSet<QUuid> collapsed = m_collapsedGroupIDs; + const std::optional<QUuid> active = m_activeLayerID; + const QSet<QUuid> selected = m_selectedLayerIDs; + installProject(snapshot, *m_projectPath); + viewport = kept; + QSet<QUuid> ids; + for (const ProjectLayerRecord &record : snapshot.manifest.layers) + ids.insert(record.id); + m_collapsedGroupIDs = collapsed & ids; + if (active && ids.contains(*active)) { + setActiveLayerID(active); + m_selectedLayerIDs = (selected & ids) | QSet<QUuid>{*active}; + } + notify(); +} + void EditorSession::setProjectPath(std::optional<QString> path) { m_projectPath = std::move(path); diff --git a/src/Document/EditorSession+Projects.h b/src/Document/EditorSession+Projects.h index ecd4297..3cd8325 100644 --- a/src/Document/EditorSession+Projects.h +++ b/src/Document/EditorSession+Projects.h @@ -3,5 +3,7 @@ public: std::optional<ProjectSnapshot> projectSnapshot() const; // Only a package that loaded and validated whole is installed. void installProject(const ProjectSnapshot &snapshot, const QString &path); + // Swift's reloadProject: what the package holds now, the view kept. + void reloadProject(const ProjectSnapshot &snapshot); void clearProject(); void createNewProject(int width, int height); diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 688ff38..0d0a4b7 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -92,6 +92,7 @@ void ProjectWorkspace::select(QUuid id) current().session.commitTransform(); m_selectedID = id; current().controller.window = window; + current().controller.resumeExternalChangeCheck(); emit changed(); } diff --git a/src/IO/ProjectController+ExternalChanges.cpp b/src/IO/ProjectController+ExternalChanges.cpp new file mode 100644 index 0000000..a368bbd --- /dev/null +++ b/src/IO/ProjectController+ExternalChanges.cpp @@ -0,0 +1,167 @@ +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectController.h" +#include "IO/ProjectDigest.h" +#include "Logging.h" +#include <QFileInfo> +#include <QFutureWatcher> +#include <QMessageBox> +#include <QPushButton> +#include <QTimer> +#include <QtConcurrent> + +namespace { +// Off the UI thread; a half-written package gives none. +QFuture<std::optional<QByteArray>> digestOf(const QString &path) +{ + return QtConcurrent::run([path]() -> std::optional<QByteArray> { + try { + return ProjectDigest::compute(path); + } catch (const std::runtime_error &error) { + qCDebug(lcIO) << "no digest for" << path << error.what(); + return std::nullopt; + } + }); +} +} + +void ProjectController::watchProject(const QString &path) +{ + if (externalChanges.watcher && externalChanges.watcher->path == path) + return; + externalChanges.watcher = std::make_unique<ProjectWatcher>(path, [this] { noteExternalChange(); }); +} + +void ProjectController::stopWatchingProject() +{ + externalChanges.watcher.reset(); + externalChanges.knownDigest.reset(); + externalChanges.recheck->stop(); + externalChanges.pending = false; +} + +void ProjectController::rememberProjectDigest(const QString &path, std::function<void()> then) +{ + auto *watcher = new QFutureWatcher<std::optional<QByteArray>>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, then] { + watcher->deleteLater(); + externalChanges.knownDigest = watcher->result(); + then(); + }); + watcher->setFuture(digestOf(path)); +} + +void ProjectController::resumeExternalChangeCheck() +{ + if (externalChanges.pending) + noteExternalChange(); +} + +void ProjectController::noteExternalChange() +{ + externalChanges.pending = true; + if (externalChanges.checking) + return; + externalChanges.checking = true; + checkExternalChange(); +} + +// Swift's loop: each pass takes one pending change, else stops. +void ProjectController::checkExternalChange() +{ + if (!externalChanges.pending) { + externalChanges.checking = false; + return; + } + externalChanges.pending = false; + const std::optional<QString> path = session.projectPath(); + if (!path || !session.document() || externalChanges.saving) { + externalChanges.checking = false; + return; + } + // Content, not dates: sync clients touch metadata alone. + auto *watcher = new QFutureWatcher<std::optional<QByteArray>>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, path = *path] { + watcher->deleteLater(); + const std::optional<QByteArray> digest = watcher->result(); + if (!digest || digest == externalChanges.knownDigest) { + checkExternalChange(); + return; + } + // An edit in progress finishes before the document goes. + if (!session.canStartProjectOperation() || session.transformEdit() || (workspace && workspace->isManaging())) { + externalChanges.checking = false; + scheduleRecheck(); + return; + } + if (!session.isModified()) { + reloadFromDisk(path, [this] { checkExternalChange(); }); + return; + } + if (!window || !isFrontmost()) { + externalChanges.pending = true; + externalChanges.checking = false; + return; + } + askToRevert([this, path, digest](bool revert) { + if (!revert) { + externalChanges.knownDigest = digest; + checkExternalChange(); + return; + } + reloadFromDisk(path, [this] { checkExternalChange(); }); + }); + }); + watcher->setFuture(digestOf(*path)); +} + +bool ProjectController::isFrontmost() const +{ + return !workspace || &workspace->current().controller == this; +} + +// Backs off while busy: a long operation is not polled. +void ProjectController::scheduleRecheck() +{ + externalChanges.pending = true; + const int attempt = externalChanges.recheckAttempt; + externalChanges.recheckAttempt = std::min(attempt + 1, 7); + externalChanges.recheck->start(250 * (1 << attempt)); +} + +void ProjectController::reloadFromDisk(const QString &path, std::function<void()> then) +{ + externalChanges.recheckAttempt = 0; + session.setIsProjectBusy(true); + // A package that fails to load leaves the document alone. + load(path, [this, path, then](const Loaded &loaded) { + if (!loaded.snapshot || session.projectPath() != path || !session.document()) { + if (!loaded.snapshot) + qCInfo(lcIO).noquote() << "the changed package did not load yet:" << loaded.failure; + session.setIsProjectBusy(false); + then(); + return; + } + session.reloadProject(*loaded.snapshot); + // As loaded, not first seen: it may have changed. + rememberProjectDigest(path, [this, path, then] { + externalChanges.reloadCount += 1; + qCInfo(lcIO).noquote() << "reloaded" << path << "after it changed on disk"; + session.setIsProjectBusy(false); + then(); + }); + }); +} + +void ProjectController::askToRevert(std::function<void(bool)> then) +{ + const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled"); + auto *alert = new QMessageBox(window); + alert->setAttribute(Qt::WA_DeleteOnClose); + alert->setIcon(QMessageBox::Warning); + alert->setText(QStringLiteral("“%1” was changed on disk.").arg(name)); + alert->setInformativeText(QStringLiteral("Another app changed this project. You can revert to the version on disk, losing your unsaved changes, or keep what you have.")); + const QPushButton *revert = alert->addButton(QStringLiteral("Revert"), QMessageBox::AcceptRole); + alert->addButton(QStringLiteral("Keep Mine"), QMessageBox::RejectRole); + connect(alert, &QDialog::finished, this, [alert, revert, then] { then(alert->clickedButton() == revert); }); + alert->open(); +} diff --git a/src/IO/ProjectController+ExternalChanges.h b/src/IO/ProjectController+ExternalChanges.h new file mode 100644 index 0000000..2902017 --- /dev/null +++ b/src/IO/ProjectController+ExternalChanges.h @@ -0,0 +1,22 @@ +#pragma once +#include "IO/ProjectWatcher.h" +#include <QByteArray> +#include <memory> +#include <optional> + +class QTimer; + +// Swift's ExternalChangeState: the watch's bookkeeping, per controller. +struct ExternalChangeState { + std::unique_ptr<ProjectWatcher> watcher; + // The package as last read or written. + std::optional<QByteArray> knownDigest; + bool checking = false; + bool pending = false; + // Our own save is writing: its events are ours. + bool saving = false; + QTimer *recheck = nullptr; + int recheckAttempt = 0; + // Reloads because the package changed on disk; tests read it. + int reloadCount = 0; +}; diff --git a/src/IO/ProjectController.cpp b/src/IO/ProjectController.cpp index 531317e..123f8b6 100644 --- a/src/IO/ProjectController.cpp +++ b/src/IO/ProjectController.cpp @@ -7,6 +7,7 @@ #include <QFutureWatcher> #include <QMessageBox> #include <QPushButton> +#include <QTimer> #include <QtConcurrent> bool ProjectController::isProject(const QUrl &url) @@ -24,7 +25,13 @@ bool ProjectController::samePlace(const std::optional<QString> &path, const QStr return path && resolved(*path) == resolved(other); } -ProjectController::ProjectController(EditorSession &session) : session(session) {} +ProjectController::ProjectController(EditorSession &session) : session(session) +{ + externalChanges.recheck = new QTimer(this); + externalChanges.recheck->setSingleShot(true); + externalChanges.recheck->setTimerType(Qt::PreciseTimer); + connect(externalChanges.recheck, &QTimer::timeout, this, &ProjectController::noteExternalChange); +} bool ProjectController::canStart() const { @@ -113,17 +120,27 @@ void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then) void ProjectController::write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then) { + // Our own save changes the package: its events are ours. + externalChanges.saving = true; auto *watcher = new QFutureWatcher<std::optional<QString>>(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, then] { watcher->deleteLater(); if (const std::optional<QString> failure = watcher->result()) { - showError(QStringLiteral("Couldn’t save the project"), *failure, [then] { then(false); }); + // Swift's defer: the guard lasts until the alert is answered. + showError(QStringLiteral("Couldn’t save the project"), *failure, [this, then] { + externalChanges.saving = false; + then(false); + }); return; } session.setProjectPath(destination); session.history.markSaved(); m_saveGeneration += 1; - then(true); + rememberProjectDigest(destination, [this, destination, then] { + watchProject(destination); + externalChanges.saving = false; + then(true); + }); }); // The store blocks on the disk: off the UI thread. watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> { @@ -188,7 +205,10 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo // Saving in the confirmation can replace the opened file. if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) { session.installProject(*first.snapshot, source); - end(true); + rememberProjectDigest(source, [this, source, end] { + watchProject(source); + end(true); + }); return; } load(source, [this, end, refuse, source](const Loaded &again) { @@ -197,7 +217,10 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo return; } session.installProject(*again.snapshot, source); - end(true); + rememberProjectDigest(source, [this, source, end] { + watchProject(source); + end(true); + }); }); }); }); @@ -230,8 +253,10 @@ void ProjectController::newCanvas() return; confirmReplacement([this](bool proceed) { session.setIsProjectBusy(false); - if (proceed) - session.clearProject(); + if (!proceed) + return; + session.clearProject(); + stopWatchingProject(); }); } @@ -248,6 +273,7 @@ void ProjectController::close(QWidget *closing) if (!proceed) return; session.clearProject(); + stopWatchingProject(); if (closing) closing->close(); }); diff --git a/src/IO/ProjectController.h b/src/IO/ProjectController.h index e5ec1f9..3768c9f 100644 --- a/src/IO/ProjectController.h +++ b/src/IO/ProjectController.h @@ -1,6 +1,7 @@ #pragma once #include "Document/EditorSession.h" #include "IO/ImageExporter.h" +#include "IO/ProjectController+ExternalChanges.h" #include <QList> #include <QPointer> #include <QUrl> @@ -44,6 +45,15 @@ public: void exportPNG(std::function<void()> done = {}); void exportJPEG(std::function<void()> done = {}); + // Swift's ProjectController+ExternalChanges: the package kept in step. + ExternalChangeState externalChanges; + void watchProject(const QString &path); + void stopWatchingProject(); + // Remembers the package as it is now, then calls `then`. + void rememberProjectDigest(const QString &path, std::function<void()> then); + // Its tab came to the front: ask a held question. + void resumeExternalChangeCheck(); + private: struct Incoming { QList<QUrl> urls; @@ -78,6 +88,12 @@ private: void drainIncoming(); void handle(const Incoming &request); std::optional<QUuid> ownTab() const; + void noteExternalChange(); + void checkExternalChange(); + bool isFrontmost() const; + void scheduleRecheck(); + void reloadFromDisk(const QString &path, std::function<void()> then); + void askToRevert(std::function<void(bool)> then); int m_saveGeneration = 0; std::deque<Incoming> m_incoming; diff --git a/src/IO/ProjectDigest.cpp b/src/IO/ProjectDigest.cpp new file mode 100644 index 0000000..0cd4ec9 --- /dev/null +++ b/src/IO/ProjectDigest.cpp @@ -0,0 +1,37 @@ +#include "IO/ProjectDigest.h" +#include <QCryptographicHash> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QtEndian> +#include <algorithm> +#include <stdexcept> + +QByteArray ProjectDigest::compute(const QString &path) +{ + QCryptographicHash hasher(QCryptographicHash::Sha256); + QFile manifest(path + QStringLiteral("/manifest.json")); + if (!manifest.open(QIODevice::ReadOnly)) + throw std::runtime_error(manifest.errorString().toStdString()); + // A failed read comes up short; Swift's Data(contentsOf:) throws. + const QByteArray bytes = manifest.readAll(); + if (bytes.size() != manifest.size()) + throw std::runtime_error("the manifest could not be read whole"); + hasher.addData(bytes); + // Assets are not read: a save would wait on each. + const QDir images(path + QStringLiteral("/images")); + QStringList names = images.entryList(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot, QDir::Unsorted); + std::sort(names.begin(), names.end()); + for (const QString &name : names) { + const QFileInfo info(images.filePath(name)); + // Gone mid-listing: half written, as Swift's resourceValues throws. + if (!info.exists() && !info.isSymLink()) + throw std::runtime_error("an image went while the package was read"); + if (info.isSymLink() || !info.isFile()) + continue; + hasher.addData(name.toUtf8()); + const quint64 size = qToLittleEndian(quint64(info.size())); + hasher.addData(QByteArrayView(reinterpret_cast<const char *>(&size), sizeof size)); + } + return hasher.result(); +} diff --git a/src/IO/ProjectDigest.h b/src/IO/ProjectDigest.h new file mode 100644 index 0000000..e2070b1 --- /dev/null +++ b/src/IO/ProjectDigest.h @@ -0,0 +1,9 @@ +#pragma once +#include <QByteArray> +#include <QString> + +// Swift's ProjectDigest: the manifest, and each image's name and size. +namespace ProjectDigest { +// Throws std::runtime_error when the manifest cannot be read. +QByteArray compute(const QString &path); +} diff --git a/src/IO/ProjectWatcher.cpp b/src/IO/ProjectWatcher.cpp new file mode 100644 index 0000000..3fc366f --- /dev/null +++ b/src/IO/ProjectWatcher.cpp @@ -0,0 +1,66 @@ +#include "IO/ProjectWatcher.h" +#include <QFile> +#include <QFileInfo> +#include <sys/stat.h> + +ProjectWatcher::ProjectWatcher(const QString &path, std::function<void()> onChange, QObject *parent) + : QObject(parent), path(path), m_onChange(std::move(onChange)), m_delivery(new QTimer(this)), m_rearm(new QTimer(this)) +{ + m_delivery->setObjectName(QStringLiteral("delivery")); + m_delivery->setSingleShot(true); + // Swift's sleeps never end early; a coarse timer may. + m_delivery->setTimerType(Qt::PreciseTimer); + m_delivery->setInterval(coalescing); + connect(m_delivery, &QTimer::timeout, this, [this] { m_onChange(); }); + // A swapped-in package retires the watched inodes: arm by path. + m_rearm->setObjectName(QStringLiteral("rearm")); + m_rearm->setInterval(100); + m_rearm->setTimerType(Qt::PreciseTimer); + connect(m_rearm, &QTimer::timeout, this, [this] { + arm(); + m_rearmTries += 1; + if (m_watcher.files().size() + m_watcher.directories().size() == watchedPaths().size() || m_rearmTries >= 20) + m_rearm->stop(); + }); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, &ProjectWatcher::noteEvent); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &ProjectWatcher::noteEvent); + // The parent's other entries are not ours: only another package. + connect(&m_parent, &QFileSystemWatcher::directoryChanged, this, [this] { + if (identity() != m_identity) + noteEvent(); + }); + m_parent.addPath(QFileInfo(path).absolutePath()); + arm(); +} + +QStringList ProjectWatcher::watchedPaths() const +{ + return {path, path + QStringLiteral("/manifest.json"), path + QStringLiteral("/images")}; +} + +std::optional<std::pair<dev_t, ino_t>> ProjectWatcher::identity() const +{ + struct stat status; + if (::stat(QFile::encodeName(path).constData(), &status) != 0) + return std::nullopt; + return std::pair(status.st_dev, status.st_ino); +} + +void ProjectWatcher::arm() +{ + m_identity = identity(); + const QStringList watched = m_watcher.files() + m_watcher.directories(); + if (!watched.isEmpty()) + m_watcher.removePaths(watched); + for (const QString &each : watchedPaths()) { + if (QFileInfo::exists(each)) + m_watcher.addPath(each); + } +} + +void ProjectWatcher::noteEvent() +{ + m_rearmTries = 0; + m_rearm->start(); + m_delivery->start(); +} diff --git a/src/IO/ProjectWatcher.h b/src/IO/ProjectWatcher.h new file mode 100644 index 0000000..eb058fd --- /dev/null +++ b/src/IO/ProjectWatcher.h @@ -0,0 +1,34 @@ +#pragma once +#include <QFileSystemWatcher> +#include <QObject> +#include <QTimer> +#include <functional> +#include <optional> +#include <sys/types.h> + +// Swift's ProjectWatcher: tells its owner the package changed on disk. +class ProjectWatcher : public QObject { +public: + ProjectWatcher(const QString &path, std::function<void()> onChange, QObject *parent = nullptr); + + const QString path; + // Quiet time before reporting, so one save reports once. + static constexpr int coalescing = 300; + +private: + QStringList watchedPaths() const; + void arm(); + void noteEvent(); + // The package folder's device and inode, when it exists. + std::optional<std::pair<dev_t, ino_t>> identity() const; + + const std::function<void()> m_onChange; + QFileSystemWatcher m_watcher; + // Qt watches no IN_MOVE_SELF: a renamed package shows above. + QFileSystemWatcher m_parent; + std::optional<std::pair<dev_t, ino_t>> m_identity; + // Named children, so tests read whether they run. + QTimer *const m_delivery; + QTimer *const m_rearm; + int m_rearmTries = 0; +}; diff --git a/tests/ExternalChangeFixtures.h b/tests/ExternalChangeFixtures.h new file mode 100644 index 0000000..cb8d2c1 --- /dev/null +++ b/tests/ExternalChangeFixtures.h @@ -0,0 +1,79 @@ +#pragma once +#include "DialogDesk.h" +#include "Document/BrushStroke.h" +#include "Document/ProjectWorkspace.h" +#include "IO/ProjectController.h" +#include "IO/ProjectDigest.h" +#include "IO/ProjectStore.h" +#include <QFutureWatcher> +#include <QSaveFile> +#include <QTemporaryDir> +#include <QtTest> + +// Shared by the external change tests: packages written elsewhere. + +// A saved two-layer project: an image below a blank layer. +inline QString savedProject(const QTemporaryDir &root) +{ + EditorSession session; + session.createDocument(8, 6); + QImage image = BrushRaster::context(8, 6, false); + image.fill(QColor(200, 120, 60)); + session.insert(ImportedImage(image, image, QStringLiteral("Image"))); + session.renameLayer(session.activeLayerID().value(), QStringLiteral("Base")); + session.addBlankLayer(); + const QString path = root.filePath(QStringLiteral("Watched.comp")); + ProjectStore::save(session.projectSnapshot().value(), path); + return path; +} + +// Rewrites the package as another app would: a new name. +inline void renameFirstLayerOnDisk(const QString &path, const QString &name) +{ + ProjectSnapshot snapshot = ProjectStore::load(path); + snapshot.manifest.layers[0].name = name; + ProjectStore::save(snapshot, path); +} + +// Rewrites a file through a temporary, as a script does. +inline void rewrite(const QString &file, const QByteArray &bytes) +{ + QSaveFile out(file); + if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size() || !out.commit()) + throw std::runtime_error("could not rewrite " + file.toStdString()); +} + +inline QByteArray contents(const QString &file) +{ + QFile in(file); + if (!in.open(QIODevice::ReadOnly)) + throw std::runtime_error("could not read " + file.toStdString()); + return in.readAll(); +} + +inline bool answered(const std::function<void(std::function<void(bool)>)> &call) +{ + std::optional<bool> result; + call([&](bool value) { result = value; }); + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) && result.value(); +} + +// Opens the project as the app does: watched, digest kept. +inline std::unique_ptr<ProjectController> opened(EditorSession &session, const QString &path) +{ + auto controller = std::make_unique<ProjectController>(session); + if (!answered([&](auto done) { controller->open(path, done); })) + throw std::runtime_error("the project did not open"); + return controller; +} + +inline QString firstName(const EditorSession &session) +{ + return session.document().value().layers.front().name; +} + +// Longer than the watcher's quiet time and a digest. +inline void settle() +{ + QTest::qWait(900); +} diff --git a/tests/ExternalChangeGateTests.cpp b/tests/ExternalChangeGateTests.cpp new file mode 100644 index 0000000..707463a --- /dev/null +++ b/tests/ExternalChangeGateTests.cpp @@ -0,0 +1,262 @@ +#include "ExternalChangeFixtures.h" + +// What holds a change back, and what a reload keeps. +class ExternalChangeGateTests : public QObject { + Q_OBJECT +private slots: + void aFailedSaveKeepsItsGuardUntilTheAlertIsAnswered(); + void aManagingWorkspaceHoldsTheCheck(); + void theBackOffDoublesToACap(); + void aLoadLandingElsewhereInstallsNothing(); + void aReloadKeepsTheWholeViewAndAnnouncesItLast(); + void anUnreadableManifestAsksNothing(); + void saveAsAndOpenMoveTheWatch(); + void aSecondChangeWhileAskingIsHeard(); +}; + +void ExternalChangeGateTests::aFailedSaveKeepsItsGuardUntilTheAlertIsAnswered() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + QWidget window; + window.show(); + controller->window = &window; + session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here")); + // Save As into a folder that takes nothing. + const QString locked = root.filePath(QStringLiteral("Locked")); + QVERIFY(QDir().mkpath(locked)); + QVERIFY(QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::ExeOwner)); + DialogDesk desk; + desk.replies = {locked + QStringLiteral("/Copy.comp"), "OK"}; + // While the alert is up, changes on disk drop. + desk.note = [&] { + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTest::qWait(900); + return QStringLiteral("saving=%1 reloads=%2").arg(controller->externalChanges.saving).arg(controller->externalChanges.reloadCount); + }; + QVERIFY(!answered([&](auto done) { controller->save(true, done); })); + QCOMPARE(desk.seen.size(), 2); + QVERIFY2(desk.seen[1].endsWith("|saving=1 reloads=0"), qPrintable(desk.seen[1])); + QVERIFY(!controller->externalChanges.saving); + settle(); + QCOMPARE(desk.seen.size(), 2); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QCOMPARE(firstName(session), QString("Unsaved here")); + QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); +} + +void ExternalChangeGateTests::aManagingWorkspaceHoldsTheCheck() +{ + QTemporaryDir root; + const QString path = savedProject(root); + ProjectWorkspace workspace; + QWidget window; + window.show(); + workspace.window = &window; + QVERIFY(answered([&](auto done) { workspace.open(path, done); })); + const std::shared_ptr<ProjectTab> watched = workspace.tabs().back(); + workspace.newCanvas(); + const std::shared_ptr<ProjectTab> other = workspace.tabs().back(); + other->session.createDocument(8, 6); + other->session.addBlankLayer(); + workspace.select(watched->id); + // Closing the other tab asks; meanwhile the workspace manages. + DialogDesk desk; + desk.replies = {"Cancel"}; + desk.note = [&] { + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTest::qWait(900); + return QStringLiteral("managing=%1 reloads=%2 recheck=%3") + .arg(workspace.isManaging()) + .arg(watched->controller.externalChanges.reloadCount) + .arg(watched->controller.externalChanges.recheck->isActive()); + }; + std::optional<bool> closed; + workspace.close(other->id, [&] { closed = true; }); + QTRY_VERIFY(closed.has_value()); + QCOMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].endsWith("|managing=1 reloads=0 recheck=1"), qPrintable(desk.seen[0])); + QTRY_COMPARE_WITH_TIMEOUT(watched->controller.externalChanges.reloadCount, 1, 4000); + QCOMPARE(firstName(watched->session), QString("Renamed elsewhere")); +} + +void ExternalChangeGateTests::theBackOffDoublesToACap() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + ExternalChangeState &state = controller->externalChanges; + QCOMPARE(state.recheck->timerType(), Qt::PreciseTimer); + session.setIsProjectBusy(true); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTRY_VERIFY_WITH_TIMEOUT(state.recheck->isActive(), 4000); + QCOMPARE(state.recheck->interval(), 250); + // Each busy check waits twice as long, to 32 seconds. + for (const int expected : {500, 1000, 2000, 4000, 8000, 16000, 32000, 32000}) { + state.pending = true; + controller->resumeExternalChangeCheck(); + QTRY_COMPARE(state.recheck->interval(), expected); + QVERIFY(state.recheck->isActive()); + } + QCOMPARE(state.recheckAttempt, 7); + session.setIsProjectBusy(false); + state.pending = true; + controller->resumeExternalChangeCheck(); + QTRY_COMPARE_WITH_TIMEOUT(state.reloadCount, 1, 4000); + QCOMPARE(state.recheckAttempt, 0); +} + +void ExternalChangeGateTests::aLoadLandingElsewhereInstallsNothing() +{ + for (const bool clears : {false, true}) { + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + // As the reload begins, the project moves or closes. + bool moved = false; + QObject::connect(&session, &EditorSession::changed, [&] { + if (moved || !session.isProjectBusy()) + return; + moved = true; + if (clears) + session.clearProject(); + else + session.setProjectPath(root.filePath(QStringLiteral("Elsewhere.comp"))); + }); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTRY_VERIFY_WITH_TIMEOUT(moved, 4000); + QTRY_VERIFY(!session.isProjectBusy()); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QCOMPARE(session.document().has_value(), !clears); + if (!clears) + QCOMPARE(firstName(session), QString("Base")); + } +} + +void ExternalChangeGateTests::aReloadKeepsTheWholeViewAndAnnouncesItLast() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + session.installProject(ProjectStore::load(path), path); + session.viewport.resize(QSizeF(800, 600), 1, session.document().value().size()); + session.zoom(3); + session.viewport.translate(QSizeF(40, -30)); + const CanvasViewport view = session.viewport; + const QUuid base = session.document().value().layers[0].id; + const QUuid blank = session.document().value().layers[1].id; + session.selectLayer(blank); + session.extendSelection(base); + // Disk drops the other selected layer; the active one stays. + ProjectSnapshot fewer = ProjectStore::load(path); + fewer.manifest.layers.erase(fewer.manifest.layers.begin() + 1); + std::optional<std::tuple<CanvasViewport, std::optional<QUuid>, QSet<QUuid>>> last; + QObject::connect(&session, &EditorSession::changed, [&] { last = {session.viewport, session.activeLayerID(), session.selectedLayerIDs()}; }); + session.reloadProject(fewer); + QVERIFY(session.viewport == view); + QCOMPARE(session.activeLayerID().value(), base); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{base}); + QVERIFY(std::get<0>(last.value()) == view); + QCOMPARE(std::get<1>(last.value()), std::optional<QUuid>(base)); + QCOMPARE(std::get<2>(last.value()), QSet<QUuid>{base}); + // Both layers on disk: the last signal holds both selected. + session.reloadProject(ProjectStore::load(path)); + session.selectLayer(blank); + session.extendSelection(base); + session.reloadProject(ProjectStore::load(path)); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{base, blank})); + QCOMPARE(std::get<2>(last.value()), (QSet<QUuid>{base, blank})); +} + +void ExternalChangeGateTests::anUnreadableManifestAsksNothing() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + QWidget window; + window.show(); + controller->window = &window; + session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here")); + DialogDesk desk; + // No digest: the package is left alone, no question. + const QString manifest = path + QStringLiteral("/manifest.json"); + QVERIFY(QFile::remove(manifest)); + QVERIFY(QDir().mkpath(manifest)); + settle(); + QVERIFY(desk.seen.isEmpty()); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QCOMPARE(firstName(session), QString("Unsaved here")); +} + +void ExternalChangeGateTests::saveAsAndOpenMoveTheWatch() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + const QString copy = root.filePath(QStringLiteral("Copy.comp")); + { + DialogDesk desk; + desk.replies = {copy}; + QVERIFY(answered([&](auto done) { controller->save(true, done); })); + } + QCOMPARE(controller->externalChanges.watcher->path, copy); + QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(copy)); + renameFirstLayerOnDisk(copy, QStringLiteral("Copy renamed")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + QCOMPARE(firstName(session), QString("Copy renamed")); + // The former package is no longer this project. + renameFirstLayerOnDisk(path, QStringLiteral("Former renamed")); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 1); + // Opening another package moves the watch there. + QVERIFY(answered([&](auto done) { controller->open(path, done); })); + QCOMPARE(controller->externalChanges.watcher->path, path); + QCOMPARE(firstName(session), QString("Former renamed")); + renameFirstLayerOnDisk(copy, QStringLiteral("Copy again")); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 1); +} + +void ExternalChangeGateTests::aSecondChangeWhileAskingIsHeard() +{ + for (const bool revert : {false, true}) { + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + QWidget window; + window.show(); + controller->window = &window; + session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here")); + DialogDesk desk; + desk.replies = revert ? QStringList{"Revert"} : QStringList{"Keep Mine", "Keep Mine"}; + // A second version arrives while the first question waits. + desk.note = [&] { + if (desk.seen.isEmpty()) { + renameFirstLayerOnDisk(path, QStringLiteral("Second version")); + QTest::qWait(900); + } + return QStringLiteral("pending=%1").arg(controller->externalChanges.pending); + }; + renameFirstLayerOnDisk(path, QStringLiteral("First version")); + QTRY_COMPARE_WITH_TIMEOUT(desk.seen.size(), revert ? 1 : 2, 6000); + QVERIFY2(desk.seen[0].endsWith("|pending=1"), qPrintable(desk.seen[0])); + QTRY_VERIFY_WITH_TIMEOUT(!controller->externalChanges.checking, 4000); + settle(); + QCOMPARE(desk.seen.size(), revert ? 1 : 2); + QVERIFY(!controller->externalChanges.pending); + QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path)); + QCOMPARE(controller->externalChanges.reloadCount, revert ? 1 : 0); + QCOMPARE(firstName(session), revert ? QString("Second version") : QString("Unsaved here")); + } +} + +QTEST_MAIN(ExternalChangeGateTests) +#include "ExternalChangeGateTests.moc" diff --git a/tests/ExternalChangeTests.cpp b/tests/ExternalChangeTests.cpp new file mode 100644 index 0000000..eea1a10 --- /dev/null +++ b/tests/ExternalChangeTests.cpp @@ -0,0 +1,338 @@ +#include "ExternalChangeFixtures.h" +#include <QFutureWatcher> + +// A project something else writes while it is open. +class ExternalChangeTests : public QObject { + Q_OBJECT +private slots: + void digestFollowsContentNotMetadata(); + void reloadKeepsViewportSelectionAndFoldersButNotHistory(); + void writingThePackageElsewhereReloadsTheOpenProject(); + void ourOwnSaveAndMetadataTouchesDoNotReload(); + void halfWrittenPackagesAreIgnoredUntilTheyLoad(); + void unsavedWorkIsNeverReplacedWithoutAsking(); + void unsavedWorkAsksToRevertOrKeep(); + void aBackgroundTabAsksWhenItComesToTheFront(); + void aBusyProjectWaitsThenReloads(); + void closingOrANewCanvasStopsTheWatch(); + void aNewCanvasDropsAWaitingRecheck(); + void oneCheckRunsAtATime(); + void aCheckWithoutAProjectOrDuringOurSaveEnds(); +}; + +void ExternalChangeTests::digestFollowsContentNotMetadata() +{ + QTemporaryDir root; + const QString path = savedProject(root); + const QString manifest = path + QStringLiteral("/manifest.json"); + const QByteArray before = ProjectDigest::compute(path); + // Touched, and rewritten with the same bytes: sync clients. + QFile touched(manifest); + QVERIFY(touched.open(QIODevice::ReadWrite)); + QVERIFY(touched.setFileTime(QDateTime::currentDateTime().addSecs(60), QFileDevice::FileModificationTime)); + touched.close(); + rewrite(manifest, contents(manifest)); + QCOMPARE(ProjectDigest::compute(path), before); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + const QByteArray renamed = ProjectDigest::compute(path); + QVERIFY(renamed != before); + // Another image's bytes under a layer's name. + const QDir images(path + QStringLiteral("/images")); + const QString png = images.entryList({QStringLiteral("*.png")}).value(0); + rewrite(images.filePath(png), contents(images.filePath(png)) + QByteArray(64, '\0')); + QVERIFY(ProjectDigest::compute(path) != renamed); + // No manifest, no digest. + QVERIFY(QFile::remove(manifest)); + QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path)); +} + +void ExternalChangeTests::reloadKeepsViewportSelectionAndFoldersButNotHistory() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + session.installProject(ProjectStore::load(path), path); + session.viewport.resize(QSizeF(800, 600), 1, session.document().value().size()); + session.zoom(3); + const QUuid base = session.document().value().layers[0].id; + const QUuid blank = session.document().value().layers[1].id; + session.selectLayer(blank); + session.extendSelection(base); + QCOMPARE(session.activeLayerID().value(), base); + session.renameLayer(base, QStringLiteral("Edited here")); + QVERIFY(session.isModified() && session.canUndo()); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + session.reloadProject(ProjectStore::load(path)); + QCOMPARE(firstName(session), QString("Renamed elsewhere")); + QCOMPARE(session.viewport.zoom(), 3.0); + QCOMPARE(session.activeLayerID().value(), base); + QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{base, blank})); + QVERIFY(!session.isModified()); + QVERIFY(!session.canUndo()); + QCOMPARE(session.projectPath().value(), path); + // A collapsed folder stays collapsed while it exists. + session.groupSelectedLayers(); + const QUuid folder = session.activeLayerID().value(); + session.toggleGroupExpansion(folder); + QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder}); + const ProjectSnapshot folded = session.projectSnapshot().value(); + session.reloadProject(folded); + QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder}); + session.reloadProject(ProjectStore::load(path)); + QVERIFY(session.collapsedGroupIDs().isEmpty()); + QCOMPARE(session.activeLayerID().value(), ProjectStore::load(path).manifest.activeLayerID.value()); + // A layer gone from disk: the loaded active one stands. + ProjectSnapshot fewer = ProjectStore::load(path); + fewer.manifest.layers.erase(fewer.manifest.layers.begin()); + fewer.images.erase(base); + fewer.manifest.activeLayerID = blank; + session.reloadProject(fewer); + QCOMPARE(session.activeLayerID().value(), blank); + QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{blank}); +} + +void ExternalChangeTests::writingThePackageElsewhereReloadsTheOpenProject() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + // The reload holds the project busy, then frees it. + bool busyMeanwhile = false; + QObject::connect(&session, &EditorSession::changed, [&] { busyMeanwhile = busyMeanwhile || session.isProjectBusy(); }); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + QCOMPARE(firstName(session), QString("Renamed elsewhere")); + QVERIFY(!session.isModified()); + QVERIFY(busyMeanwhile); + QVERIFY(!session.isProjectBusy()); + // Every digest's watcher is gone once it answered. + QTRY_VERIFY(controller->findChildren<QFutureWatcherBase *>().isEmpty()); + // The manifest rewritten in place, as a script would. + const QString manifest = path + QStringLiteral("/manifest.json"); + rewrite(manifest, contents(manifest).replace("Renamed elsewhere", "Renamed again")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 2, 4000); + QCOMPARE(firstName(session), QString("Renamed again")); +} + +void ExternalChangeTests::ourOwnSaveAndMetadataTouchesDoNotReload() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Saved by us")); + QVERIFY(answered([&](auto done) { controller->save(false, done); })); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QCOMPARE(firstName(session), QString("Saved by us")); + QVERIFY(!session.isModified()); + const QString manifest = path + QStringLiteral("/manifest.json"); + rewrite(manifest, contents(manifest)); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 0); + // Our save swapped the package in; the watch follows it. + renameFirstLayerOnDisk(path, QStringLiteral("Renamed after our save")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + QCOMPARE(firstName(session), QString("Renamed after our save")); +} + +void ExternalChangeTests::halfWrittenPackagesAreIgnoredUntilTheyLoad() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + const QString manifest = path + QStringLiteral("/manifest.json"); + const QByteArray good = contents(manifest); + rewrite(manifest, "{ \"format\": \"com.compositor.project\", \"version\": 9, \"layers\": ["); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QCOMPARE(session.document().value().layers.size(), size_t(2)); + QVERIFY(!session.isProjectBusy()); + rewrite(manifest, QByteArray(good).replace("\"Base\"", "\"Finished\"")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + QCOMPARE(firstName(session), QString("Finished")); +} + +void ExternalChangeTests::unsavedWorkIsNeverReplacedWithoutAsking() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here")); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + settle(); + // No window to ask in: the question waits. + QCOMPARE(controller->externalChanges.reloadCount, 0); + QVERIFY(controller->externalChanges.pending); + QCOMPARE(firstName(session), QString("Unsaved here")); + QVERIFY(session.isModified()); +} + +void ExternalChangeTests::unsavedWorkAsksToRevertOrKeep() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + QWidget window; + window.show(); + controller->window = &window; + DialogDesk desk; + desk.replies = {"Keep Mine", "Revert"}; + session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here")); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTRY_COMPARE_WITH_TIMEOUT(desk.seen.size(), 1, 4000); + QCOMPARE(desk.seen[0], QString("alert|2|“Watched.comp” was changed on disk.|Another app changed this project. You can revert to the version on " + "disk, losing your unsaved changes, or keep what you have.|Revert,Keep Mine")); + settle(); + // Kept: the same package on disk asks nothing again. + QCOMPARE(firstName(session), QString("Unsaved here")); + QVERIFY(!controller->externalChanges.pending); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QTRY_VERIFY(!window.findChild<QMessageBox *>()); + const QString manifest = path + QStringLiteral("/manifest.json"); + rewrite(manifest, contents(manifest)); + settle(); + QCOMPARE(desk.seen.size(), 1); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed twice")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + QCOMPARE(desk.seen.size(), 2); + QCOMPARE(firstName(session), QString("Renamed twice")); + QVERIFY(!session.isModified()); +} + +void ExternalChangeTests::aBackgroundTabAsksWhenItComesToTheFront() +{ + QTemporaryDir root; + const QString path = savedProject(root); + ProjectWorkspace workspace; + QWidget window; + window.show(); + workspace.window = &window; + QVERIFY(answered([&](auto done) { workspace.open(path, done); })); + const std::shared_ptr<ProjectTab> watched = workspace.tabs().back(); + watched->session.renameLayer(watched->session.document().value().layers[0].id, QStringLiteral("Unsaved here")); + workspace.newCanvas(); + QVERIFY(&workspace.current() != watched.get()); + DialogDesk desk; + desk.replies = {"Revert"}; + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + settle(); + QVERIFY(desk.seen.isEmpty()); + QVERIFY(watched->controller.externalChanges.pending); + workspace.select(watched->id); + QTRY_COMPARE_WITH_TIMEOUT(watched->controller.externalChanges.reloadCount, 1, 4000); + QCOMPARE(desk.seen.size(), 1); + QCOMPARE(firstName(watched->session), QString("Renamed elsewhere")); +} + +void ExternalChangeTests::aBusyProjectWaitsThenReloads() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + session.setIsProjectBusy(true); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 0); + QVERIFY(controller->externalChanges.pending); + QVERIFY(controller->externalChanges.recheckAttempt > 0); + session.setIsProjectBusy(false); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 8000); + QCOMPARE(firstName(session), QString("Renamed elsewhere")); + QCOMPARE(controller->externalChanges.recheckAttempt, 0); +} + +void ExternalChangeTests::closingOrANewCanvasStopsTheWatch() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + QCOMPARE(controller->externalChanges.watcher->path, path); + QVERIFY(controller->externalChanges.knownDigest.has_value()); + controller->newCanvas(); + QTRY_VERIFY(!session.document()); + QVERIFY(!controller->externalChanges.watcher); + QVERIFY(!controller->externalChanges.knownDigest); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 0); + // Opened again and closed: the watch goes with the window. + const auto reopened = opened(session, path); + QWidget closing; + closing.show(); + reopened->close(&closing); + QTRY_VERIFY(!session.document()); + QVERIFY(!reopened->externalChanges.watcher); +} + +void ExternalChangeTests::aNewCanvasDropsAWaitingRecheck() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + // An open transform holds the check back on a timer. + session.selectTool(NavigationTool::move); + session.selectLayer(session.document().value().layers[0].id); + session.beginTransform(); + QVERIFY(session.transformEdit().has_value()); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTRY_VERIFY_WITH_TIMEOUT(controller->externalChanges.recheck->isActive(), 4000); + QVERIFY(controller->externalChanges.pending); + controller->newCanvas(); + QTRY_VERIFY(!session.document()); + QVERIFY(!controller->externalChanges.recheck->isActive()); + QVERIFY(!controller->externalChanges.pending); +} + +void ExternalChangeTests::oneCheckRunsAtATime() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + // Three asks before the watcher speaks: one reload between them. + for (int ask = 0; ask < 3; ++ask) { + controller->externalChanges.pending = true; + controller->resumeExternalChangeCheck(); + } + QVERIFY(controller->externalChanges.checking); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + settle(); + QCOMPARE(controller->externalChanges.reloadCount, 1); + QVERIFY(!controller->externalChanges.checking); +} + +void ExternalChangeTests::aCheckWithoutAProjectOrDuringOurSaveEnds() +{ + QTemporaryDir root; + const QString path = savedProject(root); + EditorSession session; + const auto controller = opened(session, path); + // During our own save a change is ours: dropped. + controller->externalChanges.saving = true; + controller->externalChanges.pending = true; + controller->resumeExternalChangeCheck(); + QVERIFY(!controller->externalChanges.checking); + QVERIFY(!controller->externalChanges.pending); + controller->externalChanges.saving = false; + renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere")); + QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000); + // Without a document nothing is checked, and checking ends. + EditorSession empty; + ProjectController idle(empty); + idle.externalChanges.pending = true; + idle.resumeExternalChangeCheck(); + QVERIFY(!idle.externalChanges.checking); + QVERIFY(!idle.externalChanges.pending); +} + +QTEST_MAIN(ExternalChangeTests) +#include "ExternalChangeTests.moc" diff --git a/tests/ProjectDigestRaceTests.cpp b/tests/ProjectDigestRaceTests.cpp new file mode 100644 index 0000000..89e02e4 --- /dev/null +++ b/tests/ProjectDigestRaceTests.cpp @@ -0,0 +1,72 @@ +#include "IO/ProjectDigest.h" +#include <QDir> +#include <QFile> +#include <QTemporaryDir> +#include <QtTest> +#include <dirent.h> +#include <dlfcn.h> +#include <unistd.h> + +// An image gone between listing and looking: no digest. +namespace { +// The file to remove once its folder's listing ends. +QByteArray vanishing; +bool vanished = false; + +bool listsImages(DIR *directory) +{ + char path[4096]; + const QByteArray link = "/proc/self/fd/" + QByteArray::number(dirfd(directory)); + const ssize_t size = readlink(link.constData(), path, sizeof path - 1); + return size > 0 && QByteArray(path, int(size)).endsWith("/images"); +} + +// At the listing's end the image goes, before any lookup. +template <typename Entry> Entry *listed(DIR *directory, Entry *entry) +{ + if (!entry && !vanishing.isEmpty() && !vanished && listsImages(directory)) + vanished = ::unlink(vanishing.constData()) == 0; + return entry; +} +} + +// These take libc's place in the test executable. +extern "C" struct dirent *readdir(DIR *directory) +{ + static const auto real = reinterpret_cast<struct dirent *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir")); + return listed(directory, real(directory)); +} + +extern "C" struct dirent64 *readdir64(DIR *directory) +{ + static const auto real = reinterpret_cast<struct dirent64 *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir64")); + return listed(directory, real(directory)); +} + +class ProjectDigestRaceTests : public QObject { + Q_OBJECT +private slots: + void anImageGoneMidListingGivesNoDigest(); +}; + +void ProjectDigestRaceTests::anImageGoneMidListingGivesNoDigest() +{ + QTemporaryDir root; + const QString path = root.filePath(QStringLiteral("Race.comp")); + QVERIFY(QDir().mkpath(path + QStringLiteral("/images"))); + for (const QString &name : {QStringLiteral("manifest.json"), QStringLiteral("images/A.png"), QStringLiteral("images/B.png")}) { + QFile file(path + QLatin1Char('/') + name); + QVERIFY(file.open(QIODevice::WriteOnly) && file.write("bytes") == 5); + } + // Listed whole, one image looked up after it is gone. + const QByteArray whole = ProjectDigest::compute(path); + vanishing = QFile::encodeName(path + QStringLiteral("/images/A.png")); + QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path)); + QVERIFY(vanished); + // Settled, the package fingerprints again, without it. + vanishing.clear(); + QVERIFY(ProjectDigest::compute(path) != whole); +} + +QTEST_GUILESS_MAIN(ProjectDigestRaceTests) +#include "ProjectDigestRaceTests.moc" diff --git a/tests/ProjectWatcherTests.cpp b/tests/ProjectWatcherTests.cpp new file mode 100644 index 0000000..9d0d40d --- /dev/null +++ b/tests/ProjectWatcherTests.cpp @@ -0,0 +1,218 @@ +#include "IO/ProjectDigest.h" +#include "IO/ProjectWatcher.h" +#include <QDir> +#include <QFile> +#include <QCryptographicHash> +#include <QElapsedTimer> +#include <QTemporaryDir> +#include <QtEndian> +#include <fcntl.h> +#include <sys/syscall.h> +#include <unistd.h> +#include <QtTest> + +// Swift's ProjectWatcher: one report a burst, the watch kept. +namespace { +void writeFile(const QString &file, const QByteArray &bytes) +{ + QFile out(file); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate) || out.write(bytes) != bytes.size()) + throw std::runtime_error("could not write " + file.toStdString()); +} + +// A package: a manifest and an images folder. +struct Package { + QTemporaryDir root; + QString path = root.filePath(QStringLiteral("Watched.comp")); + int reports = 0; + std::unique_ptr<ProjectWatcher> watcher; + Package() + { + QDir().mkpath(path + QStringLiteral("/images")); + writeFile(path + QStringLiteral("/manifest.json"), "{}"); + watcher = std::make_unique<ProjectWatcher>(path, [this] { reports += 1; }); + } + QTimer &timer(const char *name) const { return *watcher->findChild<QTimer *>(QString::fromLatin1(name)); } +}; +} + +class ProjectWatcherTests : public QObject { + Q_OBJECT +private slots: + void aBurstOfWritesReportsOnce(); + void aManifestWrittenInPlaceIsSeen(); + void anImageAddedIsSeen(); + void reArmingStopsOnceEveryPathIsWatched(); + void aMissingPathIsTriedForTwoSeconds(); + void aDigestIgnoresTheOrderImagesWereWritten(); + void anExchangeKeepingTheOldPackageIsSeen(); + void deliveryWaitsTheWholeQuietTime(); + void aDigestHashesTheManifestNamesAndSizes(); + void anUnreadableManifestGivesNoDigest(); +}; + +void ProjectWatcherTests::aBurstOfWritesReportsOnce() +{ + Package package; + for (int count = 0; count < 3; ++count) { + writeFile(package.path + QStringLiteral("/manifest.json"), QByteArray::number(count)); + QTest::qWait(50); + } + QCOMPARE(package.reports, 0); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000); + // Single shot: quiet afterwards. + QTest::qWait(1000); + QCOMPARE(package.reports, 1); + QVERIFY(!package.timer("delivery").isActive()); +} + +void ProjectWatcherTests::aManifestWrittenInPlaceIsSeen() +{ + Package package; + // Its entries are unchanged: only the manifest's own watch speaks. + writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"changed\": true }"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000); +} + +void ProjectWatcherTests::anImageAddedIsSeen() +{ + Package package; + // Inside images/: only that folder's watch speaks. + writeFile(package.path + QStringLiteral("/images/A.png"), "png"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000); + writeFile(package.path + QStringLiteral("/images/B.png"), "png"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 2, 2000); +} + +void ProjectWatcherTests::reArmingStopsOnceEveryPathIsWatched() +{ + Package package; + writeFile(package.path + QStringLiteral("/manifest.json"), "[]"); + QTRY_VERIFY_WITH_TIMEOUT(package.timer("rearm").isActive(), 1000); + QTRY_VERIFY_WITH_TIMEOUT(!package.timer("rearm").isActive(), 500); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000); + // A swapped-in manifest is watched again by path. + QFile::remove(package.path + QStringLiteral("/manifest.json")); + writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"swapped\": true }"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 2, 2000); + writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"again\": true }"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 3, 2000); +} + +void ProjectWatcherTests::aMissingPathIsTriedForTwoSeconds() +{ + Package package; + int tries = 0; + connect(&package.timer("rearm"), &QTimer::timeout, [&] { tries += 1; }); + QVERIFY(QDir(package.path + QStringLiteral("/images")).removeRecursively()); + QTRY_VERIFY_WITH_TIMEOUT(package.timer("rearm").isActive(), 1000); + // A hundred milliseconds a try, twenty tries. + QTest::qWait(1000); + QVERIFY(package.timer("rearm").isActive()); + QTRY_VERIFY_WITH_TIMEOUT(!package.timer("rearm").isActive(), 2000); + QCOMPARE(tries, 20); +} + +void ProjectWatcherTests::aDigestIgnoresTheOrderImagesWereWritten() +{ + QTemporaryDir root; + const auto make = [&](const QString &name, const QStringList &order) { + const QString path = root.filePath(name); + QDir().mkpath(path + QStringLiteral("/images")); + writeFile(path + QStringLiteral("/manifest.json"), "{}"); + for (const QString &image : order) + writeFile(path + QStringLiteral("/images/") + image, image.toUtf8()); + return ProjectDigest::compute(path); + }; + const QStringList names{QStringLiteral("A.png"), QStringLiteral("BB.png"), QStringLiteral("CCC.png"), QStringLiteral("D.mask.png")}; + QStringList reversed = names; + std::reverse(reversed.begin(), reversed.end()); + QCOMPARE(make(QStringLiteral("One.comp"), names), make(QStringLiteral("Two.comp"), reversed)); +} + +void ProjectWatcherTests::anExchangeKeepingTheOldPackageIsSeen() +{ + Package package; + const QString other = package.root.filePath(QStringLiteral("Other.comp")); + QDir().mkpath(other + QStringLiteral("/images")); + writeFile(other + QStringLiteral("/manifest.json"), "{ \"other\": true }"); + QTest::qWait(500); + QCOMPARE(package.reports, 0); + // Another writer swaps packages and keeps the old one aside. + QCOMPARE(syscall(SYS_renameat2, AT_FDCWD, QFile::encodeName(other).constData(), AT_FDCWD, QFile::encodeName(package.path).constData(), 2), 0L); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000); + // The new package's own files are watched now. + writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"edited\": true }"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 2, 2000); + writeFile(package.path + QStringLiteral("/images/A.png"), "png"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 3, 2000); + // A sibling that is not the package says nothing. + writeFile(package.root.filePath(QStringLiteral("Unrelated.txt")), "text"); + QTest::qWait(600); + QCOMPARE(package.reports, 3); +} + +void ProjectWatcherTests::deliveryWaitsTheWholeQuietTime() +{ + Package package; + QCOMPARE(package.timer("delivery").timerType(), Qt::PreciseTimer); + QCOMPARE(package.timer("rearm").timerType(), Qt::PreciseTimer); + QCOMPARE(package.timer("delivery").interval(), 300); + QCOMPARE(package.timer("rearm").interval(), 100); + // Quiet time counts from the last write, not the first. + writeFile(package.path + QStringLiteral("/manifest.json"), "[]"); + QTest::qWait(200); + QCOMPARE(package.reports, 0); + QElapsedTimer clock; + clock.start(); + writeFile(package.path + QStringLiteral("/manifest.json"), "[1]"); + QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000); + QVERIFY2(clock.elapsed() >= 300, qPrintable(QString::number(clock.elapsed()))); + QTest::qWait(500); + QCOMPARE(package.reports, 1); +} + +void ProjectWatcherTests::aDigestHashesTheManifestNamesAndSizes() +{ + QTemporaryDir root; + const QString path = root.filePath(QStringLiteral("Digest.comp")); + const QString images = path + QStringLiteral("/images/"); + QDir().mkpath(images + QStringLiteral("folder")); + writeFile(path + QStringLiteral("/manifest.json"), "M"); + writeFile(images + QStringLiteral("b.png"), "bbb"); + writeFile(images + QStringLiteral("a.png"), "aa"); + writeFile(images + QStringLiteral(".hidden"), "h"); + QVERIFY(QFile::link(images + QStringLiteral("a.png"), images + QStringLiteral("link.png"))); + QVERIFY(QFile::link(images + QStringLiteral("gone.png"), images + QStringLiteral("dangling.png"))); + // The oracle: manifest, then sorted names and sizes. + QCryptographicHash oracle(QCryptographicHash::Sha256); + oracle.addData(QByteArrayView("M")); + for (const auto &[name, size] : {std::pair(".hidden", 1), std::pair("a.png", 2), std::pair("b.png", 3)}) { + oracle.addData(QByteArrayView(name)); + const quint64 little = qToLittleEndian(quint64(size)); + oracle.addData(QByteArrayView(reinterpret_cast<const char *>(&little), sizeof little)); + } + QCOMPARE(ProjectDigest::compute(path), oracle.result()); + // Same size, other bytes: not read, so unchanged. + writeFile(images + QStringLiteral("a.png"), "zz"); + QCOMPARE(ProjectDigest::compute(path), oracle.result()); + // Same size, another name: changed. + QVERIFY(QFile::rename(images + QStringLiteral("b.png"), images + QStringLiteral("c.png"))); + QVERIFY(ProjectDigest::compute(path) != oracle.result()); +} + +void ProjectWatcherTests::anUnreadableManifestGivesNoDigest() +{ + QTemporaryDir root; + const QString path = root.filePath(QStringLiteral("Broken.comp")); + // A folder opens but cannot be read. + QDir().mkpath(path + QStringLiteral("/manifest.json")); + QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path)); + // A size that is not what reads: /proc says zero. + QVERIFY(QDir(path + QStringLiteral("/manifest.json")).removeRecursively()); + QVERIFY(QFile::link(QStringLiteral("/proc/self/status"), path + QStringLiteral("/manifest.json"))); + QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path)); +} + +QTEST_GUILESS_MAIN(ProjectWatcherTests) +#include "ProjectWatcherTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 748379c..730641b 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -152,6 +152,10 @@ compositor_test(ScrubbableTransformTests) compositor_test(NewCanvasSheetTests) compositor_test(OmarchyThemeTests) compositor_test(ProjectControllerTests) +compositor_test(ExternalChangeTests) +compositor_test(ExternalChangeGateTests) +compositor_test(ProjectWatcherTests) +compositor_test(ProjectDigestRaceTests) compositor_test(ProjectCopyTests) compositor_test(ProjectOpenTests) compositor_test(ProjectReceiveTests)