diff --git a/AGENTS.md b/AGENTS.md index c0341cd..f547209 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Platform adaptations, each forced by a missing Qt analog: - Brush speed (8.3's gate: 4K at a scale of 2, an 800 px soft brush, median frame at most 8 ms): 6.8–7.4 ms measured, from 47. The soft tip's density comes from a table of 8192 steps over d²/r² from h² to the rim, in radii because hardness alone shapes it, linear between steps; inside h the tip is solid, the table's first value, so a near-hard tip keeps every step across its fade (from 0.9995 on, a table over the whole radius missed the falloff by up to 46 levels). Once the table's steps are finer than float can tell apart ((1 − h²) / 8192 under float's epsilon, from about 0.9995), even that table misses by a few levels: there `tipDensity` evaluates the shader's own formula, float for float, for dabs and segments alike, its solid and empty ends returning at once what the formula yields, so only samples in the thin fade pay for `exp` and `log`. `BrushCoverageTests` holds dabs and strokes on both sides of the switch against the shader's own quadrature, replayed in float. The kernel keeps the shader's order of float operations to the letter (`midpoint ± halfLength · node − projection`, never regrouped): 8192 pixels along a near-hard stroke a regrouping moved a pixel three levels, and a reference written from the twin's own code repeated it. Its one read per lookup is checked at the upper step, so a lost bound aborts. Only a segment passing through the solid core can look up inside it, so the lower clamp runs for those alone (`quadrature`): for every segment it cost 0.3 ms a frame. Bands of 32 rows are the tasks. The halving's bands take their rows, sums and source row from the caller's scratch, so a worker allocates nothing and a failed allocation throws where the caller catches it (QtConcurrent wraps a worker's exception in `QUnhandledException`). `publish` copies each tile and makes its selection coverage on the calling thread, so a failure throws there and a worker's malloc arena never meets a starved allocator, then fills side by side. Pieces compose and halve side by side (`sideBySide`), each halving unthreaded: Qt 6.4 deadlocks on a `blockingMap` inside a map task. The Lanczos colour rows sum four channels in a GCC vector (`vector_size(16)`), each lane as the scalar sum, and the vertical pass runs tap by tap over whole rows; `BrushCoverage.cpp` and `DownsampleCache.cpp` build at `-O3`, where GCC vectorises them. The shadow is skipped when the dirty rect lies inside the document, whose opaque fill covers it. Equivalent mutants: that skip, `fill`'s skip of a zero weight, the colour row's right clamp (it only ever reads padding), the table's interpolation (under a third of a level), its lower clamp at step 1 (the first steps of every fade sit at the density's cap), the clamped quadrature for every segment, the formula for every hardness and `tipDensity`'s two early returns (each only costs time). `BrushPerformanceTests` runs under `BRUSH_BENCHMARK=1`. - Canvas speed (11.1): `CanvasPerformanceTests` (under `CANVAS_BENCHMARK=1`, at a backing scale of two, as the brush benchmark) pans a 1000-point canvas by trackpad deltas through the canvas's own wheel path, at fit and at Actual Pixels (`zoom(1)`, as the menu sets it), and zooms it in 5% steps around its middle, over a 3840 × 2160 document of ten noise layers (photographs and halves, opacities, multiply, screen, overlay and two Hue layers). Measured on the dev container (32 cores): pan at fit 30 ms median (p95 37), pan at Actual Pixels 68 ms (p95 74), zoom 77 ms (p95 86); with every layer normal 16, 15 and 29 ms. The hand-blended HSL modes are the cost: `HslBlend::blend` ran one thread over the layer's device area, 40 ms a Hue layer at fit; its rows now blend side by side (pointers taken first, since `scanLine` detaches), the same bytes, 3× faster (pan at fit 85 → 30 ms), and the mode is checked before the rows start, since a worker's exception comes back wrapped. At Actual Pixels a Hue layer still costs about 25 ms at 2× (the backdrop's copy and conversion, the enlarged surface and its draw back); the rest is QPainter's single-threaded smooth scaling, about 40 megapixels a frame at this size. Painting the canvas in parallel bands would need its caches (pieces, strokes, effects previews) made safe across threads, and is not done. The guards sit at half again these medians (45, 100 and 115 ms), so a regression fails. Compared with `docs/brush-performance.md`: Swift measures strokes only (2.6–2.8 ms median on macOS); the twin's stroke gate is 8.3's (6.8–7.4 ms at 4K, scale two), and Swift has no pan or zoom figures to compare. `Rendering/PoolMap` (Linux-only) wraps every `QtConcurrent::blockingMap`: Qt 6.4's deadlocks when a pool thread waits on a full pool (a worker's blur or blend on one core, or two workers on two), so a pool thread lends its place while it waits (`releaseThread`, then `reserveThread`, Qt's documented remedy); `PoolMapTests` runs the blend, the blur and a map on a pool of one thread. - Desktop integration (11.2): the app id is `io.github.ZacharyZhang_NY.OmaPhoto` (Compositor's was its bundle identifier, `com.wonderassembly.compositor`), which names the desktop entry, the icon and the MIME package, and which `main` gives Qt as the desktop file name, so a Wayland compositor matches the window to its entry and icon; on X11 Qt 6.4 names the window class after the application (`omaphoto`, `OmaPhoto`), so the entry's `StartupWMClass` is `OmaPhoto`. `packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop` launches `omaphoto %F` (files named at launch reach `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate) and lists Swift's two document types: its projects (`application/x-compositor-project`) and JPEG, PNG, HEIC and TIFF. `io.github.ZacharyZhang_NY.OmaPhoto.xml` declares the project type for shared-mime-info, a folder (`inode/directory`) named `*.comp`, commented "OmaPhoto Project", where Swift says "Compositor Project"; a file manager may still open such a folder as a folder, since a project is a directory on Linux. `cmake --install` lays down the app, the entry, the MIME package, Compositor's app icon (`packaging/icons`) at six hicolor sizes (16 to 512) and U²-Net's licence under `share/licenses/omaphoto`; `OMAPHOTO_MODEL`, a path, installs the model as `share/omaphoto/u2net.onnx` whatever its own name, the file itself when given a link (`file(REAL_PATH)`), where `SubjectRemoval` looks, and packages pass it (11.3). A direct install (no `DESTDIR`) runs `update-mime-database` on its prefix, required, so the project type registers at once; a staged install leaves that to the package's hooks. `DesktopIntegrationTests` reads both files, stages an install and compares the whole file list (the model included when the build was configured with one, read from its cache), and installs directly with a stand-in `update-mime-database` first on the path and any `DESTDIR` of the caller cleared; the call in `main` is not under test. -- The acceptance run (11.5): `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window as `main` builds it (`AcceptanceFixtures.h`'s `App`: an `OmarchyTheme` on a folder of the test's own, the workspace, the window at 1180 × 780), one scenario for each row of the README's feature list, through menu entries, canvas gestures, sheets and the real panels (`DialogDesk`), and check pixels, layers, sizes and files. With `ACCEPTANCE_SHOTS` naming a folder, each scenario saves the window with its shown floating panels drawn over it at their places (offscreen draws no title bars), after 300 ms of events, since posted geometry and previews land from the event loop; `docs/acceptance/` holds the committed shots and `docs/acceptance.md` the table. A fix the run found: `~CompositorMenus` disconnects from `QApplication::focusChanged` first, because its Keyboard Shortcuts panel, a member, hides as it goes and moves the focus into the half-destroyed menus (quitting with that panel active crashed); `KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys` holds it. So does `~ProjectWorkspaceView` with its session watch: a floating panel whose field holds typing (the colour picker, Levels, Hue/Saturation, a filter) commits as it hides, and the session's signal reached the window's toolbar and tab strip, already gone; `WindowTeardownTests` holds all four. +- The acceptance run (11.5): `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window as `main` builds it (`AcceptanceFixtures.h`'s `App`: an `OmarchyTheme` on a folder of the test's own, the workspace, the window at 1180 × 780), one scenario for each row of the README's feature list, through menu entries, canvas gestures, sheets and the real panels (`DialogDesk`), and check pixels, layers, sizes and files; `AcceptanceNewTests`, `AcceptanceEditTests` and `AcceptanceFinishTests` (13.19h) do the same for each row new in 1.4.5. With `ACCEPTANCE_SHOTS` naming a folder, each scenario saves the window with its shown floating panels drawn over it at their places (offscreen draws no title bars), after 300 ms of events, since posted geometry and previews land from the event loop; `docs/acceptance/` holds the committed shots and `docs/acceptance.md` the table. A fix the run found: `~CompositorMenus` disconnects from `QApplication::focusChanged` first, because its Keyboard Shortcuts panel, a member, hides as it goes and moves the focus into the half-destroyed menus (quitting with that panel active crashed); `KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys` holds it. So does `~ProjectWorkspaceView` with its session watch: a floating panel whose field holds typing (the colour picker, Levels, Hue/Saturation, a filter) commits as it hides, and the session's signal reached the window's toolbar and tab strip, already gone; `WindowTeardownTests` holds all four. - Packages (11.3): one CMake install serves every package. It lays down the app, its entry, MIME package, icons and both licences (the app's MIT `LICENSE` and U²-Net's, under `share/licenses/omaphoto`), and `OMAPHOTO_MODEL` adds the model. `OMAPHOTO_BUNDLE_ORT` (off by default) installs ONNX Runtime beside the app in `lib/omaphoto` (under the library folder) with the app's run path set there, and Microsoft's `LICENSE` and `ThirdPartyNotices.txt` with it (`OMAPHOTO_ORT_NOTICES`, where the images keep them from the release; a configure that cannot find them fails). Every package bundles (11.4d, issue #5): ONNX Runtime stamps each release's symbols with its version (`VERS_1.20.1`), so a package linked against a system's runtime refuses to install, or to start, once that system updates it. The DEB, the RPM and the Arch package carry Microsoft's 1.21.0 build, pinned by checksum, which the dev, Fedora and Arch images install into `/usr/local` too, so the tests run against the runtime that ships; the flake builds from nixpkgs' own, since Nix rebuilds its closure with it. `DesktopIntegrationTests` expects the runtime (in the configured library folder), its version link and its notices in a bundling build, as it expects the model in a build given one, reading what CMake itself evaluated through compile definitions (both checked by hand in a second build). CPack makes the DEB (Ubuntu) and the RPM (Fedora), each on its own system: the DEB's library dependencies come from `dpkg-shlibdeps`, which finds the bundled runtime through the run path, and it names what no library does (`libheif-plugin-libde265` for HEVC, `qt6-image-formats-plugins` for TIFF, `shared-mime-info`, `hicolor-icon-theme`); the RPM asks for `qt6-qtimageformats`, `shared-mime-info` and `hicolor-icon-theme`, its libraries found by rpmbuild but the bundled runtime left out of what it provides and requires (`__provides_exclude_from`, `__requires_exclude` in `CPACK_RPM_SPEC_MORE_DEFINE`), and suggests RPM Fusion's `libheif-freeworld`, since Fedora's own libheif decodes no HEVC. Packages stage their install (`DESTDIR`), so their systems' hooks and triggers refresh the MIME and icon caches. `Dockerfile.dev` gains `dpkg-dev`, `file` and `fakeroot` for the DEB. `packaging/arch/PKGBUILD` builds this checkout for Arch and Omarchy (`makepkg` there), fetching the model and Microsoft's runtime by their checksums; its `prepare()` lays the runtime out as that build's CMake config expects (`lib64`, `include/onnxruntime` beside `lib/cmake`) and the package bundles it. `flake.nix` sits at the repository root; it builds the app alone and installs staged (`DESTDIR=/`) so the store holds no MIME cache. The DEB was built and read back in the dev image; the RPM, the PKGBUILD and the flake are built on their own systems in 11.4. - Distro checks (11.4): `scripts/distro-check.sh arch|fedora|fedora44|nixos|resolute` builds `scripts/distro//Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; `fedora` is 43 since 11.4d, `fedora44` the same file on 44 since 12.16) and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path with shared-mime-info's beside it (a NixOS system has the database in its profile through `xdg.mime.enable`; without it Qt knows no camera RAW type, missed until 12.16) and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators. Tests follow the systems where only a message or a count differs: libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The five starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder, and the `Compositor-Ref` spec, which a `path:` flake would copy whole. - Spot Healing (8.4): `HealPixels.c` compiles in place beside the other kernels. `SpotHealingMode`, `allSpotHealingModes` and `rawValue` (Swift's names) sit in `BrushStroke.h`, and `BrushSettings` gains `healing` and `healingMode`. While the stroke lasts its tiles show Swift's wash, gray 0.12 at 45% through the coverage, never erasing. `heal` takes the painted bounds from the kernel's `heal_coverage_bounds`, draws the source into a region about three spots wider, and hands the kernel the coverage packed row by row, as Swift's gray context holds it (the twin copies each tile's rows where Swift draws each coverage image); the seed is `QRandomGenerator::global()`, Swift's `UInt32.random`. Each touched tile is then its base with the healed region copied over, blended with the base through `PixelAdjust::blend` under a selection, where Swift clips the context. The kernel's failure is `ProjectError(tooLarge)`; pixels or packed coverage that cannot be allocated are `ExportError(render)`, as Swift's contexts. The session's `beginBrush` takes Spot Healing on pixels alone, never on a mask, as Swift's guard; `finishBrushImmediately` heals before the commit, one `Spot Healing` step; `spotHealingMode` is a getter and a setter that notifies; Tab steps the type. `isBrushTool` is Swift's four tools: the circle, the right-drag tip, the brackets, the digits and `BrushControls` serve each, and a press with Clone Stamp or the Smear begins nothing until 8.5 and 8.6 bring them. `ContentView` keeps one bar across the four, as Swift's single `isBrushTool` branch keeps its view; the bar titles itself by tool (Spot Healing, Clone Stamp, Smear, Eraser, Brush), shows Paint and Erase for the Brush alone and the three types for Spot Healing alone, and names opacity Strength for the Smear; the clone's Aligned and Sample arrive with 8.5, the Smear's modes with 8.6. Equivalent mutant: the healed tile starting from its context rather than its base, since the region holds the whole wash and the copy replaces it. diff --git a/TASKS.md b/TASKS.md index d37a702..b428056 100644 --- a/TASKS.md +++ b/TASKS.md @@ -492,7 +492,11 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.19g work: eight full runs at `-j16` and `-j32` on 32 cores passed 330 of 330; the five failures of `81894f7` did not come back, alone or with the nineteen suites that share `QStandardPaths`' test folder, so their cause is not known (the old runs had a 12-core cap and bwrap). Under a 4-CPU cap at `-j16` a new one failed three of three: `AcceptanceTests::paintingTools`' rectangle made no layer. Diagnostics showed the gradient edit still pending when the drag began, with the project free: `finishRasterCommit` freed the project, then posted the continuation that clears the gradient, so for one turn of the event loop the edit refused every press (Swift's caller resumes from `await` in the same job). The continuation now runs where the commit ends, for the gradient, the pixel move and fills; their callers' callbacks stay posted. Round 1 of review found the same gap one level up, through posted callbacks that carry work: the floating selection installed after the clear, and a filter, Levels, Hue/Saturation or Invert begun after a pending gradient read whatever layer was active by then. With the continuation inline, both run where the commit ends; only early paths post. `CommitContinuationTests` posts a check as the project frees: the gradient gone, the move gone, the floating edit ready, a tool chosen then staying chosen, and each chained command keeping its layer; the two first were red before the fix, posting the continuation again is caught, and posting the gradient's or the fill's continuation is caught by the chained and floating rows. Then 331 of 331 at `-j16` and three of three under the 4-CPU cap. - 13.19g stress past that: 2 CPUs at `-j32`, sixteen processes a core, failed six and eight suites, every one a time limit (waits of 5 to 20 s, QtTest's 300 s watchdog on `LargeCanvasBrushTests` and `LayerContextMenuEdgeTests`) but `SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError`, whose 512 KB limit then found room; it passes alone at 2 CPUs three of three and under the 4-CPU load, and its cause under that stress is not established. `dev.sh test` and the distro checks run the suite one test at a time. - 13.19g review: round 1 FAIL (the floating selection and the commands chained after a gradient still had a gap); round 2 PASS. - - [ ] 13.19h The acceptance run and its shots. + - [x] 13.19h The acceptance run and its shots. + - 13.19h work: the acceptance run checked none of the seventeen rows new in 1.4.5, though the README says it checks each feature. `AcceptanceNewTests` (Color Range, Grid Settings, snapping; presets, Quick Look, Ungroup, tabs), `AcceptanceEditTests` (fonts and their preview, painting at a layer's resolution, a hidden layer's refusal, Blur's Radius, Liquify, Smudge, masks; Move's held keys and typed width, Soft Light and a Linear Dodge stack on the canvas against the export) and `AcceptanceFinishTests` (Camera Raw's curves, Dither's ASCII and Scanlines, the JPEG preview's keys, Quit with a gradient pending and with Levels open) drive the app's window for each. All six acceptance binaries pass, twice in parallel; the shots are taken again, two of them new (`17-tabs`, `18-mask-alone`); the table says what each new row's run checks, and every row but the distro and package rows reads pass. + - 13.19h review: round 1 FAIL: the font scenario emitted the menu's signals itself, Dither and Blur's Radius were set past their controls, the ASCII check took blocks for letters, the mask-alone view was not looked at, and seven rows said more than their scenarios (my rewrite of the table had not landed). Fixed: the font popup opened, hovered and clicked (`popupRow`, `choose`); Dither's style, characters and text size through its sheet and OK, Camera Raw through OK, the Radius typed in the bar, Ctrl+Shift+G and Ctrl+Z as keys, Color Range's OK, Save As from the menu; ASCII of \"@\" must keep holes (inked share under three quarters) and grow about twice at 28; the mask-alone canvas checked black and white; each row says what its scenario does. The reviewer's mutants (no popup, no style handler, blocks for letters, no mask-alone drawing) and a Radius field that writes nothing are each caught. + - 13.19h round 2: FAIL: the committed Camera Raw shot showed the zero-exposure preview, since the scenario waited for any preview. It now waits for the preview whose ground has brightened; the shot is retaken. + - 13.19h round 3: PASS. - [ ] 13.19i Distro checks: arch, fedora, fedora44, nixos, resolute. - [ ] 13.19j Tag `v1.4.5`; the release workflow publishes every package. - [ ] 13.19k Live installs from GitHub in fresh containers. diff --git a/docs/acceptance.md b/docs/acceptance.md index 9f8a116..acc7e6a 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -1,13 +1,13 @@ # OmaPhoto 1.4.5 acceptance -The acceptance table for OmaPhoto 1.4.5, the Linux port of Compositor 1.4.5. Each row is a feature from the README. The rows under "New in 1.4.5" cover what changed since 1.3.3. A "pending" result waits for the 1.4.5 run. +The acceptance table for OmaPhoto 1.4.5, the Linux port of Compositor 1.4.5. Each row is a feature from the README. The rows under "New in 1.4.5" cover what changed since 1.3.3. A "pending" result waits for the release. -The acceptance run drives the app's own window, the way a person uses it. It chooses menu entries, drags on the canvas, types into sheets and answers the real file panels. Then it checks concrete results: pixels, layers, sizes and files on disk. The run is three test programs: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests`. They take the screenshots below when `ACCEPTANCE_SHOTS` names a folder. +The acceptance run drives the app's own window, the way a person uses it. It chooses menu entries, drags on the canvas, types into sheets and answers the real file panels. Then it checks concrete results: pixels, layers, sizes and files on disk. The run is six test programs: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` for the table, `AcceptanceNewTests`, `AcceptanceEditTests` and `AcceptanceFinishTests` for the rows new in 1.4.5. They take the screenshots below when `ACCEPTANCE_SHOTS` names a folder. ```sh docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen -v "$PWD:$PWD" -w "$PWD" omaphoto-dev \ - sh -c 'cmake -S . -B build && cmake --build build --target AcceptanceTests AcceptanceColorTests AcceptanceProjectTests && - for t in AcceptanceTests AcceptanceColorTests AcceptanceProjectTests; do ACCEPTANCE_SHOTS=$PWD/docs/acceptance build/$t || exit 1; done' + sh -c 'cmake -S . -B build && cmake --build build --target AcceptanceTests AcceptanceColorTests AcceptanceProjectTests AcceptanceNewTests AcceptanceEditTests AcceptanceFinishTests && + for t in AcceptanceTests AcceptanceColorTests AcceptanceProjectTests AcceptanceNewTests AcceptanceEditTests AcceptanceFinishTests; do ACCEPTANCE_SHOTS=$PWD/docs/acceptance build/$t || exit 1; done' ``` The screenshots come from Qt's offscreen platform, so floating panels appear without the window manager's title bars. The run uses the built-in dark theme, except for the last row. @@ -16,21 +16,21 @@ The screenshots come from Qt's offscreen platform, so floating panels appear wit | Feature | What the run does and checks | Screenshot | Suites that hold the details | Result | | --- | --- | --- | --- | --- | -| Layers, folders, masks, clipping masks, blend modes, opacity | Imports a photo and paints a layer. Clips it to the photo and sets Multiply at 60%. Masks the photo and groups both in a folder. Undo takes a step back. | [01](acceptance/01-layers.png) | `GroupTests`, `LayerMaskTests`, `LiveMaskTests`, `LayerAppearanceTests`, `LayerRendererTests`, `HistoryTests` | pending | -| Selections | Drags a marquee to within 2 px. The wand takes the flat ground. Select › Subject finds the red disc with the U²-Net model. | [02](acceptance/02-selections.png) | `SelectionTests`, `MagicWandTests`, `SelectSubjectTests`, `SelectionEditTests`, `ObjectSelectionTests`, `SelectionFeatherTests` | pending | -| Painting tools | Drags a gradient that fades to clear, a rectangle shape and a soft white brush stroke. Types "Hello OmaPhoto" on the canvas and colours "Hello" red. That leaves one colour run, committed with Ctrl+Return. | [03](acceptance/03-painting.png) | `BrushTests`, `GradientTests`, `ShapeToolTests`, `TypeToolTests`, `TextColorRunsTests`, `SpotHealingTests`, `CloneStampTests`, `SmudgeLiquifyTests` | pending | -| Move, transform, distort; rulers, guides, grid | Drags the layer with the Move tool. Transform Layer halves it, and Return applies. Rulers, a guide and the grid are shown. | [04](acceptance/04-transform-guides-grid.png) | `TransformSessionTests`, `DistortTests`, `GuideTests`, `FloatingSelectionTests` | pending | -| Adjustments as edits and as layers | Hue/Saturation turns the red disc green. An Invert layer inverts the view and leaves the pixels alone. A Curves layer opens its editor. | [05](acceptance/05-adjustments.png) | `HueSaturationTests`, `LevelsTests`, `CurvesControlsTests`, `AdjustmentLayerTests`, `AdjustmentEditorTests`, `ImageAdjustmentTests` | pending | -| Camera Raw Filter, docked | Opens in the dock beside the canvas. One stop of exposure previews, commits and brightens the ground. | [06](acceptance/06-camera-raw.png) | `CameraRawTests`, `CameraRawSessionTests`, `CameraRawPanelTests`, `CameraRawCanvasTests` | pending | -| Layer effects | A 12 px red stroke rings a shape outside its edge, and a drop shadow's sheet shows. The shape keeps its fill. | [07](acceptance/07-effects.png) | `LayerEffectsTests`, `LayerEffectsKernelTests`, `EffectsPanelTests`, `EffectsCanvasTests`, `InnerGlowTests` | pending | -| Filters | Dither leaves only black and white dots. Remove Background masks the photo around its subject: the centre stays opaque and a corner turns clear. | [08](acceptance/08-filters-dither.png), [09](acceptance/09-remove-background.png) | `DitherTests`, `FilterTests`, `FinishingFilterTests`, `SubjectRemovalTests`, `RemoveBackgroundTests` | pending | -| Crop, trim, canvas size, image size; PNG and JPEG export | Crops to 1000 × 600. Canvas Size widens it to 1400. Trim finds the photo's own 1200 again. Image Size halves it. Exports a PNG through the save panel and a JPEG through its sheet and panel, then reads both back at 600 × 300. | [10](acceptance/10-canvas-size.png), [11](acceptance/11-export-jpeg.png) | `CropTests`, `ImageTrimTests`, `ProjectResizeTests`, `ProjectExportTests`, `JPEGExportTests` | pending | -| Imports | Takes JPEG, PNG, TIFF, HEIC, SVG, a layered Photoshop file (it arrives as a folder) and a camera RAW file, which asks how to develop it. That makes eight layers with no import error. | [12](acceptance/12-raw-develop.png), [13](acceptance/13-imports.png) | `ImageImportTests`, `PSDImportTests`, `RawImporterTests`, `RawDevelopTests`, `SVGImportTests` | pending | -| Open Recent; Keyboard Shortcuts window | Saves a project, and Open Recent lists it. The shortcuts window opens over the editor. | [15](acceptance/15-shortcuts.png) | `OpenRecentTests`, `RecentProjectsCoreTests`, `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests` | pending | -| Scrub a number by its label | Dragging the Opacity title 40 points left lowers the layer's opacity. | [14](acceptance/14-scrub.png) | `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableSheetsTests` | pending | -| Keep working while a project saves | Another layer is added while a save runs. The file holds the state the save began with. | — | `ProjectSaveWhileEditingTests` | pending | -| Reload a project changed on disk | With unsaved edits open, another program renames a layer on disk. The app's alert asks, Revert is chosen, and the saved state shows with the new name. | [15](acceptance/15-shortcuts.png) | `ExternalChangeTests` | pending | -| Follows the Omarchy theme | Starts in the built-in dark. A Catppuccin Latte `colors.toml` written while the app runs retints the window. | [16](acceptance/16-omarchy-light.png) | `OmarchyThemeTests` | pending | +| Layers, folders, masks, clipping masks, blend modes, opacity | Imports a photo and paints a layer. Clips it to the photo and sets Multiply at 60%. Masks the photo and groups both in a folder. Undo takes a step back. | [01](acceptance/01-layers.png) | `GroupTests`, `LayerMaskTests`, `LiveMaskTests`, `LayerAppearanceTests`, `LayerRendererTests`, `HistoryTests` | pass | +| Selections | Drags a marquee to within 2 px. The wand takes the flat ground. Select › Subject finds the red disc with the U²-Net model. | [02](acceptance/02-selections.png) | `SelectionTests`, `MagicWandTests`, `SelectSubjectTests`, `SelectionEditTests`, `ObjectSelectionTests`, `SelectionFeatherTests` | pass | +| Painting tools | Drags a gradient that fades to clear, a rectangle shape and a soft white brush stroke. Types "Hello OmaPhoto" on the canvas and colours "Hello" red. That leaves one colour run, committed with Ctrl+Return. | [03](acceptance/03-painting.png) | `BrushTests`, `GradientTests`, `ShapeToolTests`, `TypeToolTests`, `TextColorRunsTests`, `SpotHealingTests`, `CloneStampTests`, `SmudgeLiquifyTests` | pass | +| Move, transform, distort; rulers, guides, grid | Drags the layer with the Move tool. Transform Layer halves it, and Return applies. Rulers, a guide and the grid are shown. | [04](acceptance/04-transform-guides-grid.png) | `TransformSessionTests`, `DistortTests`, `GuideTests`, `FloatingSelectionTests` | pass | +| Adjustments as edits and as layers | Hue/Saturation turns the red disc green. An Invert layer inverts the view and leaves the pixels alone. A Curves layer opens its editor. | [05](acceptance/05-adjustments.png) | `HueSaturationTests`, `LevelsTests`, `CurvesControlsTests`, `AdjustmentLayerTests`, `AdjustmentEditorTests`, `ImageAdjustmentTests` | pass | +| Camera Raw Filter, docked | Opens in the dock beside the canvas. One stop of exposure previews, commits and brightens the ground. | [06](acceptance/06-camera-raw.png) | `CameraRawTests`, `CameraRawSessionTests`, `CameraRawPanelTests`, `CameraRawCanvasTests` | pass | +| Layer effects | A 12 px red stroke rings a shape outside its edge, and a drop shadow's sheet shows. The shape keeps its fill. | [07](acceptance/07-effects.png) | `LayerEffectsTests`, `LayerEffectsKernelTests`, `EffectsPanelTests`, `EffectsCanvasTests`, `InnerGlowTests` | pass | +| Filters | Dither leaves only black and white dots. Remove Background masks the photo around its subject: the centre stays opaque and a corner turns clear. | [08](acceptance/08-filters-dither.png), [09](acceptance/09-remove-background.png) | `DitherTests`, `FilterTests`, `FinishingFilterTests`, `SubjectRemovalTests`, `RemoveBackgroundTests` | pass | +| Crop, trim, canvas size, image size; PNG and JPEG export | Crops to 1000 × 600. Canvas Size widens it to 1400. Trim finds the photo's own 1200 again. Image Size halves it. Exports a PNG through the save panel and a JPEG through its sheet and panel, then reads both back at 600 × 300. | [10](acceptance/10-canvas-size.png), [11](acceptance/11-export-jpeg.png) | `CropTests`, `ImageTrimTests`, `ProjectResizeTests`, `ProjectExportTests`, `JPEGExportTests` | pass | +| Imports | Takes JPEG, PNG, TIFF, HEIC, SVG, a layered Photoshop file (it arrives as a folder) and a camera RAW file, which asks how to develop it. That makes eight layers with no import error. | [12](acceptance/12-raw-develop.png), [13](acceptance/13-imports.png) | `ImageImportTests`, `PSDImportTests`, `RawImporterTests`, `RawDevelopTests`, `SVGImportTests` | pass | +| Open Recent; Keyboard Shortcuts window | Saves a project, and Open Recent lists it. The shortcuts window opens over the editor. | [15](acceptance/15-shortcuts.png) | `OpenRecentTests`, `RecentProjectsCoreTests`, `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests` | pass | +| Scrub a number by its label | Dragging the Opacity title 40 points left lowers the layer's opacity. | [14](acceptance/14-scrub.png) | `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableSheetsTests` | pass | +| Keep working while a project saves | Another layer is added while a save runs. The file holds the state the save began with. | — | `ProjectSaveWhileEditingTests` | pass | +| Reload a project changed on disk | With unsaved edits open, another program renames a layer on disk. The app's alert asks, Revert is chosen, and the saved state shows with the new name. | [15](acceptance/15-shortcuts.png) | `ExternalChangeTests` | pass | +| Follows the Omarchy theme | Starts in the built-in dark. A Catppuccin Latte `colors.toml` written while the app runs retints the window. | [16](acceptance/16-omarchy-light.png) | `OmarchyThemeTests` | pass | | Builds and tests on each system | The full suite runs on Ubuntu 24.04 (Qt 6.4.2), Ubuntu 26.04, Arch, Fedora 43 and 44, and NixOS. Warnings count as errors. | — | `scripts/distro-check.sh` | pending | | Packages and install scripts | Each install script runs from GitHub in a fresh container. On Ubuntu 24.04 and 26.04, Fedora 43 and 44 and Arch, it checks its package against `SHA256SUMS`, installs it and starts 1.4.5. On NixOS, `nixos.sh` resolves `v1.4.5`, builds its flake and starts 1.4.5. Debian 13 is refused, as intended. | — | `DesktopIntegrationTests`, `.github/workflows/release.yml` | pending | @@ -38,23 +38,23 @@ The screenshots come from Qt's offscreen platform, so floating panels appear wit | Feature | What the run checks | Screenshot | Suites that hold the details | Result | | --- | --- | --- | --- | --- | -| Select › Color Range | Samples the red disc; the selection covers the disc and leaves the ground. | — | `ColorRangeTests`, `ColorRangeSheetTests`, `ColorRangeCanvasTests` | pending | -| View › Grid Settings | Sets spacing, subdivisions, colour and style; the grid redraws; Restore Defaults puts them back. | — | `GridSettingsSheetTests`, `GuideGridTests` | pending | -| Snapping for selections and shapes | A marquee and a rectangle shape drawn near a guide land on it; with Ctrl they do not. | — | `SnapDrawingCanvasTests`, `TransformSnapTests` | pending | -| New Canvas presets | Chooses 1080p and Instagram Story from the ⋮ menu; the fields and the check follow. | — | `NewCanvasSheetTests` | pending | -| Quick Look preview | A saved project holds `QuickLook/Preview.jpg`, 1024 px on the long side, and reopens unchanged. | — | `QuickLookPreviewTests` | pending | -| Ungroup Layers | Ctrl+Shift+G on a folder puts its layers in its place; Undo brings the folder back. | — | `GroupSessionTests`, `LayerContextMenuTests` | pending | -| Project tabs reorder and overflow | Drags a tab past its neighbour; with many tabs, the "more tabs" menu switches to a hidden one. | — | `ProjectTabDragTests`, `ProjectTabLayoutTests` | pending | -| Fonts on selected letters and previews | Changes the face of one word; hovering a face in the menu previews it; Ctrl+Z takes back typing. | — | `TextFontRunsTests`, `TypeFontPreviewTests`, `InlineTextDrawTests` | pending | -| Painting at the layer's resolution | Paints on a scaled-down layer; the stroke is smooth at the layer's own pixels. A hidden layer says why it cannot paint. | — | `NativeResolutionPaintTests`, `PaintRefusalTests` | pending | -| Liquify, Smudge and Blur | Liquify keeps edges sharp; Smudge leaves no copies; the Blur brush's Radius applies. | — | `SmudgeLiquifyTests`, `BlurToolTests` | pending | -| Masks as Photoshop's | The mask button reveals the selection, Alt hides it; Alt-click on a mask thumbnail shows the mask alone. | — | `MaskAloneTests`, `SelectionEditTests` | pending | -| Move tool keys and values | Held Ctrl flips Auto Select; a typed width applies as one step; a resize handle snaps. | — | `TransformInspectorTests`, `TransformSnapTests` | pending | -| Blending and a sharper canvas | Soft Light, clipping stacks and adjustment layers draw as the export does. | — | `SharpCanvasTests`, `BlendModeTests`, `ClippingStackTests` | pending | -| Camera Raw curves | Drags a region of the parametric curve and a point on a channel's curve; the image follows. | — | `CameraRawCurveMixerTests`, `CameraRawTests` | pending | -| Dither ASCII and Scanlines | ASCII draws letters at the chosen size; Scanlines (CRT) applies. | — | `DitherTests`, `ScanlinesTests` | pending | -| Export JPEG preview | Zooms the preview to 100% and back to Fit; the View menu's zoom keys work on it. | — | `JPEGPreviewTests`, `JPEGExportSheetTests` | pending | -| Quit mid-edit | Quits with a gradient pending and a dialog open; the app asks once and closes. | — | `QuitPendingEditsTests`, `WindowTeardownTests` | pending | +| Select › Color Range | Samples the red disc on the canvas; the selection covers the disc, 400 pixels across, and leaves the ground. | — | `ColorRangeTests`, `ColorRangeSheetTests`, `ColorRangeCanvasTests` | pass | +| View › Grid Settings | Types a spacing of 100 in the sheet; Restore Defaults puts the grid back. | — | `GridSettingsSheetTests`, `GuideGridTests` | pass | +| Snapping for selections and shapes | A marquee and a rectangle shape drawn near a guide land on it; with Ctrl they do not. | — | `SnapDrawingCanvasTests`, `TransformSnapTests` | pass | +| New Canvas presets | Chooses Instagram Story from the ⋮ menu; the fields and the check follow, and Create makes 1080 × 1920. | — | `NewCanvasSheetTests` | pass | +| Quick Look preview | A saved project holds `QuickLook/Preview.jpg`, 576 × 1024, and its manifest keeps its four layers. | — | `QuickLookPreviewTests` | pass | +| Ungroup Layers | Ctrl+Shift+G on a folder leaves its layers where the folder stood, in order; Ctrl+Z brings the folder back. | — | `GroupSessionTests`, `LayerContextMenuTests` | pass | +| Project tabs reorder and overflow | Drags a tab past its neighbour; with many tabs, the "more tabs" menu switches to a hidden one. | [17](acceptance/17-tabs.png) | `ProjectTabDragTests`, `ProjectTabLayoutTests` | pass | +| Fonts on selected letters and previews | In the open font menu, the pointer over a face previews it on the selected word and Escape puts it back; a click gives the word that face; Ctrl+Z takes back typing. | — | `TextFontRunsTests`, `TypeFontPreviewTests`, `InlineTextDrawTests` | pass | +| Painting at the layer's resolution | Paints on a layer scaled to 25%: its pixels stay 800 wide and the stroke is 80 of them across. A hidden layer says why it cannot paint. | — | `NativeResolutionPaintTests`, `PaintRefusalTests` | pass | +| Liquify, Smudge and Blur | Liquify moves stripes and leaves few grays; Smudge leaves one fading trail; a Radius of 20 typed in the bar softens wider than 2. | — | `SmudgeLiquifyTests`, `WarpStrokeTests`, `BlurToolTests` | pass | +| Masks as Photoshop's | The mask button reveals the selection, Alt hides it; Alt-click on the mask thumbnail shows the mask alone on the canvas, black where it hides and white past it. | [18](acceptance/18-mask-alone.png) | `MaskAloneTests`, `SelectionEditTests` | pass | +| Move tool keys and values | Held Ctrl flips Auto Select; a typed width applies as one step. | — | `TransformInspectorTests`, `ResizeSnapCanvasTests` | pass | +| Blending and a sharper canvas | Soft Light and a clipping stack in Linear Dodge show on the canvas as exported. | — | `SharpCanvasTests`, `BlendingTests`, `ClippingStackTests` | pass | +| Camera Raw curves | Lifts a region of the parametric curve and adds a point on the point curve, then OK; the ground brightens. | — | `CameraRawCurveTests`, `CameraRawCurveGraphTests` | pass | +| Dither ASCII and Scanlines | In the Dither sheet, ASCII of "@" draws that letter, holes and all, about twice as tall at a text size of 28; Scanlines draws dark gaps between rows. | — | `DitherTests`, `ScanlinesTests` | pass | +| Export JPEG preview | The View keys zoom the preview to 100%, in, and back to Fit. | — | `JPEGPreviewTests`, `JPEGExportSheetTests` | pass | +| Quit mid-edit | Quits with a gradient pending: it is applied before the one question. With Levels open, Levels closes and the window goes. | — | `QuitPendingEditsTests`, `WindowTeardownTests` | pass | ## Known differences from Compositor diff --git a/docs/acceptance/02-selections.png b/docs/acceptance/02-selections.png index 83c88fa..95a082a 100644 Binary files a/docs/acceptance/02-selections.png and b/docs/acceptance/02-selections.png differ diff --git a/docs/acceptance/04-transform-guides-grid.png b/docs/acceptance/04-transform-guides-grid.png index 4be7df9..03aa0d2 100644 Binary files a/docs/acceptance/04-transform-guides-grid.png and b/docs/acceptance/04-transform-guides-grid.png differ diff --git a/docs/acceptance/05-adjustments.png b/docs/acceptance/05-adjustments.png index 2f76a75..ce0edc9 100644 Binary files a/docs/acceptance/05-adjustments.png and b/docs/acceptance/05-adjustments.png differ diff --git a/docs/acceptance/06-camera-raw.png b/docs/acceptance/06-camera-raw.png index d256ece..193d053 100644 Binary files a/docs/acceptance/06-camera-raw.png and b/docs/acceptance/06-camera-raw.png differ diff --git a/docs/acceptance/08-filters-dither.png b/docs/acceptance/08-filters-dither.png index c4b8414..74a24b5 100644 Binary files a/docs/acceptance/08-filters-dither.png and b/docs/acceptance/08-filters-dither.png differ diff --git a/docs/acceptance/09-remove-background.png b/docs/acceptance/09-remove-background.png index fa81689..52e3c7e 100644 Binary files a/docs/acceptance/09-remove-background.png and b/docs/acceptance/09-remove-background.png differ diff --git a/docs/acceptance/11-export-jpeg.png b/docs/acceptance/11-export-jpeg.png index 19f03ae..900c91b 100644 Binary files a/docs/acceptance/11-export-jpeg.png and b/docs/acceptance/11-export-jpeg.png differ diff --git a/docs/acceptance/12-raw-develop.png b/docs/acceptance/12-raw-develop.png index 9a6f285..f3c71a9 100644 Binary files a/docs/acceptance/12-raw-develop.png and b/docs/acceptance/12-raw-develop.png differ diff --git a/docs/acceptance/13-imports.png b/docs/acceptance/13-imports.png index 1a0dadb..506951a 100644 Binary files a/docs/acceptance/13-imports.png and b/docs/acceptance/13-imports.png differ diff --git a/docs/acceptance/14-scrub.png b/docs/acceptance/14-scrub.png index 5857269..5e8d881 100644 Binary files a/docs/acceptance/14-scrub.png and b/docs/acceptance/14-scrub.png differ diff --git a/docs/acceptance/15-shortcuts.png b/docs/acceptance/15-shortcuts.png index cd8d5b2..2f119ec 100644 Binary files a/docs/acceptance/15-shortcuts.png and b/docs/acceptance/15-shortcuts.png differ diff --git a/docs/acceptance/16-omarchy-light.png b/docs/acceptance/16-omarchy-light.png index 5a8f53b..1c57575 100644 Binary files a/docs/acceptance/16-omarchy-light.png and b/docs/acceptance/16-omarchy-light.png differ diff --git a/docs/acceptance/17-tabs.png b/docs/acceptance/17-tabs.png new file mode 100644 index 0000000..b0f1d03 Binary files /dev/null and b/docs/acceptance/17-tabs.png differ diff --git a/docs/acceptance/18-mask-alone.png b/docs/acceptance/18-mask-alone.png new file mode 100644 index 0000000..2916dfd Binary files /dev/null and b/docs/acceptance/18-mask-alone.png differ diff --git a/tests/AcceptanceColorTests.cpp b/tests/AcceptanceColorTests.cpp index 388a211..eb3cdd7 100644 --- a/tests/AcceptanceColorTests.cpp +++ b/tests/AcceptanceColorTests.cpp @@ -65,7 +65,12 @@ void AcceptanceColorTests::cameraRawDocked() FilterSettings settings = app.session().filterSettings(); settings.cameraRaw.exposure = 1; app.session().updateFilter(settings, true); - QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit().value().previewImage(layer), 30000); + // The shot waits for the brightened preview. + const auto brightened = [&app, layer] { + const std::optional preview = app.session().filterEdit().value().previewImage(layer); + return preview && preview.value().pixelColor(preview.value().width() / 2, preview.value().height() * 7 / 8).green() > 170; + }; + QTRY_VERIFY_WITH_TIMEOUT(brightened(), 30000); app.shot(QStringLiteral("06-camera-raw")); bool done = false; app.session().commitFilter([&done] { done = true; }); diff --git a/tests/AcceptanceEditTests.cpp b/tests/AcceptanceEditTests.cpp new file mode 100644 index 0000000..0fbba75 --- /dev/null +++ b/tests/AcceptanceEditTests.cpp @@ -0,0 +1,239 @@ +#include "AcceptanceFixtures.h" +#include "Document/LayerAppearance.h" +#include "Document/SmudgeLiquify.h" +#include "UI/NativeLayerList.h" +#include "UI/TransformInspector.h" +#include "UI/TypeControls.h" +#include +#include + +// The acceptance run, part five: 1.4.5's editing. +class AcceptanceEditTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QStandardPaths::setTestModeEnabled(true); } + void textPaintingSmearAndMasks(); + void moveKeysAndBlending(); +}; + +namespace { +QImage filled(int width, int height, QColor color) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(color); + return image; +} + +QUuid add(App &app, const QImage &image, const QString &name, QPointF centre) +{ + app.session().insert(ImportedImage(image, image, name), centre); + return app.session().activeLayerID().value(); +} + +int red(App &app, QUuid id, int x, int y) +{ + for (const ImageLayer &layer : app.session().document().value().layers) + if (layer.id == id) + return qRed(layer.asset.value().image().pixel(x, y)); + throw std::runtime_error("no such layer"); +} + +// The canvas's pixel at a document point, at 100%. +QColor onCanvas(App &app, QPointF point) +{ + const QPointF view = app.session().viewport.viewPoint(point + QPointF(0.5, 0.5), app.session().document().value().size()); + return app.canvas().grab().toImage().pixelColor(int(std::floor(view.x())), int(std::floor(view.y()))); +} + +bool near(QColor a, QColor b) +{ + return qAbs(a.red() - b.red()) <= 2 && qAbs(a.green() - b.green()) <= 2 && qAbs(a.blue() - b.blue()) <= 2; +} +} + +void AcceptanceEditTests::textPaintingSmearAndMasks() +{ + App app; + app.session().createDocument(1200, 800, true); + // A face for the selected word, a preview, and Undo. + app.session().selectTool(NavigationTool::type); + app.press(QPointF(100, 200)); + app.release(QPointF(100, 200)); + QTRY_VERIFY(app.session().textDraft()); + QTest::keyClicks(&app.canvas(), QStringLiteral("Hello World")); + for (int letter = 0; letter < 5; ++letter) + QTest::keyClick(&app.canvas(), Qt::Key_Left, Qt::ShiftModifier); + auto *faces = app.window.findChild(QStringLiteral("typeFont")); + QVERIFY(faces); + const QString mono = QStringLiteral("DejaVuSansMono"); + // Pointing at a face previews it; Escape puts it back. + QTest::mouseMove(faces->view()->viewport(), popupRow(*faces, mono)); + QTRY_COMPARE(app.session().textDraft().value().style.fontNameAt(6), mono); + QTest::keyClick(faces->view(), Qt::Key_Escape); + QTRY_VERIFY(app.session().textDraft().value().style.fontNameAt(6) != mono); + choose(*faces, mono); + QTRY_COMPARE(app.session().textDraft().value().style.fontRuns.value(), (std::vector{{6, 5, mono}})); + app.window.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&app.window)); + app.canvas().setFocus(); + QTRY_VERIFY(app.canvas().hasFocus()); + QTest::keyClick(&app.canvas(), Qt::Key_End); + QTest::keyClicks(&app.canvas(), QStringLiteral("!!")); + QTest::keyClick(&app.canvas(), Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(app.session().textDraft().value().style.content, QStringLiteral("Hello World")); + QTest::keyClick(&app.canvas(), Qt::Key_Return, Qt::ControlModifier); + QVERIFY(!app.session().textDraft()); + // A brush paints in a scaled-down layer's own pixels. + const QUuid half = add(app, filled(800, 800, Qt::black), QStringLiteral("Half"), QPointF(600, 400)); + app.session().beginTransform(); + LayerTransform quarter = app.session().activeLayer().value().transform; + quarter.origin = QPointF(500, 300); + quarter.size = QSizeF(200, 200); + app.session().previewTransform(quarter); + app.session().commitTransform(); + app.session().selectTool(NavigationTool::brush); + app.session().setBrushSettings({.diameter = 20, .hardness = 1, .red = 1, .green = 1, .blue = 1}); + app.drag(QPointF(600, 330), QPointF(600, 470)); + app.settle(); + QCOMPARE(app.session().activeLayer().value().asset.value().size(), QSize(800, 800)); + int painted = 0; + for (int x = 0; x < 800; ++x) + painted += red(app, half, x, 400) > 128; + QVERIFY2(painted >= 76 && painted <= 84, qPrintable(QString::number(painted))); + // A hidden layer says why it cannot paint. + app.session().toggleLayerVisibility(half); + app.press(QPointF(560, 400)); + app.release(QPointF(560, 400)); + QVERIFY(app.session().brushError().value().contains(QStringLiteral("is hidden"))); + app.session().setBrushError(std::nullopt); + app.session().toggleLayerVisibility(half); + // The Blur brush's Radius sets how far it softens. + QImage step = filled(400, 200, Qt::black); + QPainter(&step).fillRect(200, 0, 200, 200, Qt::white); + const QUuid edge = add(app, step, QStringLiteral("Edge"), QPointF(200, 600)); + const auto softened = [&](const QString &radius) { + app.session().selectTool(NavigationTool::blur); + app.session().setBlurMode(BlurToolMode::blur); + app.session().setBrushSettings({.diameter = 120, .hardness = 1, .opacity = 1}); + // The bar's own Radius field. + auto *field = app.window.findChild(QStringLiteral("blurRadius")); + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, radius); + QTest::keyClick(field, Qt::Key_Return); + app.drag(QPointF(200, 520), QPointF(200, 680)); + app.settle(); + int soft = 0; + for (int x = 100; x < 300; ++x) + soft += red(app, edge, x, 100) > 10 && red(app, edge, x, 100) < 245; + app.action("undo").trigger(); + return soft; + }; + const int narrow = softened(QStringLiteral("2")), wide = softened(QStringLiteral("20")); + QVERIFY2(narrow < 16 && wide > 40, qPrintable(QString("%1 %2").arg(narrow).arg(wide))); + // Liquify keeps the stripes sharp as it pushes them. + QImage stripes = filled(400, 200, Qt::black); + for (int x = 0; x < 400; x += 20) + QPainter(&stripes).fillRect(x, 0, 10, 200, Qt::white); + const QUuid striped = add(app, stripes, QStringLiteral("Stripes"), QPointF(800, 600)); + app.session().selectTool(NavigationTool::blur); + app.session().setBlurMode(BlurToolMode::liquify); + app.session().setBrushSettings({.diameter = 80, .hardness = 0.5, .opacity = 0.7}); + app.drag(QPointF(720, 600), QPointF(840, 600)); + app.settle(); + int moved = 0, grey = 0; + for (int x = 0; x < 400; ++x) { + const int value = red(app, striped, x, 100); + moved += value != qRed(stripes.pixel(x, 100)); + grey += value > 40 && value < 215; + } + QVERIFY2(moved > 20 && grey * 5 < moved, qPrintable(QString("%1 %2").arg(moved).arg(grey))); + // Smudge leaves one fading trail, no copies along it. + QImage block = filled(400, 200, Qt::white); + QPainter(&block).fillRect(0, 0, 100, 200, Qt::red); + const QUuid smeared = add(app, block, QStringLiteral("Block"), QPointF(200, 200)); + app.session().setBlurMode(BlurToolMode::smudge); + app.session().setBrushSettings({.diameter = 60, .hardness = 0.5, .opacity = 0.8}); + app.drag(QPointF(50, 200), QPointF(300, 200)); + app.settle(); + const auto green = [&](int x) { + for (const ImageLayer &layer : app.session().document().value().layers) + if (layer.id == smeared) + return qGreen(layer.asset.value().image().pixel(x, 100)); + throw std::runtime_error("no such layer"); + }; + QVERIFY2(green(110) < 200, qPrintable(QString::number(green(110)))); + for (int x = 110; x < 300; x += 10) + QVERIFY2(green(x + 10) >= green(x) - 3, qPrintable(QString("%1: %2 then %3").arg(x).arg(green(x)).arg(green(x + 10)))); + app.session().selectLayer(striped); + // The mask button reveals; Alt hides; Alt-click shows alone. + app.session().selectTool(NavigationTool::marquee); + app.drag(QPointF(650, 520), QPointF(750, 680)); + auto *mask = app.window.findChild(QStringLiteral("addLayerMask")); + QTest::mouseClick(mask, Qt::LeftButton); + QImage shown = app.session().activeLayer().value().mask.value().asset.image(); + QCOMPARE(qGray(shown.pixel(100, 100)), 255); + QCOMPARE(qGray(shown.pixel(5, 100)), 0); + app.action("undo").trigger(); + QTest::mouseClick(mask, Qt::LeftButton, Qt::AltModifier); + shown = app.session().activeLayer().value().mask.value().asset.image(); + QVERIFY(qGray(shown.pixel(100, 100)) == 0 && qGray(shown.pixel(5, 100)) == 255); + LayerThumbnailButton *thumbnail = nullptr; + for (LayerCell *cell : app.window.findChild()->cells()) + if (cell->layerID() == striped) + thumbnail = cell->findChild("maskThumbnail"); + QVERIFY(thumbnail); + QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier, thumbnail->rect().center()); + QCOMPARE(app.session().maskAloneLayer().value().id, striped); + QTRY_VERIFY(app.window.findChild(QStringLiteral("maskAloneBadge"))->isVisible()); + // The canvas shows the mask: black where hidden, white past. + QTRY_COMPARE(onCanvas(app, QPointF(700, 600)), QColor(Qt::black)); + QCOMPARE(onCanvas(app, QPointF(620, 600)), QColor(Qt::white)); + QCOMPARE(onCanvas(app, QPointF(50, 150)), QColor(Qt::white)); + app.shot(QStringLiteral("18-mask-alone")); +} + +void AcceptanceEditTests::moveKeysAndBlending() +{ + App app; + QVERIFY(scene(1200, 800).save(app.path("photo.png"))); + app.session().createDocument(1200, 800); + app.importFile(app.path("photo.png")); + // Held Ctrl flips Auto Select in the bar. + app.session().selectTool(NavigationTool::move); + auto *autoSelect = app.window.findChild(QStringLiteral("transformAutoSelect")); + QVERIFY(autoSelect); + const bool before = autoSelect->isChecked(); + QTest::keyPress(&app.canvas(), Qt::Key_Control, Qt::ControlModifier); + QCOMPARE(autoSelect->isChecked(), !before); + QTest::keyRelease(&app.canvas(), Qt::Key_Control, Qt::NoModifier); + QCOMPARE(autoSelect->isChecked(), before); + // A typed width applies as one step on leaving. + auto *width = app.window.findChild(QStringLiteral("transformW")); + QVERIFY(width); + const int steps = app.session().history.undoCount(); + width->setFocus(); + QTRY_VERIFY(width->hasFocus()); + width->selectAll(); + QTest::keyClicks(width, QStringLiteral("600")); + app.canvas().setFocus(); + QTRY_COMPARE(app.session().activeLayer().value().transform.size.width(), 600.0); + QCOMPARE(app.session().history.undoCount(), steps + 1); + app.action("undo").trigger(); + // Soft Light and a Linear Dodge stack show as exported. + app.session().zoom(1); + const QUuid grey = add(app, filled(1200, 800, QColor(77, 77, 77)), QStringLiteral("Grey"), QPointF(600, 400)); + app.session().setLayerBlendMode(LayerBlendMode::softLight); + const QList points{QPointF(600, 400), QPointF(300, 100), QPointF(900, 700)}; + for (const QPointF point : points) + QTRY_VERIFY2(near(onCanvas(app, point), app.shown(int(point.x()), int(point.y()))), qPrintable(onCanvas(app, point).name())); + add(app, filled(1200, 800, QColor(0, 0, 160, 160)), QStringLiteral("Clipped"), QPointF(600, 400)); + app.session().toggleClippingMask(app.session().activeLayerID().value()); + app.session().selectLayer(grey); + app.session().setLayerBlendMode(LayerBlendMode::linearDodge); + for (const QPointF point : points) + QTRY_VERIFY2(near(onCanvas(app, point), app.shown(int(point.x()), int(point.y()))), qPrintable(onCanvas(app, point).name())); +} + +QTEST_MAIN(AcceptanceEditTests) +#include "AcceptanceEditTests.moc" diff --git a/tests/AcceptanceFinishTests.cpp b/tests/AcceptanceFinishTests.cpp new file mode 100644 index 0000000..d2d884a --- /dev/null +++ b/tests/AcceptanceFinishTests.cpp @@ -0,0 +1,203 @@ +#include "AcceptanceFixtures.h" +#include "DialogDesk.h" +#include "UI/JPEGExportSheet.h" +#include +#include +#include +#include +#include + +// The acceptance run, part six: 1.4.5's filters, export, Quit. +class AcceptanceFinishTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QStandardPaths::setTestModeEnabled(true); } + void cameraRawCurvesAndDither(); + void jpegPreviewAndQuitMidEdit(); +}; + +namespace { +QUuid photo(App &app) +{ + if (!scene(1200, 800).save(app.path("photo.png"))) + throw std::runtime_error("the photo could not be written"); + app.session().createDocument(1200, 800); + app.importFile(app.path("photo.png")); + return app.session().activeLayerID().value(); +} + +void drag(QWidget *widget, QPoint from, QPoint to) +{ + QTest::mousePress(widget, Qt::LeftButton, Qt::NoModifier, from); + for (int step = 1; step <= 4; ++step) { + const QPointF at = from + (to - from) * (step / 4.0); + QMouseEvent event(QEvent::MouseMove, at, widget->mapToGlobal(at), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(widget, &event); + } + QTest::mouseRelease(widget, Qt::LeftButton, Qt::NoModifier, to); +} + +// A shown widget of that name, once its panel shows. +template Widget &shownChild(App &app, const char *name) +{ + Widget *shown = nullptr; + static_cast(QTest::qWaitFor([&] { + for (Widget *each : app.window.findChildren(QString::fromLatin1(name))) + if (each->isVisible()) + shown = each; + return shown != nullptr; + }, 5000)); + if (!shown) + throw std::runtime_error(std::string("no shown ") + name); + return *shown; +} + +// The filter panel's OK, then the commit's landing. +bool committed(App &app) +{ + shownChild(app, "filterOK").click(); + return QTest::qWaitFor([&app] { return !app.session().filterEdit() && !app.session().isProjectBusy(); }, 30000); +} + +void typeInto(App &app, const char *name, const QString &text, bool submit) +{ + auto &field = shownChild(app, name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + if (submit) + QTest::keyClick(&field, Qt::Key_Return); +} + +// The first glyph in the ground: its box, inked share. +std::pair glyph(const QImage &image) +{ + const QRect ground(400, 640, 400, 120); + std::map counts; + for (int y = ground.top(); y < ground.bottom(); ++y) + for (int x = ground.left(); x < ground.right(); ++x) + ++counts[image.pixel(x, y)]; + const int paper = qGray(std::max_element(counts.begin(), counts.end(), [](auto a, auto b) { return a.second < b.second; })->first); + // Ink lies past halfway from the paper's gray. + const auto inked = [&image, paper](QPoint at) { return std::abs(qGray(image.pixel(at)) - paper) > 127; }; + std::vector seen(size_t(image.width()) * size_t(image.height())); + const auto index = [&image](QPoint at) { return size_t(at.y()) * size_t(image.width()) + size_t(at.x()); }; + for (int y = ground.top() + 20; y < ground.bottom() - 40; ++y) + for (int x = ground.left(); x < ground.right(); ++x) { + if (!inked(QPoint(x, y))) + continue; + QRect box(x, y, 1, 1); + int count = 0; + std::vector stack{QPoint(x, y)}; + while (!stack.empty()) { + const QPoint at = stack.back(); + stack.pop_back(); + if (!image.rect().contains(at) || !inked(at) || seen[index(at)]) + continue; + seen[index(at)] = true; + ++count; + box |= QRect(at, QSize(1, 1)); + for (const QPoint step : {QPoint(1, 0), QPoint(-1, 0), QPoint(0, 1), QPoint(0, -1), QPoint(1, 1), QPoint(-1, -1), QPoint(1, -1), QPoint(-1, 1)}) + stack.push_back(at + step); + } + return {box, double(count) / (box.width() * box.height())}; + } + throw std::runtime_error("no glyph in the ground"); +} +} + +void AcceptanceFinishTests::cameraRawCurvesAndDither() +{ + App app; + const QUuid layer = photo(app); + const int before = pixel(app.session(), layer, 600, 700).green(); + // Camera Raw: a curve region lifted, a point dragged. + app.action("cameraRawFilter").trigger(); + QTRY_VERIFY(app.window.findChild(QStringLiteral("CurveSection"))); + app.window.findChild(QStringLiteral("CurveSection"))->click(); + QWidget *graph = app.window.findChild(QStringLiteral("cameraRawCurveGraph")); + QTRY_VERIFY(graph && graph->isVisible()); + const int w = graph->width(), h = graph->height(); + drag(graph, QPoint(int(w * 0.6), h / 2), QPoint(int(w * 0.6), h / 2 - h / 4)); + QVERIFY2(app.session().filterEdit().value().settings.cameraRaw.curve.lights > 20, + qPrintable(QString::number(app.session().filterEdit().value().settings.cameraRaw.curve.lights))); + app.window.findChild(QStringLiteral("curvePage1"))->click(); + const size_t points = app.session().filterEdit().value().settings.cameraRaw.curve.rgb.size(); + drag(graph, QPoint(w / 4, h * 3 / 4), QPoint(w / 4, h / 2)); + QCOMPARE(app.session().filterEdit().value().settings.cameraRaw.curve.rgb.size(), points + 1); + QVERIFY(committed(app)); + QVERIFY2(pixel(app.session(), layer, 600, 700).green() > before + 10, qPrintable(pixel(app.session(), layer, 600, 700).name())); + app.action("undo").trigger(); + // ASCII of "@" draws that letter, holes and all. + app.action("dither").trigger(); + choose(shownChild(app, "ditherStyle"), QStringLiteral("ASCII")); + typeInto(app, "ditherCharacters", QStringLiteral("@"), false); + QVERIFY(committed(app)); + const QImage small = app.session().activeLayer().value().asset.value().image(); + const auto [letter, inked] = glyph(small); + QVERIFY2(inked < 0.75 && letter.height() >= 6, qPrintable(QString("%1 %2").arg(inked).arg(letter.height()))); + app.action("undo").trigger(); + app.action("dither").trigger(); + QCOMPARE(shownChild(app, "ditherStyle").currentText(), QStringLiteral("ASCII")); + typeInto(app, "textSizeField", QStringLiteral("28"), true); + QTRY_VERIFY(!app.session().filterEdit() && !app.session().isProjectBusy()); + const auto [large, inkedLarge] = glyph(app.session().activeLayer().value().asset.value().image()); + const double grown = double(large.height()) / letter.height(); + QVERIFY2(grown > 1.6 && grown < 2.4 && inkedLarge < 0.75, qPrintable(QString::number(grown))); + app.action("undo").trigger(); + // Scanlines: dark gaps between the beam's rows across the sky. + app.action("dither").trigger(); + choose(shownChild(app, "ditherStyle"), QStringLiteral("Scanlines (CRT)")); + QVERIFY(committed(app)); + const QImage lines = app.session().activeLayer().value().asset.value().image(); + int darkest = 255, brightest = 0; + for (int y = 100; y < 110; ++y) { + darkest = std::min(darkest, qGray(lines.pixel(600, y))); + brightest = std::max(brightest, qGray(lines.pixel(600, y))); + } + QVERIFY2(brightest - darkest > 40, qPrintable(QString("%1 %2").arg(darkest).arg(brightest))); +} + +void AcceptanceFinishTests::jpegPreviewAndQuitMidEdit() +{ + App app; + photo(app); + // Export JPEG: View keys zoom to 100% and back. + bool done = false; + app.workspace.current().controller.exportJPEG([&done] { done = true; }); + QTRY_VERIFY(app.window.findChild()); + auto *preview = app.window.findChild(); + QVERIFY(preview && !preview->zoom()); + JPEGExportSheet *sheet = app.window.findChild(); + QTest::keyClick(sheet, Qt::Key_1, Qt::ControlModifier); + QCOMPARE(preview->zoom(), std::optional(1)); + QTest::keyClick(sheet, Qt::Key_Equal, Qt::ControlModifier); + QVERIFY(preview->zoom().value() > 1); + QTest::keyClick(sheet, Qt::Key_0, Qt::ControlModifier); + QVERIFY(!preview->zoom()); + QTest::keyClick(sheet, Qt::Key_Escape); + QTRY_VERIFY(done); + // Quit with a gradient pending: applied before the one question. + app.session().selectTool(NavigationTool::gradient); + app.drag(QPointF(0, 400), QPointF(1200, 400)); + QVERIFY(app.session().gradientEdit()); + DialogDesk desk; + desk.replies = {"Cancel"}; + desk.note = [&] { return QStringLiteral("pending=%1 levels=%2").arg(app.session().gradientEdit().has_value()).arg(app.session().levels().has_value()); }; + app.window.close(); + QTRY_COMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].endsWith("|pending=0 levels=0"), qPrintable(desk.seen[0])); + QTRY_VERIFY(!app.workspace.isManaging()); + QVERIFY(app.window.isVisible()); + // With Levels open: cancelled, one question, then the window goes. + app.action("levels").trigger(); + QVERIFY(app.session().levels()); + desk.replies = {"Don’t Save"}; + app.window.close(); + QTRY_COMPARE(desk.seen.size(), 2); + QVERIFY2(desk.seen[1].endsWith("|pending=0 levels=0"), qPrintable(desk.seen[1])); + QTRY_VERIFY(!app.window.isVisible()); +} + +QTEST_MAIN(AcceptanceFinishTests) +#include "AcceptanceFinishTests.moc" diff --git a/tests/AcceptanceFixtures.h b/tests/AcceptanceFixtures.h index 1e59351..7b7e844 100644 --- a/tests/AcceptanceFixtures.h +++ b/tests/AcceptanceFixtures.h @@ -5,6 +5,8 @@ #include "UI/CompositorMenus.h" #include "UI/OmarchyTheme.h" #include "UI/ProjectWorkspaceView.h" +#include +#include #include #include #include @@ -88,6 +90,25 @@ struct App { } }; +// A combo box popup row, where the pointer finds it. +inline QPoint popupRow(QComboBox &box, const QString &text) +{ + box.showPopup(); + if (!QTest::qWaitFor([&box] { return box.view()->isVisible(); }, 2000)) + throw std::runtime_error("the menu never opened"); + const int row = box.findText(text); + if (row < 0) + throw std::runtime_error("no such entry"); + return box.view()->visualRect(box.model()->index(row, 0)).center(); +} + +// Chooses an entry with a click in its popup. +inline void choose(QComboBox &box, const QString &text) +{ + const QPoint row = popupRow(box, text); + QTest::mouseClick(box.view()->viewport(), Qt::LeftButton, Qt::NoModifier, row); +} + // A photograph's stand-in: sky, ground and a red disc. inline QImage scene(int width, int height) { diff --git a/tests/AcceptanceNewTests.cpp b/tests/AcceptanceNewTests.cpp new file mode 100644 index 0000000..86da539 --- /dev/null +++ b/tests/AcceptanceNewTests.cpp @@ -0,0 +1,195 @@ +#include "AcceptanceFixtures.h" +#include "DialogDesk.h" +#include "Document/Guides.h" +#include "Document/ShapeTool.h" +#include "IO/ProjectStore.h" +#include "UI/ColorPickerSheet.h" +#include "UI/ColorRangeSheet.h" +#include "UI/GridSettingsSheet.h" +#include "UI/ProjectTabs.h" +#include + +// The acceptance run, part four: 1.4.5's selections, grid, canvases, tabs. +class AcceptanceNewTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QStandardPaths::setTestModeEnabled(true); } + void colorRangeGridAndSnapping(); + void presetsQuickLookUngroupAndTabs(); +}; + +namespace { +// A drag with keys held at every step. +void drag(App &app, QPointF from, QPointF to, Qt::KeyboardModifiers keys) +{ + app.press(from, keys); + for (int step = 1; step <= 8; ++step) { + const QPointF view = app.at(from + (to - from) * (step / 8.0)); + QMouseEvent event(QEvent::MouseMove, view, view, app.canvas().mapToGlobal(view.toPoint()), Qt::NoButton, Qt::LeftButton, keys); + QApplication::sendEvent(&app.canvas(), &event); + } + QTest::mouseRelease(&app.canvas(), Qt::LeftButton, keys, app.at(to)); +} + +void typeInSheet(App &app, const char *name, const QString &text) +{ + auto *field = app.window.findChild()->findChild(QString::fromLatin1(name)); + QVERIFY(field); + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, text); + QTest::keyClick(field, Qt::Key_Return); +} + +ProjectTabButton &tabButton(App &app, const QString &title) +{ + for (ProjectTabButton *button : app.window.tabs()->buttons()) + if (button->tab->title() == title) + return *button; + throw std::runtime_error("no such tab"); +} +} + +void AcceptanceNewTests::colorRangeGridAndSnapping() +{ + App app; + QVERIFY(scene(1200, 800).save(app.path("photo.png"))); + app.session().createDocument(1200, 800); + app.importFile(app.path("photo.png")); + // Color Range samples the red disc on the canvas. + app.action("colorRange").trigger(); + QVERIFY(app.session().colorRange()); + app.press(QPointF(600, 400)); + app.release(QPointF(600, 400)); + QTRY_VERIFY(app.session().selection().has_value()); + // The panel's OK applies it. + QPushButton *ok = nullptr; + for (QPushButton *button : app.window.findChild()->findChildren()) + if (button->text() == QStringLiteral("OK")) + ok = button; + QVERIFY(ok); + ok->click(); + QVERIFY(!app.session().colorRange()); + const QRectF disc = app.session().selection().value().path.boundingRect(); + QVERIFY2(qAbs(disc.center().x() - 600) < 4 && qAbs(disc.width() - 400) < 12 && qAbs(disc.height() - 400) < 12, + qPrintable(QString("%1 %2").arg(disc.width()).arg(disc.height()))); + app.action("deselect").trigger(); + // Grid Settings: typed spacing applies; Restore Defaults undoes it. + ProjectController &controller = app.workspace.current().controller; + bool done = false; + controller.gridSettings([&done] { done = true; }); + QTRY_VERIFY(app.window.findChild()); + typeInSheet(app, "gridSpacing", QStringLiteral("100")); + QTRY_VERIFY(done); + QVERIFY(app.session().layoutGrid() == LayoutGrid(100, 8)); + done = false; + controller.gridSettings([&done] { done = true; }); + QTRY_VERIFY(app.window.findChild()); + app.window.findChild()->findChild(QStringLiteral("gridDefaults"))->click(); + app.window.findChild()->findChild(QStringLiteral("gridOK"))->click(); + QTRY_VERIFY(done); + QVERIFY(app.session().layoutGrid() == LayoutGrid()); + // Marquee and rectangle land on a guide, unless Ctrl. + app.session().setShowsGuides(true); + app.session().addGuide({.id = QUuid::createUuid(), .axis = CanvasGuide::Axis::vertical, .position = 300}); + app.session().selectTool(NavigationTool::marquee); + drag(app, QPointF(296, 100), QPointF(500, 300), Qt::NoModifier); + QCOMPARE(app.session().selection().value().path.boundingRect().left(), 300.0); + app.action("deselect").trigger(); + drag(app, QPointF(296, 100), QPointF(500, 300), Qt::ControlModifier); + QVERIFY(qAbs(app.session().selection().value().path.boundingRect().left() - 296) <= 2); + app.action("deselect").trigger(); + app.session().selectTool(NavigationTool::shape); + app.session().setShapeKind(ShapeKind::rectangle); + drag(app, QPointF(296, 500), QPointF(450, 650), Qt::NoModifier); + QCOMPARE(app.session().activeLayer().value().transform.origin.x(), 300.0); + drag(app, QPointF(296, 100), QPointF(450, 250), Qt::ControlModifier); + QVERIFY(qAbs(app.session().activeLayer().value().transform.origin.x() - 296) <= 2); +} + +void AcceptanceNewTests::presetsQuickLookUngroupAndTabs() +{ + App app; + // A preset fills the fields and checks the match. + auto *more = app.window.findChild(QStringLiteral("presetSizes")); + QVERIFY(more && more->menu()); + QAction *story = nullptr; + for (QAction *entry : more->menu()->actions()) + if (entry->text() == QStringLiteral("Instagram Story")) + story = entry; + QVERIFY(story); + story->trigger(); + QVERIFY(story->isChecked()); + QCOMPARE(app.window.findChild(QStringLiteral("heightInput"))->text(), QStringLiteral("1920")); + app.window.findChild(QStringLiteral("createCanvas"))->click(); + QCOMPARE(QSize(int(app.session().document().value().width), int(app.session().document().value().height)), QSize(1080, 1920)); + // Ungroup: the layers take the folder's place; undo returns. + app.action("newBlankLayer").trigger(); + const QUuid first = app.session().activeLayerID().value(); + app.action("newBlankLayer").trigger(); + const QUuid second = app.session().activeLayerID().value(); + app.session().selectLayers({first, second}, second); + app.session().groupSelectedLayers(); + const QUuid folder = app.session().activeLayerID().value(); + const auto order = [&app] { + QList ids; + for (const ImageLayer &layer : app.session().document().value().layers) + ids << layer.id; + return ids; + }; + const QList grouped = order(); + app.canvas().setFocus(); + QTest::keyClick(&app.canvas(), Qt::Key_G, Qt::ControlModifier | Qt::ShiftModifier); + // The two layers stand where the folder stood, in order. + QList expected = grouped; + expected.removeAll(folder); + QCOMPARE(order(), expected); + QCOMPARE(int(order().indexOf(first)) + 1, int(order().indexOf(second))); + for (const ImageLayer &layer : app.session().document().value().layers) + QVERIFY(!layer.isGroup && !layer.parentID); + QTest::keyClick(&app.canvas(), Qt::Key_Z, Qt::ControlModifier); + QCOMPARE(order(), grouped); + // A save writes the Quick Look preview, 1024 long. + const QString project = app.path("Story.comp"); + DialogDesk desk; + desk.replies = {project}; + app.action("saveAs").trigger(); + QTRY_VERIFY_WITH_TIMEOUT(QFileInfo(project).isDir() && !app.session().isModified(), 30000); + const QImage preview(QDir(project).filePath(QStringLiteral("QuickLook/Preview.jpg"))); + QCOMPARE(preview.size(), QSize(576, 1024)); + QCOMPARE(int(ProjectStore::load(project).manifest.layers.size()), 4); + // A tab dragged past its neighbour; the rest gather. + app.workspace.newCanvas(); + app.workspace.newCanvas(); + QStringList before; + for (const std::shared_ptr &tab : app.workspace.tabs()) + before << tab->title(); + const int from = int(before.indexOf(QStringLiteral("Story"))); + QVERIFY(from >= 0 && from + 1 < before.size()); + QWindow *window = app.window.windowHandle(); + ProjectTabButton &dragged = tabButton(app, QStringLiteral("Story")); + const QPoint start = dragged.mapTo(&app.window, QPoint(20, 14)); + QTest::mousePress(window, Qt::LeftButton, {}, start); + QTest::mouseMove(window, start + QPoint(10, 0)); + QTest::mouseMove(window, start + QPoint(dragged.width() + 40, 0)); + QTest::mouseRelease(window, Qt::LeftButton, {}, start + QPoint(dragged.width() + 40, 0)); + QCOMPARE(app.workspace.tabs()[from]->title(), before[from + 1]); + QCOMPARE(app.workspace.tabs()[from + 1]->title(), QStringLiteral("Story")); + for (int tab = 0; tab < 12; ++tab) + app.workspace.newCanvas(); + OverflowTabsPill &pill = *app.window.tabs()->pill(); + QTRY_VERIFY(pill.isVisible()); + QTest::mousePress(&pill, Qt::LeftButton); + auto *menu = pill.findChild(QStringLiteral("projectTabsOverflowMenu")); + QVERIFY(menu && menu->isVisible()); + app.shot(QStringLiteral("17-tabs")); + QAction *hidden = menu->actions().front(); + const QString chosen = hidden->text(); + hidden->trigger(); + menu->close(); + QTest::mouseRelease(&pill, Qt::LeftButton); + QCOMPARE(app.workspace.current().title(), chosen); +} + +QTEST_MAIN(AcceptanceNewTests) +#include "AcceptanceNewTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 1a4c4ec..a7dc99f 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -203,6 +203,9 @@ compositor_test(DitherKernelTests) compositor_test(AcceptanceTests) compositor_test(AcceptanceColorTests) compositor_test(AcceptanceProjectTests) +compositor_test(AcceptanceNewTests) +compositor_test(AcceptanceEditTests) +compositor_test(AcceptanceFinishTests) compositor_test(WindowTeardownTests) compositor_test(RecentProjectsCoreTests) compositor_test(ProjectWorkspaceSignalTests)