Modules/Sketch2D/tests/HistoryTests.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

482 lines
14 KiB
C++

#include "Document.hpp"
#include "History.hpp"
#include "UnitTest++/UnitTest++.h"
using namespace tp;
using namespace tp::sketch2d;
namespace {
Brush solidBrush() {
Brush brush;
brush.color = { 1, 0, 0, 1 };
brush.setFlow(1);
brush.setHardness(1);
brush.setSize(40);
return brush;
}
int4 alphaAt(const Document& document, int4 x, int4 y) {
document.settlePaint();
const Layer* layer = document.activeLayer();
const TileCoord coord = { x / kTileSize, y / kTileSize };
const Tile* tile = layer->tileIfExists(coord);
if (!tile) return 0;
return tile->pixels()[((ualni) (y % kTileSize) * kTileSize + (x % kTileSize)) * 4 + 3];
}
void paintAt(Document& document, Vec2F point) {
document.stampBrushDab(point, solidBrush());
document.finishStroke("Paint");
document.settlePaint();
}
}
SUITE(HistoryModel) {
TEST(CommitUndoRedoWalkStates) {
History<int> history;
history.reset("New file", 0);
history.commit("A", 1);
history.commit("B", 2);
CHECK_EQUAL("B", history.undoLabel());
CHECK_EQUAL(1, *history.undo());
CHECK_EQUAL("B", history.redoLabel());
CHECK_EQUAL(0, *history.undo());
CHECK(!history.canUndo());
CHECK(history.undo() == nullptr);
CHECK_EQUAL(2, *history.jumpTo(2));
CHECK(!history.canRedo());
}
TEST(CommitAfterUndoDropsRedoBranch) {
History<int> history;
history.reset("New file", 0);
history.commit("A", 1);
history.commit("B", 2);
history.undo();
history.commit("C", 3);
CHECK_EQUAL(3u, history.entries().size());
CHECK_EQUAL("C", history.entries().back().label);
CHECK(!history.canRedo());
}
TEST(MergeKeyCollapsesConsecutiveCommits) {
History<int> history;
history.reset("New file", 0);
history.commit("Layer opacity", 1, "layer_opacity");
history.commit("Layer opacity", 2, "layer_opacity");
history.commit("Layer opacity", 3, "layer_opacity");
CHECK_EQUAL(2u, history.entries().size());
CHECK_EQUAL(3, history.current().state);
CHECK_EQUAL(0, *history.undo());
}
TEST(LimitClampsAndDropsOldestStates) {
History<int> history;
CHECK_EQUAL(30, history.limit());
history.setLimit(1);
CHECK_EQUAL(2, history.limit());
history.setLimit(5000);
CHECK_EQUAL(1000, history.limit());
history.setLimit(3);
history.reset("New file", 0);
for (int step = 1; step <= 10; step++)
history.commit("Step", step);
CHECK_EQUAL(4u, history.entries().size());
CHECK_EQUAL(7, history.entries().front().state);
CHECK_EQUAL(10, history.current().state);
}
TEST(LoweringLimitTrimsExistingHistory) {
History<int> history;
history.reset("New file", 0);
for (int step = 1; step <= 10; step++)
history.commit("Step", step);
history.setLimit(2);
CHECK_EQUAL(3u, history.entries().size());
CHECK_EQUAL(10, history.current().state);
}
TEST(ClearKeepsOnlyCurrentState) {
History<int> history;
history.reset("New file", 0);
history.commit("A", 1);
history.commit("B", 2);
history.undo();
history.clear();
CHECK_EQUAL(1u, history.entries().size());
CHECK_EQUAL(1, history.current().state);
CHECK(!history.canUndo());
CHECK(!history.canRedo());
}
}
SUITE(DocumentHistory) {
TEST(UndoRemovesStrokeAndRedoRestoresIt) {
Document document;
paintAt(document, { 100, 100 });
CHECK_EQUAL(255, alphaAt(document, 100, 100));
CHECK_EQUAL("Paint", document.history().undoLabel());
CHECK(document.undo());
CHECK_EQUAL(0, alphaAt(document, 100, 100));
CHECK_EQUAL(0u, document.tileCount());
CHECK(document.redo());
CHECK_EQUAL(255, alphaAt(document, 100, 100));
}
TEST(SnapshotsShareUnchangedTilesAndCopyChangedOnes) {
Document document;
const int4 quarter = kTileSize / 4;
paintAt(document, { (halnf) quarter, (halnf) quarter });
const Tile* before = document.activeLayer()->tileIfExists({ 0, 0 });
paintAt(document, { (halnf) (kTileSize * 3), (halnf) quarter });
CHECK(document.activeLayer()->tileIfExists({ 0, 0 }) == before);
paintAt(document, { (halnf) (quarter * 3), (halnf) quarter });
CHECK(document.activeLayer()->tileIfExists({ 0, 0 }) != before);
document.undo();
CHECK(document.activeLayer()->tileIfExists({ 0, 0 }) == before);
CHECK_EQUAL(0, alphaAt(document, quarter * 3 + 4, quarter));
CHECK_EQUAL(255, alphaAt(document, quarter, quarter));
}
TEST(UndoRestoresLayerStackAndActiveLayer) {
Document document;
Layer* first = document.activeLayer();
document.addLayer(document.nextLayerName());
document.commit("Create layer");
CHECK_EQUAL(2u, document.layerCount());
CHECK_EQUAL("Layer 2", document.activeLayer()->name());
document.removeActiveLayer();
document.commit("Delete layer");
CHECK_EQUAL(1u, document.layerCount());
document.undo();
CHECK_EQUAL(2u, document.layerCount());
CHECK_EQUAL("Layer 2", document.activeLayer()->name());
document.undo();
CHECK_EQUAL(1u, document.layerCount());
CHECK(document.activeLayer() == first);
}
TEST(UndoRestoresLayerProperties) {
Document document;
Layer& layer = *document.activeLayer();
document.setLayerOpacity(layer, 0.4f);
document.commit("Layer opacity", "layer_opacity");
document.setLayerVisible(layer, false);
document.commit("Show/hide layer");
document.undo();
CHECK(layer.visible());
CHECK_CLOSE(0.4f, layer.opacity(), 0.001f);
document.undo();
CHECK_CLOSE(1.0f, layer.opacity(), 0.001f);
}
TEST(EraseUndoBringsTilesBack) {
Document document;
paintAt(document, { 100, 100 });
Brush eraser = solidBrush();
eraser.type = BrushType::Eraser;
eraser.setSize(80);
document.stampBrushDab({ 100, 100 }, eraser);
document.finishStroke("Eraser");
CHECK_EQUAL(0u, document.tileCount());
document.undo();
CHECK_EQUAL(1u, document.tileCount());
CHECK_EQUAL(255, alphaAt(document, 100, 100));
}
TEST(JumpToHistoryState) {
Document document;
paintAt(document, { 100, 100 });
paintAt(document, { 700, 100 });
paintAt(document, { 1300, 100 });
document.jumpToHistory(1);
CHECK_EQUAL(1u, document.tileCount());
document.jumpToHistory(3);
CHECK_EQUAL(3u, document.tileCount());
}
TEST(ResetStartsFreshHistory) {
Document document;
paintAt(document, { 100, 100 });
document.addLayer(document.nextLayerName());
document.commit("Create layer");
document.reset();
CHECK_EQUAL(1u, document.layerCount());
CHECK_EQUAL("Layer 1", document.activeLayer()->name());
CHECK_EQUAL(1u, document.history().entries().size());
CHECK_EQUAL("New file", document.history().current().label);
}
}
#include "Sketch2DUI.hpp"
#include "Ui.hpp"
#include "UiDriver.hpp"
using namespace tp::ui;
using namespace tp::ui::testing;
namespace {
class QuietViewport : public ViewportRenderer {
public:
void render(Document&, const ViewSettings&, Vec2F) override {}
[[nodiscard]] ImageId image() const override { return { 1 }; }
void setDebugOverlay(bool) override {}
[[nodiscard]] bool debugOverlay() const override { return false; }
};
class PlainIcons : public IconProvider {
public:
[[nodiscard]] ImageId icon(const std::string&) const override { return { 1 }; }
};
struct HistoryScene {
Document document;
Brush brush;
QuietViewport viewport;
PlainIcons icons;
Sketch2DRoot root{ document, viewport, brush, icons };
};
Modifiers ctrlKey(bool shift = false) {
Modifiers modifiers;
modifiers.control = true;
modifiers.shift = shift;
return modifiers;
}
const MenuItem& editItem(Sketch2DRoot& root, size_t index) { return *root.editMenu.items()[index]; }
void strokeOnCanvas(UiDriver& driver, Sketch2DRoot& root, Vec2F offset) {
const RectF canvas = root.canvas.windowRect();
driver.drag(canvas.pos + offset, canvas.pos + offset + Vec2F(60, 20));
}
}
SUITE(UndoRedoCommands) {
TEST(ShortcutsUndoAndRedoStrokes) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
strokeOnCanvas(driver, scene.root, { 100, 100 });
CHECK(scene.document.tileCount() > 0);
driver.key(InputID::Z, ctrlKey());
CHECK_EQUAL(0u, scene.document.tileCount());
driver.key(InputID::Z, ctrlKey(true));
CHECK(scene.document.tileCount() > 0);
driver.key(InputID::Y, ctrlKey());
CHECK_EQUAL(0u, scene.document.tileCount());
driver.key(InputID::Y, ctrlKey(true));
CHECK(scene.document.tileCount() > 0);
}
TEST(HoldingUndoRepeats) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
for (int stroke = 0; stroke < 4; stroke++)
strokeOnCanvas(driver, scene.root, Vec2F(100.0f + 150.0f * (halnf) stroke, 100.0f));
CHECK_EQUAL(4u, scene.document.history().currentIndex());
driver.holdKey(InputID::Z, 2, ctrlKey());
CHECK_EQUAL(1u, scene.document.history().currentIndex());
}
TEST(EditMenuNamesTheStepAndDisablesAtEnds) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.root.editMenu.refresh();
CHECK_EQUAL("Undo", editItem(scene.root, 0).text());
CHECK(!editItem(scene.root, 0).isEnabled());
CHECK_EQUAL("Redo", editItem(scene.root, 1).text());
strokeOnCanvas(driver, scene.root, { 100, 100 });
driver.key(InputID::N, ctrlKey(true));
scene.root.editMenu.refresh();
CHECK_EQUAL("Undo: Create layer", editItem(scene.root, 0).text());
CHECK(editItem(scene.root, 0).isEnabled());
driver.key(InputID::Z, ctrlKey());
scene.root.editMenu.refresh();
CHECK_EQUAL("Undo: Paint", editItem(scene.root, 0).text());
CHECK_EQUAL("Redo: Create layer", editItem(scene.root, 1).text());
}
TEST(EraserStrokeIsLabelledEraser) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
strokeOnCanvas(driver, scene.root, { 100, 100 });
driver.key(InputID::E);
strokeOnCanvas(driver, scene.root, { 100, 100 });
CHECK_EQUAL("Eraser", scene.document.history().undoLabel());
}
TEST(ToolbarButtonsFollowHistory) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
CHECK(!scene.root.toolBar.undo.isEnabled());
CHECK(!scene.root.toolBar.redo.isEnabled());
strokeOnCanvas(driver, scene.root, { 100, 100 });
CHECK(scene.root.toolBar.undo.isEnabled());
driver.click(scene.root.toolBar.undo);
CHECK_EQUAL(0u, scene.document.tileCount());
CHECK(scene.root.toolBar.redo.isEnabled());
CHECK(!scene.root.toolBar.undo.isEnabled());
driver.click(scene.root.toolBar.redo);
CHECK(scene.document.tileCount() > 0);
}
TEST(UndoRefreshesLayerPanel) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.key(InputID::N, ctrlKey(true));
CHECK_EQUAL(2u, scene.root.toolBar.layersPopup.rows().size());
driver.key(InputID::Z, ctrlKey());
CHECK_EQUAL(1u, scene.root.toolBar.layersPopup.rows().size());
}
}
SUITE(HistoryMenu) {
TEST(ListsStatesNewestFirstWithCurrentChecked) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
strokeOnCanvas(driver, scene.root, { 100, 100 });
driver.key(InputID::N, ctrlKey(true));
driver.key(InputID::Z, ctrlKey());
scene.root.historyMenu.refresh();
const auto& items = scene.root.historyMenu.items();
CHECK_EQUAL("Create layer", items[0]->text());
CHECK_EQUAL("Paint", items[1]->text());
CHECK_EQUAL("New file", items[2]->text());
CHECK(!items[0]->entry().isChecked());
CHECK(items[1]->entry().isChecked());
CHECK(items[0]->entry().muted);
CHECK(!items[2]->entry().muted);
CHECK_EQUAL("Clear history", items[3]->text());
CHECK_EQUAL("Set history limit", items[4]->text());
}
TEST(ClickingStateJumpsThere) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
strokeOnCanvas(driver, scene.root, { 100, 100 });
strokeOnCanvas(driver, scene.root, { 400, 100 });
for (const auto& title : scene.root.menuBar.titles())
if (title->text() == "History") driver.click(*title);
CHECK(scene.root.historyMenu.isOpen());
driver.click(*scene.root.historyMenu.items()[2]);
CHECK_EQUAL(0u, scene.document.history().currentIndex());
CHECK_EQUAL(0u, scene.document.tileCount());
}
TEST(LongHistoryShowsMoreMarkers) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
for (int step = 0; step < 25; step++) {
scene.document.addLayer(scene.document.nextLayerName());
scene.document.commit("Create layer");
}
scene.document.jumpToHistory(15);
scene.root.historyMenu.refresh();
const auto& items = scene.root.historyMenu.items();
CHECK_EQUAL("(4 More)", items.front()->text());
CHECK(!items.front()->isEnabled());
CHECK_EQUAL(1u + 6u + 1u + 12u + 1u + 2u, items.size());
CHECK_EQUAL("(3 More)", items[items.size() - 3]->text());
}
}
SUITE(HistoryDialogs) {
TEST(ClearHistoryAsksAndKeepsCurrentState) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
strokeOnCanvas(driver, scene.root, { 100, 100 });
strokeOnCanvas(driver, scene.root, { 400, 100 });
const ualni tiles = scene.document.tileCount();
scene.root.commands.run(Command::ClearHistory);
CHECK(scene.root.confirm.isOpen());
CHECK_EQUAL("Are you sure you want to clear history?", scene.root.confirm.message());
CHECK_EQUAL(3u, scene.document.history().entries().size());
driver.click(*scene.root.confirm.buttons()[0]);
CHECK_EQUAL(1u, scene.document.history().entries().size());
CHECK_EQUAL(tiles, scene.document.tileCount());
CHECK(!scene.root.commands.isEnabled(Command::Undo));
CHECK(!scene.root.commands.isEnabled(Command::ClearHistory));
}
TEST(CancellingClearKeepsHistory) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
strokeOnCanvas(driver, scene.root, { 100, 100 });
scene.root.commands.run(Command::ClearHistory);
driver.key(InputID::ESCAPE);
CHECK_EQUAL(2u, scene.document.history().entries().size());
}
TEST(SetHistoryLimitDialogAppliesClampedValue) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.root.commands.run(Command::SetHistoryLimit);
CHECK(scene.root.historyLimit.isOpen());
CHECK_EQUAL("30", scene.root.historyLimit.steps.field().text());
driver.click(scene.root.historyLimit.steps.field());
driver.type("5");
driver.key(InputID::ENTER);
driver.click(*scene.root.historyLimit.buttons()[0]);
CHECK_EQUAL(5, scene.document.history().limit());
scene.root.commands.run(Command::SetHistoryLimit);
driver.click(scene.root.historyLimit.steps.decrementButton());
driver.click(scene.root.historyLimit.steps.decrementButton());
driver.click(scene.root.historyLimit.steps.decrementButton());
driver.click(*scene.root.historyLimit.buttons()[0]);
CHECK_EQUAL(2, scene.document.history().limit());
}
TEST(CancelLeavesLimitUnchanged) {
HistoryScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.root.commands.run(Command::SetHistoryLimit);
driver.click(scene.root.historyLimit.steps.incrementButton());
driver.click(*scene.root.historyLimit.buttons()[1]);
CHECK_EQUAL(30, scene.document.history().limit());
}
}