Files
OmaPhoto/tests/ColorRangeTests.cpp

404 lines
17 KiB
C++

#include "Document/EditorSession.h"
#include "SelectionFixtures.h"
#include <QFutureWatcher>
#include <QtTest>
extern "C" {
#include "WandPixels.h"
}
// Swift 1.3.4's Color Range: model, kernel, session.
namespace {
// Red, a band of blue, and a clear corner.
QImage picture()
{
QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied);
image.fill(QColor(200, 30, 30));
for (int y = 0; y < 20; ++y)
for (int x = 20; x < 30; ++x)
image.setPixelColor(x, y, QColor(30, 40, 210));
for (int y = 0; y < 5; ++y)
for (int x = 35; x < 40; ++x)
image.setPixelColor(x, y, Qt::transparent);
return image;
}
struct Opened {
EditorSession session;
Opened()
{
session.createDocument(40, 20);
session.insert(ImportedImage(picture(), picture(), QStringLiteral("Picture")));
session.beginColorRange();
}
const ColorRangeEdit &edit() const { return session.colorRange().value(); }
// A change, then its match landing: a second signal.
void land(const std::function<void()> &change)
{
QSignalSpy changed(&session, &EditorSession::changed);
change();
QVERIFY(QTest::qWaitFor([&changed] { return changed.count() >= 2; }));
}
void sample(QPointF point, bool shift = false, bool option = false)
{
land([&] { session.sampleColorRange(point, shift, option); });
}
int covered(int x, int y) const { return coverage(session, x, y); }
};
std::vector<uchar> rgb(int r, int g, int b)
{
return {uchar(r), uchar(g), uchar(b)};
}
// A 2100 checkerboard, black in its corner: past the limit.
QImage checkerboard()
{
QImage board(2100, 2100, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < board.height(); ++y) {
auto *row = reinterpret_cast<quint32 *>(board.scanLine(y));
for (int x = 0; x < board.width(); ++x)
row[x] = (x + y) % 2 && (x > 2 || y > 2) ? 0xFFFFFFFF : 0xFF000000;
}
return board;
}
// The session's workers: its own watchers and one a change.
int watchers(const EditorSession &session)
{
return int(session.findChildren<QFutureWatcherBase *>(Qt::FindDirectChildrenOnly).size());
}
}
class ColorRangeTests : public QObject {
Q_OBJECT
private slots:
void theKernelMatchesWithinFuzziness();
void aClickPicksTheAverageAroundIt();
void addTakeAwayAndStartOver();
void fuzzinessInvertAndThePreview();
void okIsOneStepAndCancelPutsBack();
void theGatesWaitForThePanel();
void everyChangeIsAnnounced();
void anOutlineTooDetailedIsShownAndKeptOut();
void anErrorKeepsTheLastMatch();
void onlyTheNewestMatchLands();
void thePreviewTruncatesAsSwifts();
};
void ColorRangeTests::theKernelMatchesWithinFuzziness()
{
// Four pixels: red, red off by 10, blue, clear.
const std::array<uchar, 16> pixels{200, 30, 30, 255, 210, 30, 30, 255, 30, 40, 210, 255, 0, 0, 0, 0};
std::array<uchar, 4> mask{};
const std::vector<uchar> red = rgb(200, 30, 30);
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 9, 0, mask.data()), 1L);
QCOMPARE(mask, (std::array<uchar, 4>{255, 0, 0, 0}));
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 10, 0, mask.data()), 2L);
QCOMPARE(mask, (std::array<uchar, 4>{255, 255, 0, 0}));
// Taken away wins; inverted, clear pixels are selected too.
const std::vector<uchar> off = rgb(210, 30, 30);
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, off.data(), 1, 10, 0, mask.data()), 0L);
QCOMPARE(color_range_mask(pixels.data(), 4, 1, 16, red.data(), 1, nullptr, 0, 10, 1, mask.data()), 2L);
QCOMPARE(mask, (std::array<uchar, 4>{0, 0, 255, 255}));
// Premultiplied pixels are read straight: half clear red matches red.
const std::array<uchar, 4> half{100, 15, 15, 128};
std::array<uchar, 1> one{};
QCOMPARE(color_range_mask(half.data(), 1, 1, 4, red.data(), 1, nullptr, 0, 1, 0, one.data()), 1L);
}
void ColorRangeTests::aClickPicksTheAverageAroundIt()
{
Opened opened;
QVERIFY(!opened.edit().hasColors() && opened.edit().preview.isNull());
opened.sample(QPointF(5.7, 10.2));
QCOMPARE(opened.edit().include, rgb(200, 30, 30));
// On the seam: three red and six blue, averaged.
opened.sample(QPointF(20, 10));
QCOMPARE(opened.edit().include, rgb((3 * 200 + 6 * 30 + 4) / 9, (3 * 30 + 6 * 40 + 4) / 9, (3 * 30 + 6 * 210 + 4) / 9));
// At the clear corner's edge only coloured pixels count.
opened.sample(QPointF(34, 2));
QCOMPARE(opened.edit().include, rgb(200, 30, 30));
// The click's pixel is floored: 19.6 reads round 19.
opened.sample(QPointF(19.6, 10));
QCOMPARE(opened.edit().include, rgb((6 * 200 + 3 * 30 + 4) / 9, (6 * 30 + 3 * 40 + 4) / 9, (6 * 30 + 3 * 210 + 4) / 9));
// In the clear corner, past the edge, no number: nothing.
for (const QPointF point : {QPointF(38, 2), QPointF(-1, 3), QPointF(40, 3), QPointF(qQNaN(), 3)}) {
const int generation = opened.edit().generation;
opened.session.sampleColorRange(point, false, false);
QCOMPARE(opened.edit().generation, generation);
}
// Three rows, one of each colour: all three count.
QImage rows(3, 3, QImage::Format_RGBA8888_Premultiplied);
for (int x = 0; x < 3; ++x) {
rows.setPixelColor(x, 0, QColor(90, 0, 0));
rows.setPixelColor(x, 1, QColor(0, 90, 0));
rows.setPixelColor(x, 2, QColor(0, 0, 90));
}
EditorSession session;
session.createDocument(3, 3);
session.insert(ImportedImage(rows, rows, QStringLiteral("Rows")));
session.beginColorRange();
session.sampleColorRange(QPointF(1, 1), false, false);
QCOMPARE(session.colorRange().value().include, rgb(30, 30, 30));
}
void ColorRangeTests::addTakeAwayAndStartOver()
{
Opened opened;
opened.sample(QPointF(5, 5));
QCOMPARE(opened.covered(5, 5), 255);
QCOMPARE(opened.covered(25, 5), 0);
QVERIFY(opened.session.selection().value().antialiased);
// The match takes the session's antialiasing.
opened.session.setSelectionAntialiased(false);
opened.sample(QPointF(6, 5));
QVERIFY(!opened.session.selection().value().antialiased);
// Shift adds blue; Alt takes red away.
opened.sample(QPointF(25, 5), true, false);
QCOMPARE(opened.edit().include, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
QCOMPARE(opened.covered(25, 5), 255);
opened.sample(QPointF(5, 5), true, true);
QCOMPARE(opened.edit().exclude, rgb(200, 30, 30));
QVERIFY(opened.covered(5, 5) == 0 && opened.covered(25, 5) == 255);
// The chosen eyedroppers act without keys; replace starts over.
opened.session.setColorRangeSampleMode(HueSampleMode::remove);
opened.sample(QPointF(25, 5));
QCOMPARE(opened.edit().exclude, (std::vector<uchar>{200, 30, 30, 30, 40, 210}));
QVERIFY(!opened.session.selection());
opened.session.setColorRangeSampleMode(HueSampleMode::replace);
opened.sample(QPointF(25, 5));
QVERIFY(opened.edit().include == rgb(30, 40, 210) && opened.edit().exclude.empty());
QCOMPARE(opened.covered(25, 5), 255);
// Held keys outrank the chosen eyedropper.
opened.session.setColorRangeHeld(HueSampleMode::add);
QCOMPARE(opened.edit().effectiveMode(), HueSampleMode::add);
}
void ColorRangeTests::fuzzinessInvertAndThePreview()
{
Opened opened;
opened.sample(QPointF(5, 5));
// The preview: the mask at twice the panel's fit.
const double scale = std::min(292.0 / 40, 200.0 / 20) * 2;
QCOMPARE(opened.edit().preview.size(), QSize(int(40 * scale), int(20 * scale)));
QCOMPARE(qGray(opened.edit().preview.pixel(10, 10)), 255);
QCOMPARE(qGray(opened.edit().preview.pixel(int(25 * scale), 10)), 0);
// Fuzziness: whole, 0 to 200; wide, blue joins.
opened.land([&] { opened.session.setColorRangeFuzziness(250.4); });
QCOMPARE(opened.edit().fuzziness, 200.0);
QCOMPARE(opened.covered(25, 5), 255);
opened.session.setColorRangeFuzziness(-3);
QCOMPARE(opened.edit().fuzziness, 0.0);
opened.session.setColorRangeFuzziness(12.6);
QCOMPARE(opened.edit().fuzziness, 13.0);
// Invert: all but red, the clear corner included.
opened.land([&] { opened.session.setColorRangeInvert(true); });
QVERIFY(opened.covered(5, 5) == 0 && opened.covered(25, 5) == 255 && opened.covered(38, 2) == 255);
// No colours: the old selection and no preview.
Opened empty;
empty.session.updateColorRange();
QVERIFY(!empty.session.selection() && empty.edit().preview.isNull());
}
void ColorRangeTests::okIsOneStepAndCancelPutsBack()
{
Opened opened;
QPainterPath corner;
corner.addRect(0, 0, 4, 4);
const DocumentSelection before{corner, true};
opened.session.cancelColorRange();
opened.session.setSelection(before, QStringLiteral("Before"));
opened.session.beginColorRange();
QCOMPARE(opened.edit().original.value().path, before.path);
opened.sample(QPointF(25, 5));
QCOMPARE(opened.covered(1, 1), 0);
// Cancel: the selection there was, no step.
const int steps = opened.session.history.undoCount();
opened.session.cancelColorRange();
QVERIFY(!opened.session.colorRange() && opened.covered(1, 1) == 255 && opened.covered(25, 5) == 0);
QCOMPARE(opened.session.history.undoCount(), steps);
// OK: one step, named; undo puts the old back.
opened.session.beginColorRange();
opened.sample(QPointF(25, 5));
opened.session.commitColorRange();
QCOMPARE(opened.session.history.undoCount(), steps + 1);
QCOMPARE(opened.session.history.undoName(), QStringLiteral("Color Range"));
QCOMPARE(opened.covered(25, 5), 255);
opened.session.undo();
QVERIFY(opened.covered(1, 1) == 255 && opened.covered(25, 5) == 0);
// OK with no colour keeps the old selection, no step.
opened.session.beginColorRange();
opened.session.commitColorRange();
QCOMPARE(opened.session.history.undoCount(), steps);
QCOMPARE(opened.covered(1, 1), 255);
// OK on a match of nothing deselects.
opened.session.beginColorRange();
opened.sample(QPointF(5, 5));
opened.sample(QPointF(5, 5), false, true);
QVERIFY(!opened.session.selection());
opened.session.commitColorRange();
QVERIFY(!opened.session.selection());
QCOMPARE(opened.session.history.undoName(), QStringLiteral("Deselect"));
}
void ColorRangeTests::theGatesWaitForThePanel()
{
Opened opened;
QVERIFY(!opened.session.canEditLayers() && !opened.session.canUseHistory() && !opened.session.canStartProjectOperation());
QVERIFY(!opened.session.canSelectColorRange());
// A second begin keeps the open edit.
const QUuid id = opened.edit().id;
opened.session.beginColorRange();
QCOMPARE(opened.edit().id, id);
opened.session.cancelColorRange();
QVERIFY(opened.session.canEditLayers() && opened.session.canUseHistory() && opened.session.canStartProjectOperation());
QVERIFY(opened.session.canSelectColorRange());
// A request waiting on the panel runs once it closes.
opened.session.beginColorRange();
bool ran = false;
opened.session.waitForFileRequest([&ran] { ran = true; });
QTest::qWait(10);
QVERIFY(!ran);
opened.session.cancelColorRange();
QTRY_VERIFY(ran);
// OK frees one too.
opened.session.beginColorRange();
ran = false;
opened.session.waitForFileRequest([&ran] { ran = true; });
QTest::qWait(10);
QVERIFY(!ran);
opened.session.commitColorRange();
QTRY_VERIFY(ran);
// Without a document there is nothing to open.
EditorSession empty;
QVERIFY(!empty.canSelectColorRange());
empty.beginColorRange();
QVERIFY(!empty.colorRange());
}
void ColorRangeTests::everyChangeIsAnnounced()
{
Opened opened;
QSignalSpy changed(&opened.session, &EditorSession::changed);
// Before a colour, Fuzziness and Invert still say so.
opened.session.setColorRangeFuzziness(50);
QCOMPARE(changed.count(), 1);
opened.session.setColorRangeInvert(true);
QCOMPARE(changed.count(), 2);
opened.session.setColorRangeInvert(false);
// Each eyedropper and held key; an unchanged one is silent.
changed.clear();
opened.session.setColorRangeSampleMode(HueSampleMode::add);
opened.session.setColorRangeSampleMode(HueSampleMode::add);
opened.session.setColorRangeHeld(HueSampleMode::remove);
opened.session.setColorRangeHeld(HueSampleMode::remove);
opened.session.setColorRangeFuzziness(50);
QCOMPARE(changed.count(), 2);
// A click says so now, and as its match lands.
opened.session.setColorRangeHeld(std::nullopt);
opened.session.setColorRangeSampleMode(HueSampleMode::replace);
changed.clear();
opened.session.sampleColorRange(QPointF(5, 5), false, false);
QCOMPARE(changed.count(), 1);
QTRY_COMPARE(changed.count(), 2);
QCOMPARE(opened.covered(5, 5), 255);
}
void ColorRangeTests::anOutlineTooDetailedIsShownAndKeptOut()
{
const QImage board = checkerboard();
EditorSession session;
session.createDocument(2100, 2100);
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
session.beginColorRange();
session.setColorRangeFuzziness(0);
session.sampleColorRange(QPointF(1, 1), false, false);
QTRY_VERIFY_WITH_TIMEOUT(session.colorRange().value().error, 30000);
QCOMPARE(session.colorRange().value().error.value(), QString::fromUtf8(MagicWandError(MagicWandError::Kind::tooDetailed).what()));
// The preview still shows; the selection stays as it was.
QVERIFY(!session.colorRange().value().preview.isNull() && !session.selection());
const int steps = session.history.undoCount();
session.commitColorRange();
QVERIFY(!session.selection() && !session.colorRange());
QCOMPARE(session.history.undoCount(), steps);
}
void ColorRangeTests::anErrorKeepsTheLastMatch()
{
// A red band on top; a black block amid checks.
QImage board = checkerboard();
for (int y = 0; y < 10; ++y)
for (int x = 0; x < board.width(); ++x)
board.setPixelColor(x, y, QColor(200, 30, 30));
for (int y = 100; y < 103; ++y)
for (int x = 100; x < 103; ++x)
board.setPixelColor(x, y, Qt::black);
EditorSession session;
session.createDocument(2100, 2100);
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
session.beginColorRange();
session.setColorRangeFuzziness(0);
session.sampleColorRange(QPointF(10, 4), false, false);
QTRY_VERIFY_WITH_TIMEOUT(session.selection(), 30000);
QVERIFY(coverage(session, 10, 4) == 255 && coverage(session, 500, 500) == 0);
// Black matches every dark check: too detailed, the band stays.
session.sampleColorRange(QPointF(101, 101), false, false);
QTRY_VERIFY_WITH_TIMEOUT(session.colorRange().value().error, 30000);
QVERIFY(coverage(session, 10, 4) == 255 && coverage(session, 500, 500) == 0);
// OK after an error: the old selection, no step.
const int steps = session.history.undoCount();
session.commitColorRange();
QVERIFY(!session.selection() && session.history.undoCount() == steps);
}
void ColorRangeTests::onlyTheNewestMatchLands()
{
EditorSession session;
session.createDocument(2100, 2100);
const QImage board = checkerboard();
session.insert(ImportedImage(board, board, QStringLiteral("Board")));
const int idle = watchers(session);
session.beginColorRange();
session.setColorRangeFuzziness(0);
// A slow trace, then a gray that matches nothing, quickly.
session.sampleColorRange(QPointF(1, 1), false, false);
session.sampleColorRange(QPointF(3, 3), false, false);
QTRY_COMPARE_WITH_TIMEOUT(watchers(session), idle, 30000);
QVERIFY(!session.colorRange().value().error && !session.colorRange().value().preview.isNull());
// A closed edit's trace lands on no later edit.
session.cancelColorRange();
session.beginColorRange();
session.sampleColorRange(QPointF(1, 1), false, false);
session.cancelColorRange();
session.beginColorRange();
// Both at generation one: the edit's id tells them apart.
session.sampleColorRange(QPointF(3, 3), false, false);
QCOMPARE(session.colorRange().value().generation, 1);
QTRY_COMPARE_WITH_TIMEOUT(watchers(session), idle, 30000);
QVERIFY(!session.colorRange().value().error);
// Nor on another document.
Opened opened;
const int quiet = watchers(opened.session);
opened.session.sampleColorRange(QPointF(5, 5), false, false);
opened.session.createDocument(40, 20);
QTRY_COMPARE_WITH_TIMEOUT(watchers(opened.session), quiet, 30000);
QVERIFY(!opened.session.selection() && opened.edit().preview.isNull());
}
void ColorRangeTests::thePreviewTruncatesAsSwifts()
{
EditorSession session;
session.createDocument(400, 300);
QImage red(400, 300, QImage::Format_RGBA8888_Premultiplied);
red.fill(QColor(200, 30, 30));
session.insert(ImportedImage(red, red, QStringLiteral("Red")));
session.beginColorRange();
session.sampleColorRange(QPointF(5, 5), false, false);
QTRY_VERIFY(!session.colorRange().value().preview.isNull());
QCOMPARE(session.colorRange().value().preview.size(), QSize(533, 400));
}
QTEST_MAIN(ColorRangeTests)
#include "ColorRangeTests.moc"