Modules/Sketch2D/tests/integration/Sketch2DAppTests.cpp
Шурупов Илья Викторович fca8fe0902 Render strokes at the shown level and paint full resolution in the background
Dabs now go only into preview tiles of the shown level at every zoom,
including a level 0 preview overlay that the compositor shows in
place of the layer tile. The layer tiles are painted by the engine,
and releasing a stroke commits history without waiting for them.
Stroke opacity coverage is shared per tile across a stroke's flushes,
each engine job only replays the dabs that touch its tile, and undo
drops an open preview instead of painting it. Readers that need final
pixels settle explicitly; the renderer no longer does.

tp::Map::remove rehashed the whole table on every call, which made
applying thousands of finished tiles quadratic; it now rehashes only
when the table is mostly empty or full of deleted slots.
2026-10-20 22:13:30 +03:00

420 lines
15 KiB
C++

#include "AppDriver.hpp"
#include "Sketch2DApplication.hpp"
#include "StudioUI.hpp"
#include "UnitTest++/UnitTest++.h"
#include <cstdlib>
#include <filesystem>
using namespace tp;
using namespace tp::sketch2d;
using namespace tp::ui;
namespace {
struct AppScene {
Sketch2DApplication application;
AppDriver driver{ application };
Sketch2DRoot& root() { return application.root(); }
StudioRoot& studio() { return *application.studioRoot(); }
Document& document() { return application.document(); }
Vec2F canvasPoint(halnf x, halnf y) {
const RectF canvas = root().canvas.windowRect();
return canvas.pos + Vec2F(x, y);
}
};
}
SUITE(Sketch2DApplicationIntegration) {
TEST(StrokePaintsTilesAndUploadsThem) {
AppScene scene;
scene.driver.drag(scene.canvasPoint(200, 200), scene.canvasPoint(420, 260));
CHECK(scene.document().tileCount() > 0);
CHECK_EQUAL("Paint", scene.document().history().undoLabel());
scene.driver.frame();
CHECK(scene.application.renderer().drawnTilesLastFrame() > 0);
}
TEST(UndoButtonRemovesStroke) {
AppScene scene;
scene.driver.drag(scene.canvasPoint(200, 200), scene.canvasPoint(300, 220));
CHECK(scene.document().tileCount() > 0);
scene.driver.click(scene.studio().undo);
CHECK_EQUAL(0u, scene.document().tileCount());
}
TEST(SwitchingLayersWithThumbnailsDoesNotCrash) {
AppScene scene;
scene.driver.drag(scene.canvasPoint(200, 200), scene.canvasPoint(300, 220));
scene.driver.click(scene.studio().layers);
LayersPopup& popup = scene.root().toolBar.layersPopup;
scene.driver.click(popup.add);
scene.driver.drag(scene.canvasPoint(150, 300), scene.canvasPoint(260, 320));
scene.driver.click(scene.studio().layers);
for (int round = 0; round < 3; round++) {
for (size_t row = 0; row < popup.rows().size(); row++)
scene.driver.click(popup.rows()[row]->name);
}
CHECK_EQUAL(2u, popup.rows().size());
}
TEST(DraggingLayerGripReorders) {
AppScene scene;
Layer* bottom = scene.document().activeLayer();
scene.driver.click(scene.studio().layers);
LayersPopup& popup = scene.root().toolBar.layersPopup;
scene.driver.click(popup.add);
scene.driver.frames(20);
ReorderableList& list = popup.list();
REQUIRE CHECK_EQUAL(2u, list.itemCount());
const Vec2F grip = list.windowPosition() + list.gripRect(1).center();
const Vec2F above = list.windowPosition() + list.gripRect(0).center() - Vec2F(0.0f, 20.0f);
scene.driver.drag(grip, above);
CHECK(scene.document().layers()[1].get() == bottom);
}
TEST(ZoomedOutRenderingUsesMips) {
AppScene scene;
scene.driver.drag(scene.canvasPoint(200, 200), scene.canvasPoint(420, 260));
for (int notch = 0; notch < 20; notch++)
scene.driver.scroll(scene.canvasPoint(300, 300), -1);
scene.driver.frame();
CHECK(scene.document().zoom() < 0.5f);
CHECK(scene.application.renderer().mipLevelLastFrame() >= 1);
CHECK(scene.application.renderer().drawnTilesLastFrame() > 0);
}
TEST(FramedCanvasRendersAfterToggle) {
AppScene scene;
scene.root().commands.run(Command::InfiniteCanvas);
scene.driver.frames(3);
CHECK(!scene.document().isInfinite());
scene.driver.drag(scene.canvasPoint(300, 300), scene.canvasPoint(340, 320));
CHECK(scene.document().tileCount() > 0);
}
}
SUITE(Sketch2DTileUploads) {
void paintTiles(Document & document, int4 columns, int4 rows, int4 offsetX) {
Brush brush;
brush.setSize(40);
for (int4 y = 0; y < rows; y++)
for (int4 x = 0; x < columns; x++)
document.stampBrushDab(
{ (halnf) ((x + offsetX) * kTileSize + kTileSize / 2), (halnf) (y * kTileSize + kTileSize / 2) }, brush
);
document.finishStroke();
}
TEST(OffScreenTilesAreNotUploaded) {
AppScene scene;
paintTiles(scene.document(), 2, 2, 0);
paintTiles(scene.document(), 2, 2, 40);
scene.driver.frames(3);
CHECK_EQUAL(0u, scene.application.renderer().uploadedTilesLastFrame());
CHECK(scene.application.renderer().drawnTilesLastFrame() == 4);
CHECK(!scene.application.renderer().hasPendingUploads());
}
TEST(LargeUploadsAreSpreadOverFrames) {
AppScene scene;
TileRenderer& renderer = scene.application.renderer();
renderer.setUploadTilesPerFrame(3);
paintTiles(scene.document(), 3, 3, 0);
scene.driver.frame();
CHECK_EQUAL(3u, renderer.uploadedTilesLastFrame());
CHECK(renderer.drawnTilesLastFrame() + renderer.fallbackTilesLastFrame() > 0);
CHECK(renderer.hasPendingUploads());
int frames = 0;
while (renderer.hasPendingUploads() && frames < 20) {
scene.driver.frame();
frames++;
}
CHECK(!renderer.hasPendingUploads());
CHECK_EQUAL(9u, renderer.drawnTilesLastFrame());
CHECK(frames >= 2);
}
}
SUITE(Sketch2DChannelViewIntegration) {
TEST(ChannelViewRendersEveryChannel) {
AppScene scene;
scene.driver.drag(scene.canvasPoint(200, 200), scene.canvasPoint(420, 260));
scene.root().commands.run(Command::ShowChannel);
CHECK(scene.root().canvas.settings().showChannel);
for (int index = 0; index < kViewChannelCount; index++) {
scene.root().channelMenu.items()[(size_t) index]->activate();
scene.driver.frame();
CHECK(scene.application.renderer().drawnTilesLastFrame() > 0);
}
}
}
SUITE(Sketch2DInputCoalescing) {
TEST(DrawingKeepsEveryPointerSample) {
AppScene scene;
const CoalescingStatistics before = scene.application.window().inputStatistics();
WindowInputObserver& input = scene.application.window().injectInput();
for (int step = 0; step < 20; step++)
input.onPointerMoved(scene.canvasPoint(200.0f + (halnf) step * 5, 200.0f));
scene.driver.frame();
const CoalescingStatistics after = scene.application.window().inputStatistics();
CHECK_EQUAL(20u, after.delivered - before.delivered);
}
}
SUITE(Sketch2DPenInput) {
int4 paintedHeightAt(Document & document, int4 worldX) {
document.settlePaint();
int4 height = 0;
for (int4 y = -400; y < 400; y++) {
const Tile* tile = document.activeLayer()->tileIfExists({ floordiv(worldX, kTileSize), floordiv(y, kTileSize) });
if (!tile) continue;
const int4 lx = worldX - floordiv(worldX, kTileSize) * kTileSize;
const int4 ly = y - floordiv(y, kTileSize) * kTileSize;
height += tile->pixels()[((ualni) ly * kTileSize + lx) * 4 + 3] > 0 ? 1 : 0;
}
return height;
}
TEST(PenPressureWidensTheStroke) {
AppScene scene;
Brush& brush = scene.application.brush();
brush.setSize(40);
brush.setHardness(1);
brush.setUsesPressure(PressureTarget::Size, true);
std::vector<Vec2F> points;
std::vector<PointerSample> samples;
for (int step = 0; step <= 20; step++) {
points.push_back(scene.canvasPoint(200.0f + (halnf) step * 15, 300.0f));
samples.push_back({ PointerTool::Pen, 0.1f + 0.9f * (halnf) step / 20.0f, { 20, 10 } });
}
scene.driver.penStroke(points, samples);
Document& document = scene.document();
const Vec2F start = document.screenToWorld(scene.root().canvas.toLocal(points[2]));
const Vec2F end = document.screenToWorld(scene.root().canvas.toLocal(points[18]));
const int4 thin = paintedHeightAt(document, (int4) start.x);
const int4 thick = paintedHeightAt(document, (int4) end.x);
CHECK(thin > 0);
CHECK(thick > thin * 2);
}
}
SUITE(Sketch2DSessionRecordingIntegration) {
TEST(RecordedSessionCapturesRealWindowInputAndRestoresTheCanvas) {
const std::filesystem::path directory = std::filesystem::temp_directory_path() / "sketch2d-app-sessions";
std::filesystem::remove_all(directory);
setenv("SKETCH2D_SESSION_DIR", directory.c_str(), 1);
AppScene scene;
scene.driver.drag(scene.canvasPoint(200, 200), scene.canvasPoint(300, 220));
const ualni tilesBefore = scene.document().tileCount();
scene.root().commands.run(Command::RecordSession);
REQUIRE CHECK(scene.root().toolBar.recording.isVisible());
PointerSample pen;
pen.tool = PointerTool::Pen;
pen.pressure = 0.6f;
scene.driver.penStroke(
{ scene.canvasPoint(150, 320), scene.canvasPoint(250, 330), scene.canvasPoint(350, 350) }, { pen, pen, pen }
);
scene.root().commands.run(Command::StopSessionRecording);
scene.driver.settle(scene.root().sessionSaveDialog);
REQUIRE CHECK(scene.root().sessionSaveDialog.isOpen());
scene.driver.key(InputID::ENTER);
const std::optional<RecordedSession> saved = RecordedSession::load(scene.root().lastSessionDirectory());
REQUIRE CHECK(saved.has_value());
size_t penSamples = 0;
for (const RecordedInput& event : saved->input.events)
if (event.kind == RecordedInput::Kind::Sample && event.sample.isStylus()) penSamples++;
CHECK(penSamples >= 3);
CHECK(saved->recordedFrames.find("frame.draw") != std::string::npos);
CHECK(!saved->snapshot.environment.device.empty());
Document restored;
Brush brush;
ViewSettings settings;
saved->snapshot.applyTo(restored, brush, settings);
CHECK_EQUAL(tilesBefore, restored.tileCount());
unsetenv("SKETCH2D_SESSION_DIR");
if (!std::getenv("SKETCH2D_KEEP_TEST_SESSION")) std::filesystem::remove_all(directory);
}
TEST(PressingDDoesNotOpenTheDebugUi) {
AppScene scene;
scene.driver.key(InputID::D);
scene.driver.frame();
CHECK(!scene.application.inspector().isVisible());
}
TEST(DebugMenuTogglesTheDebugUi) {
AppScene scene;
const MenuItem* toggle = nullptr;
for (const auto& item : scene.root().debugMenu.items())
if (item->entry().text == "Debug UI") toggle = item.get();
REQUIRE CHECK(toggle != nullptr);
toggle->entry().action();
CHECK(scene.application.inspector().isVisible());
CHECK(toggle->entry().isChecked());
toggle->entry().action();
CHECK(!scene.application.inspector().isVisible());
}
TEST(TabletLayoutCanBeForcedOnDesktop) {
Sketch2DApplication application;
AppDriver driver(application);
driver.frame();
REQUIRE CHECK(application.studioRoot() != nullptr);
CHECK(application.studioRoot()->toolBar.isVisible());
CHECK(application.studioRoot()->activeBar.isVisible());
CHECK(!application.root().toolBar.isVisible());
}
}
SUITE(Sketch2DDebugView) {
TEST(DebugViewCountsTilesOfTheShownLevelByState) {
AppScene scene;
scene.application.renderer().setDebug(true);
Layer* layer = scene.document().activeLayer();
for (int4 y = 0; y < 8; y++)
for (int4 x = 0; x < 8; x++)
layer->tileAt({ x, y })->clear({ 0, 0, 1, 1 });
scene.document().commit("Fill");
scene.driver.frames(20);
const ualni doneAtFullSize = scene.application.renderer().debugCount(TileDebugState::Done);
CHECK(doneAtFullSize > 0);
for (int notch = 0; notch < 30; notch++)
scene.driver.scroll(scene.canvasPoint(300, 300), -1);
scene.driver.frames(20);
const int4 level = scene.application.renderer().mipLevelLastFrame();
REQUIRE CHECK(level >= 2);
const ualni done = scene.application.renderer().debugCount(TileDebugState::Done);
CHECK(done > 0);
CHECK(done <= (ualni) std::max(1, 64 >> (2 * level)) + 4);
Brush brush;
brush.setSize(400);
for (int4 dab = 0; dab < 6; dab++)
scene.document().stampBrushDab({ 100.0f + (halnf) dab * 120.0f, 500.0f }, brush);
scene.driver.frames(1);
CHECK(scene.application.renderer().debugCount(TileDebugState::Preview) > 0);
scene.document().finishStroke();
scene.document().settlePaint();
scene.driver.frames(20);
CHECK_EQUAL(0u, scene.application.renderer().debugCount(TileDebugState::Preview));
CHECK_EQUAL(0u, scene.application.renderer().debugCount(TileDebugState::Queued));
CHECK_EQUAL(0u, scene.application.renderer().debugCount(TileDebugState::Working));
scene.document().setRotation(0.6f);
scene.driver.frames(20);
CHECK(scene.application.renderer().debugCount(TileDebugState::Done) > 0);
CHECK(scene.application.renderer().drawnTilesLastFrame() > 0);
scene.application.renderer().setDebug(false);
}
}
SUITE(Sketch2DZoomedOutStrokes) {
TEST(ZoomedOutStrokeShowsAtOnceAndSettlesToFullResolution) {
AppScene scene;
Layer* layer = scene.document().activeLayer();
for (int4 y = 0; y < 8; y++)
for (int4 x = 0; x < 8; x++)
layer->tileAt({ x, y })->clear({ 0, 0, 1, 1 });
scene.document().commit("Fill");
for (int notch = 0; notch < 30; notch++)
scene.driver.scroll(scene.canvasPoint(300, 300), -1);
scene.driver.frames(10);
REQUIRE CHECK(scene.document().zoom() <= 0.25f);
const RGBA before = scene.document().sampleColor({ 450.5f, 410.5f }, false);
Brush brush;
brush.setSize(80);
brush.setHardness(1.0f);
brush.color = { 1, 1, 0, 1 };
for (int4 dab = 0; dab <= 10; dab++)
scene.document().stampBrushDab({ 200.0f + (halnf) dab * 50.0f, 410.0f }, brush);
CHECK(scene.document().deferredDabs() > 0);
scene.driver.frames(2);
CHECK(scene.application.renderer().drawnTilesLastFrame() > 0);
scene.document().finishStroke();
scene.driver.frames(30);
scene.document().settlePaint();
CHECK_EQUAL("Paint", scene.document().history().undoLabel());
const RGBA after = scene.document().sampleColor({ 450.5f, 410.5f }, false);
CHECK(std::abs(after.r - before.r) + std::abs(after.g - before.g) + std::abs(after.b - before.b) > 0.1f);
const RGBA away = scene.document().sampleColor({ 450.5f, 900.5f }, false);
CHECK_CLOSE(before.b, away.b, 0.01f);
CHECK_EQUAL(0u, scene.document().paintBacklog());
CHECK(scene.application.renderer().drawnTilesLastFrame() > 0);
}
}
SUITE(Sketch2DUploadBudget) {
void fillArea(Document & document, int4 tiles) {
Layer* layer = document.activeLayer();
for (int4 y = 0; y < tiles; y++)
for (int4 x = 0; x < tiles; x++)
layer->tileAt({ x, y })->clear({ 0.3f, 0.5f, 0.7f, 1.0f });
}
TEST(UploadsStayWithinThePixelBudget) {
AppScene scene;
TileRenderer& renderer = scene.application.renderer();
renderer.setUploadTilesPerFrame(1000);
fillArea(scene.document(), 12);
scene.driver.frame();
CHECK(renderer.uploadedPixelsLastFrame() <= TileRenderer::kUploadPixelBudget + (ualni) kTileSize * kTileSize);
CHECK(renderer.uploadedPixelsLastFrame() > 0);
}
TEST(LargeInvalidationsShowTheCoarserLevelFirst) {
AppScene scene;
TileRenderer& renderer = scene.application.renderer();
renderer.setUploadTilesPerFrame(1000);
fillArea(scene.document(), 12);
scene.driver.frame();
CHECK_EQUAL(0, renderer.wantedLevelLastFrame());
CHECK_EQUAL(1, renderer.mipLevelLastFrame());
CHECK(renderer.drawnTilesLastFrame() > 0);
int frames = 0;
while ((renderer.mipLevelLastFrame() != 0 || renderer.hasPendingUploads()) && frames < 40) {
scene.driver.frame();
frames++;
}
CHECK_EQUAL(0, renderer.mipLevelLastFrame());
CHECK(!renderer.hasPendingUploads());
CHECK(frames >= TileRenderer::kCoarseHoldFrames - 1);
}
TEST(TilesNotUploadedYetFallBackToTheirParent) {
AppScene scene;
TileRenderer& renderer = scene.application.renderer();
renderer.setUploadTilesPerFrame(2);
fillArea(scene.document(), 2);
scene.driver.frame();
CHECK_EQUAL(0, renderer.mipLevelLastFrame());
CHECK(renderer.hasPendingUploads());
CHECK(renderer.fallbackTilesLastFrame() > 0);
CHECK_EQUAL(4u, renderer.drawnTilesLastFrame() + renderer.fallbackTilesLastFrame());
}
}