diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp index c9bb0a1..1f939eb 100644 --- a/src/Rendering/EditorCanvas.cpp +++ b/src/Rendering/EditorCanvas.cpp @@ -430,6 +430,10 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; }; live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); }; live.adjustmentScale = scale; + const QPointF corner = center(QPointF(0, 0)); + live.adjustmentRegion = [corner, scale](const QRectF &rect) { + return QRectF((rect.left() - corner.x()) / scale, (rect.top() - corner.y()) / scale, rect.width() / scale, rect.height() / scale); + }; live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) { const ImageLayer &layer = byID.at(id); if (!layer.mask || !layer.mask->isEnabled) diff --git a/src/Rendering/LiveMaskRenderer.cpp b/src/Rendering/LiveMaskRenderer.cpp index 9bfff0f..f735ca3 100644 --- a/src/Rendering/LiveMaskRenderer.cpp +++ b/src/Rendering/LiveMaskRenderer.cpp @@ -208,8 +208,9 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip) qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size(); return; } - // The document the device shows, where Grain's pattern sits. - const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect())); + // What the device shows; Grain and Noise keep the document. + const QRectF shown = context.deviceTransform().inverted().mapRect(QRectF(device->rect())); + const QRectF region = adjustmentRegion ? adjustmentRegion(shown) : shown; try { // The kernels' byte order, one image pixel a device pixel. QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy(); @@ -217,7 +218,7 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip) throw ExportError(ExportError::Kind::render); original.setDevicePixelRatio(1); // Device pixels a document pixel, where Swift's surface counts points. - QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width()); + QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / shown.width()); const auto found = m_modes.find(id); const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal; if (mode != LayerBlendMode::normal) diff --git a/src/Rendering/LiveMaskRenderer.h b/src/Rendering/LiveMaskRenderer.h index 6bbafc8..312a348 100644 --- a/src/Rendering/LiveMaskRenderer.h +++ b/src/Rendering/LiveMaskRenderer.h @@ -31,6 +31,8 @@ public: std::function adjustmentClip = [](QUuid, const QPainter &, QImage &) {}; // Painter units a document pixel: the canvas's zoom. double adjustmentScale = 1; + // The document a painter region shows: the canvas maps it. + std::function adjustmentRegion; private: // The pixels beneath, adjusted and mixed back through coverage. diff --git a/tests/AdjustmentCanvasTests.cpp b/tests/AdjustmentCanvasTests.cpp new file mode 100644 index 0000000..420edc9 --- /dev/null +++ b/tests/AdjustmentCanvasTests.cpp @@ -0,0 +1,123 @@ +#include "CanvasContractFixtures.h" + +// Compositor 1.4's GPU canvas contracts on the CPU canvas: adjustments. +namespace { +// A canvas over a photo and a smaller, translucent layer. +struct Scene : ContractScene { + Scene(int width, int height, int canvasWidth, int canvasHeight) : ContractScene(width, height, canvasWidth, canvasHeight) + { + const QImage photo = noise(width, height, 3); + session.insert(ImportedImage(photo, photo, QStringLiteral("Photo"))); + const QImage top = noise(width / 2, height / 2, 5, 160); + session.insert(ImportedImage(top, top, QStringLiteral("Top"))); + } + // An adjustment layer of this kind on top, at 80%. + QUuid adjust(const LayerAdjustment &adjustment) + { + session.addAdjustment(adjustment.kind); + session.setAdjustmentEditingID(std::nullopt); + const QUuid id = session.activeLayerID().value(); + session.updateAdjustment(id, adjustment); + session.setLayerOpacity(0.8); + return id; + } +}; + +LayerAdjustment visible(AdjustmentKind kind) +{ + LayerAdjustment adjustment{kind}; + switch (kind) { + case AdjustmentKind::hsv: adjustment.hsvSettings = HueSaturationSettings(120, 30); break; + case AdjustmentKind::levels: adjustment.levels.ranges[0].outputWhite = 160; break; + case AdjustmentKind::curves: adjustment.curves.channels[0] = {{0, 20}, {120, 170}, {255, 235}}; break; + case AdjustmentKind::exposure: adjustment.setExposure(ExposureSettings{.exposure = 0.8, .gamma = 1.2}); break; + case AdjustmentKind::colorBalance: adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 40}); break; + case AdjustmentKind::gaussianBlur: adjustment.setGaussianRadius(6); break; + case AdjustmentKind::motionBlur: + adjustment.setResolvedMotionDistance(30); + adjustment.setResolvedMotionAngle(30); + break; + case AdjustmentKind::addNoise: + adjustment.setResolvedNoiseAmount(25); + adjustment.setResolvedNoiseSeed(7); + break; + case AdjustmentKind::grain: adjustment.setGrain(GrainSettings{.amount = 60, .size = 3, .seed = 11}); break; + default: break; + } + return adjustment; +} + +} + +class AdjustmentCanvasTests : public QObject { + Q_OBJECT +private slots: + void everyAdjustmentShowsAsTheExport_data(); + void everyAdjustmentShowsAsTheExport(); + void patternsStayWithTheDocument_data(); + void patternsStayWithTheDocument(); +}; + +void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport_data() +{ + QTest::addColumn("kind"); + for (const AdjustmentKind kind : allAdjustmentKinds) + QTest::newRow(qPrintable(rawValue(kind))) << kind; +} + +// Swift's matchesEveryAdjustment: a mask, 80%, one to one. +void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport() +{ + QFETCH(AdjustmentKind, kind); + Scene scene(60, 50, 60, 50); + const QImage before = scene.exported(); + const QUuid id = scene.adjust(visible(kind)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, rampMask(60, 50)); }); + scene.session.zoom(1); + QVERIFY(scene.exported() != before); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport()))); +} + +void AdjustmentCanvasTests::patternsStayWithTheDocument_data() +{ + QTest::addColumn("kind"); + QTest::newRow("addNoise") << AdjustmentKind::addNoise; + QTest::newRow("grain") << AdjustmentKind::grain; +} + +// Swift's patternsStayWithTheDocument: wherever it sits, however zoomed. +void AdjustmentCanvasTests::patternsStayWithTheDocument() +{ + QFETCH(AdjustmentKind, kind); + Scene scene(60, 50, 140, 120); + scene.adjust(visible(kind)); + // One to one, and the crisp path's document pixels. + for (const double zoom : {1.0, 2.0}) { + scene.session.zoom(zoom); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1: %2").arg(zoom).arg(scene.gapToExport()))); + scene.session.viewport.translate(QSizeF(3, -2)); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1 panned: %2").arg(zoom).arg(scene.gapToExport()))); + } + // Reduced: a whole-pixel pan moves the pattern with the image. + scene.session.zoom(0.8); + const QImage before = scene.shot(); + const QPoint corner = scene.origin().toPoint(); + scene.session.viewport.translate(QSizeF(5, 3)); + const QImage after = scene.shot(); + QCOMPARE(scene.origin().toPoint(), corner + QPoint(5, 3)); + int worst = 0; + for (int y = corner.y() + 2; y < corner.y() + 36; ++y) { + for (int x = corner.x() + 2; x < corner.x() + 44; ++x) { + const QRgb was = before.pixel(x, y), now = after.pixel(x + 5, y + 3); + worst = std::max({worst, std::abs(qRed(was) - qRed(now)), std::abs(qGreen(was) - qGreen(now)), std::abs(qBlue(was) - qBlue(now))}); + } + } + QVERIFY2(worst <= 1, qPrintable(QString::number(worst))); + // A partial repaint shows the whole's pattern, resampled alike. + const QRect part(corner + QPoint(18, 12), QSize(20, 15)); + const QImage piece = scene.canvas.grab(part).toImage().convertToFormat(QImage::Format_ARGB32); + QVERIFY(ContractScene::gap(piece, after.copy(part)) <= 2); +} + +QTEST_MAIN(AdjustmentCanvasTests) +#include "AdjustmentCanvasTests.moc" diff --git a/tests/CanvasContractFixtures.h b/tests/CanvasContractFixtures.h new file mode 100644 index 0000000..ec128dc --- /dev/null +++ b/tests/CanvasContractFixtures.h @@ -0,0 +1,89 @@ +#pragma once +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include +#include +#include +#include + +// Compositor 1.4's GPU canvas tests, held against the CPU canvas. +struct ContractScene { + EditorSession session; + CanvasView canvas{session}; + ContractScene(int width, int height, int canvasWidth, int canvasHeight) + { + session.createDocument(width, height); + session.setShowsTransformControls(false); + canvas.resize(canvasWidth, canvasHeight); + session.viewport.resize(QSizeF(canvasWidth, canvasHeight), 1, QSizeF(width, height)); + } + QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); } + QPointF origin() { return session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); } + QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); } + // What the canvas shows at each document pixel's centre. + QImage documentShot() + { + canvas.synchronizeDisplay(); + const QImage view = shot(); + const QSizeF size = session.document().value().size(); + const double zoom = session.viewport.pointsPerPixel(); + QImage result(size.toSize(), QImage::Format_ARGB32); + for (int y = 0; y < result.height(); ++y) { + for (int x = 0; x < result.width(); ++x) + result.setPixel(x, y, view.pixel(int(origin().x() + (x + 0.5) * zoom), int(origin().y() + (y + 0.5) * zoom))); + } + return result; + } + // The export over the checkerboard, as the canvas shows it. + QImage exportedOnCanvas() + { + const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image; + const double zoom = session.viewport.pointsPerPixel(); + QImage result(exported.size(), QImage::Format_ARGB32_Premultiplied); + for (int y = 0; y < result.height(); ++y) { + for (int x = 0; x < result.width(); ++x) { + const bool light = (int(std::floor((x + 0.5) * zoom / 10)) + int(std::floor((y + 0.5) * zoom / 10))) % 2 == 0; + result.setPixel(x, y, QColor::fromRgbF(light ? 0.35 : 0.30, light ? 0.35 : 0.30, light ? 0.35 : 0.30).rgb()); + } + } + QPainter painter(&result); + painter.drawImage(QRectF(result.rect()), exported); + painter.end(); + return result.convertToFormat(QImage::Format_ARGB32); + } + int gapToExport(const QPainterPath &skip = QPainterPath()) { return gap(documentShot(), exportedOnCanvas(), skip); } + // The worst channel gap inside the border, `skip` aside. + static int gap(const QImage &one, const QImage &other, const QPainterPath &skip = QPainterPath()) + { + int worst = 0; + for (int y = 1; y < one.height() - 1; ++y) { + for (int x = 1; x < one.width() - 1; ++x) { + if (skip.contains(QPointF(x + 0.5, y + 0.5))) + continue; + const QRgb a = one.pixel(x, y), b = other.pixel(x, y); + worst = std::max({worst, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))}); + } + } + return worst; + } + // A band round a path, where overlays draw. + static QPainterPath band(const QPainterPath &path, double width = 6) + { + QPainterPathStroker stroker; + stroker.setWidth(width); + return stroker.createStroke(path); + } +}; + +// A mask dark on the left, light on the right. +inline ImportedImage rampMask(int width, int height) +{ + QImage mask(width, height, QImage::Format_Grayscale8); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + mask.scanLine(y)[x] = uchar(x * 255 / (width - 1)); + } + return LayerMask::assetFrom(mask); +} diff --git a/tests/CanvasDisplayTests.cpp b/tests/CanvasDisplayTests.cpp index cd237af..2812799 100644 --- a/tests/CanvasDisplayTests.cpp +++ b/tests/CanvasDisplayTests.cpp @@ -83,7 +83,6 @@ private slots: void theLiveCompositeDrawsWhatTheExportDraws(); void adjustmentsShowAsTheExportDoes(); void anAdjustmentsMaskClipsItOnTheCanvas(); - void grainFollowsTheViewOnTheCanvas(); }; void CanvasDisplayTests::theBackgroundCheckerboardShadowAndBorderSurroundTheDocument() @@ -411,32 +410,5 @@ void CanvasDisplayTests::anAdjustmentsMaskClipsItOnTheCanvas() session.cancelBrush(); } -void CanvasDisplayTests::grainFollowsTheViewOnTheCanvas() -{ - // Swift's canvas hands Grain the view's region, not the document's. - Scene scene(40, 40, 120, 120); - EditorSession &session = scene.session; - session.insert(filled(40, 40, qRgba(128, 128, 128, 255), "Gray")); - session.addAdjustment(AdjustmentKind::grain); - session.setAdjustmentEditingID(std::nullopt); - LayerAdjustment grain = session.activeLayer().value().adjustment.value(); - GrainSettings strong = grain.grain(); - strong.amount = 100; - // Pinned: under some seeds the pan keeps one pixel's tone. - strong.seed = 7; - grain.setGrain(strong); - session.updateAdjustment(session.activeLayerID().value(), grain); - session.zoom(1); - QVERIFY(gapToExport(scene) > 20); - // A pan moves the pattern under the same document pixel. - const auto pixel = [&scene] { - const QPointF origin = scene.origin(); - return scene.shot().pixel(int(origin.x() + 20.5), int(origin.y() + 20.5)); - }; - const QRgb before = pixel(); - session.viewport.translate(QSizeF(3, 0)); - QVERIFY(pixel() != before); -} - QTEST_MAIN(CanvasDisplayTests) #include "CanvasDisplayTests.moc" diff --git a/tests/CanvasEditContractTests.cpp b/tests/CanvasEditContractTests.cpp new file mode 100644 index 0000000..23540b0 --- /dev/null +++ b/tests/CanvasEditContractTests.cpp @@ -0,0 +1,177 @@ +#include "CanvasContractFixtures.h" + +// Compositor 1.4's GPU canvas contracts on the CPU canvas: edits. +namespace { +QUuid insertNoise(ContractScene &scene, int width, int height, quint32 seed, QPointF centre, int alpha = 255) +{ + const QImage image = noise(width, height, seed, alpha); + scene.session.insert(ImportedImage(image, image, QStringLiteral("Noise")), centre); + return scene.session.activeLayerID().value(); +} + +QPainterPath ellipse(const QRectF &rect) +{ + QPainterPath path; + path.addEllipse(rect); + return path; +} + +bool finished(const std::function)> &start) +{ + bool done = false; + start([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 20000); +} +} + +class CanvasEditContractTests : public QObject { + Q_OBJECT +private slots: + void aNewMaskRevealsTheWholeLayer_data(); + void aNewMaskRevealsTheWholeLayer(); + void movingPixelsShowAsTheirCommit_data(); + void movingPixelsShowAsTheirCommit(); + void aGradientInASelectionShowsAsItsCommit(); + void aGradientThatCannotFillCancels(); + void aLiveMaskFromFurtherDownShowsAsTheExport(); +}; + +void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer_data() +{ + QTest::addColumn("rotation"); + QTest::newRow("upright") << 0.0; + QTest::newRow("turned") << 25.0; +} + +// Swift's aNewMaskRevealsTheWholeLayer: one white pixel, stretched. +void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer() +{ + QFETCH(double, rotation); + ContractScene scene(80, 70, 80, 70); + const QUuid id = insertNoise(scene, 30, 20, 2, QPointF(40, 35)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {10, 12}, .size = {38, 30}, .rotation = rotation}; + }); + scene.session.zoom(1); + scene.session.selectLayer(id); + const QImage before = scene.exported(); + scene.session.addLayerMask(); + QCOMPARE(scene.session.activeLayer().value().mask.value().asset.size(), QSize(1, 1)); + const QImage after = scene.exported(); + // Wherever the layer covered its pixel, nothing changes. + int covered = 0; + for (int y = 0; y < after.height(); ++y) { + for (int x = 0; x < after.width(); ++x) { + if (qAlpha(before.pixel(x, y)) < 255) + continue; + ++covered; + QVERIFY2(ContractScene::gap(before.copy(x - 1, y - 1, 3, 3), after.copy(x - 1, y - 1, 3, 3)) <= 1, + qPrintable(QStringLiteral("%1, %2").arg(x).arg(y))); + } + } + QVERIFY(covered > 900); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport()))); +} + +void CanvasEditContractTests::movingPixelsShowAsTheirCommit_data() +{ + QTest::addColumn("duplicate"); + QTest::newRow("moving") << false; + QTest::newRow("duplicating") << true; +} + +// Swift's matchesWhileMovingPixels, on a masked layer at 60%. +void CanvasEditContractTests::movingPixelsShowAsTheirCommit() +{ + QFETCH(bool, duplicate); + ContractScene scene(80, 60, 80, 60); + const QUuid id = insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, rampMask(80, 60)); + record(snapshot, id).opacity = 0.6; + }); + scene.session.zoom(1); + scene.session.selectLayer(id); + const QImage still = scene.documentShot(); + scene.session.applySelection(ellipse(QRectF(20, 15, 30, 24)), SelectionMode::replace, QStringLiteral("Select")); + QVERIFY(scene.session.beginPixelMove(duplicate)); + scene.session.movePixels(QSizeF(9.4, 5.6)); + const QImage moving = scene.documentShot(); + QVERIFY(finished([&](std::function done) { scene.session.finishPixelMove(std::move(done)); })); + const QImage moved = scene.documentShot(); + // The ants run round the moved outline: left out. + const QPainterPath ants = ContractScene::band(ellipse(QRectF(29, 21, 30, 24))); + QVERIFY(ContractScene::gap(still, moving, ants) > 20); + QVERIFY2(ContractScene::gap(moving, moved, ants) <= 1, qPrintable(QString::number(ContractScene::gap(moving, moved, ants)))); + QVERIFY(scene.gapToExport(ants) <= 1); + // A move leaves a hole; a duplicate leaves its pixels. + const QRgb left = scene.exported().pixel(24, 27); + QCOMPARE(qAlpha(left) == 0, !duplicate); +} + +// Swift's matchesWhileDraggingAGradient: inside a selection, at 70%. +void CanvasEditContractTests::aGradientInASelectionShowsAsItsCommit() +{ + ContractScene scene(80, 60, 80, 60); + insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + scene.session.zoom(1); + const QImage still = scene.documentShot(); + const QPainterPath outline = ellipse(QRectF(15, 10, 40, 30)); + scene.session.applySelection(outline, SelectionMode::replace, QStringLiteral("Select")); + GradientSettings settings; + settings.opacity = 0.7; + settings.style = GradientStyle::foregroundToBackground; + scene.session.setGradientSettings(settings); + scene.session.selectTool(NavigationTool::gradient); + scene.session.beginGradient(QPointF(10, 30)); + scene.session.moveGradient(std::nullopt, QPointF(70, 30)); + scene.session.endGradientDrag(); + const QImage pending = scene.documentShot(); + QVERIFY(finished([&](std::function done) { scene.session.commitGradient(std::move(done)); })); + const QImage committed = scene.documentShot(); + // The ants, the gradient's line and its ends: overlays. + QPainterPath line; + line.moveTo(10, 30); + line.lineTo(70, 30); + const QPainterPath overlays = ContractScene::band(outline).united(ContractScene::band(line, 30)); + QVERIFY(ContractScene::gap(still, pending, overlays) > 20); + QVERIFY2(ContractScene::gap(pending, committed, overlays) <= 1, qPrintable(QString::number(ContractScene::gap(pending, committed, overlays)))); + // Outside the selection the photo stays. + QCOMPARE(scene.exported().pixel(3, 3), still.pixel(3, 3)); +} + +// Swift's 5708ed3: a fill that fails cancels with the error. +void CanvasEditContractTests::aGradientThatCannotFillCancels() +{ + ContractScene scene(80, 60, 80, 60); + insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + const QImage before = scene.exported(); + const int count = scene.session.history.undoCount(); + scene.session.selectTool(NavigationTool::gradient); + scene.session.beginGradient(QPointF(10, 30)); + QVERIFY(scene.session.gradientEdit()); + scene.session.gradientEdit()->raster->pixelLimit = 100; + scene.session.moveGradient(std::nullopt, QPointF(70, 30)); + QVERIFY(!scene.session.gradientEdit() && scene.session.brushError().has_value()); + QCOMPARE(scene.session.history.undoCount(), count); + QCOMPARE(scene.exported(), before); +} + +// Swift's matchesALiveMaskOutsideAClippingStack: a turned, distant source. +void CanvasEditContractTests::aLiveMaskFromFurtherDownShowsAsTheExport() +{ + ContractScene scene(80, 60, 80, 60); + insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + const QUuid source = insertNoise(scene, 40, 30, 4, QPointF(35, 30), 140); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).transform.rotation = 25; }); + insertNoise(scene, 16, 16, 5, QPointF(60, 20)); + const QUuid target = insertNoise(scene, 50, 40, 9, QPointF(40, 30)); + const QImage unlinked = scene.exported(); + QVERIFY(scene.session.linkMask(source, target)); + scene.session.zoom(1); + QVERIFY(ContractScene::gap(unlinked, scene.exported()) > 20); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport()))); +} + +QTEST_MAIN(CanvasEditContractTests) +#include "CanvasEditContractTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 2f8288a..e7a3d6b 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -266,6 +266,9 @@ compositor_test(PSDAdjustmentTests) compositor_test(SharpCanvasTests) compositor_test(AdjustmentBandsTests) compositor_test(LayerOrderTests) +compositor_test(BlendingTests) +compositor_test(AdjustmentCanvasTests) +compositor_test(CanvasEditContractTests) # It checks the bands against one pass of the kernels. target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(RawImporterTests)