#include "ContentView.h" #include "PSDFixture.h" #include #include #include #include #include #include // A Photoshop file through the session's import and the sheet. namespace { PSDRecord layer(const QString &name, QRectF bounds, const char *blend = "norm") { PSDRecord record = PSDFixture::record(name, PSDFixture::colorImage(int(bounds.width()), int(bounds.height()), 0, 0, 1), bounds); record.blendKey = blend; return record; } QUrl write(const QTemporaryDir &folder, const QString &name, const QByteArray &bytes) { QFile file(folder.filePath(name)); if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) throw std::runtime_error("could not write a fixture"); return QUrl::fromLocalFile(file.fileName()); } QUrl photoshop(const QTemporaryDir &folder, const QString &name, const std::vector &layers, QSize size = QSize(4, 2), double dpi = 72) { return write(folder, name, PSDFixture::data(PSDDocument{size.width(), size.height(), dpi, layers}, PSDFixture::colorImage(size.width(), size.height(), 0, 0, 0, 0))); } QStringList names(const EditorSession &session) { QStringList listed; for (const ImageLayer &each : session.document().value().layers) listed << each.name; return listed; } void import(EditorSession &session, const QList &urls, std::optional point = std::nullopt) { bool done = false; session.importImages(urls, point, [&done] { done = true; }); QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000)); } PSDImport blanks(size_t count) { PSDImport imported{8, 8, 72, {}, {}}; for (size_t index = 0; index < count; ++index) imported.layers.push_back(ImageLayer(QStringLiteral("Blank"), QSizeF(8, 8))); return imported; } std::optional refusal(const std::function &run) { try { run(); } catch (const ImageImportError &error) { return error.kind; } return std::nullopt; } } class PSDImportTests : public QObject { Q_OBJECT private slots: void importCreatesDocumentAndExistingCanvasGetsAGroup(); void cancelledConversionLeavesTheDocumentUnchanged(); void aConfirmedConversionImportsAndCancelWhileReadingStops(); void failuresAreReportedAndStepsNamedByTheFiles(); void aFileLandsAtThePointInsideTheActiveFolder(); void layerLimitsAndDeepFoldersRefuseTheFile(); void theSheetReadsThenListsAndAnswers(); void aFileTheSessionRefusesIsReported(); void aDirectInsertIsOneStepAndFitsTheView(); void theSheetKeepsItsLayoutAndItsContent(); }; void PSDImportTests::importCreatesDocumentAndExistingCanvasGetsAGroup() { const QTemporaryDir folder; const QUrl url = photoshop(folder, "Trip.psd", {layer("Sky", QRectF(0, 0, 4, 2))}, QSize(4, 2), 144); EditorSession session; QSignalSpy changed(&session, &EditorSession::changed); import(session, {url}); QCOMPARE(session.document().value().size(), QSizeF(4, 2)); QCOMPARE(session.document().value().resolution, 144.0); QCOMPARE(names(session), QStringList{"Sky"}); QVERIFY(!session.importError() && !session.showsConversionSheet() && !session.conversionRequest()); QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[0].id)); QCOMPARE(session.history.undoName(), QString("Import Photoshop File")); QVERIFY(changed.count() > 0); import(session, {url}); const std::vector &layers = session.document().value().layers; QCOMPARE(names(session), (QStringList{"Sky", "Trip", "Sky"})); QVERIFY(layers[1].isGroup && !layers[1].parentID); QCOMPARE(layers[1].size(), QSizeF(4, 2)); QCOMPARE(layers[2].parentID, std::optional(layers[1].id)); QCOMPARE(session.activeLayerID(), std::optional(layers[1].id)); session.undo(); QCOMPARE(names(session), QStringList{"Sky"}); } void PSDImportTests::cancelledConversionLeavesTheDocumentUnchanged() { const QTemporaryDir folder; const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2)); EditorSession session; bool done = false; session.importImages({url}, std::nullopt, [&done] { done = true; }); QVERIFY(session.showsConversionSheet() && session.conversionRequest().value().isReading); QTRY_VERIFY(!session.conversionRequest().value().isReading); const PSDConversionRequest &request = session.conversionRequest().value(); QCOMPARE(request.title, QString("Open “Vivid.psd”?")); QCOMPARE(request.confirmTitle, QString("Import")); QCOMPARE(request.conversions.size(), size_t(1)); QCOMPARE(request.conversions[0].layerName, QString("Vivid")); QVERIFY(session.isImporting() && !done); session.finishConversion(false); QVERIFY(!session.showsConversionSheet() && !session.conversionRequest()); QTRY_VERIFY(done); QVERIFY(!session.document() && !session.isImporting() && !session.importError()); } void PSDImportTests::aConfirmedConversionImportsAndCancelWhileReadingStops() { const QTemporaryDir folder; const QUrl url = photoshop(folder, "Vivid.psd", {layer("Vivid", QRectF(0, 0, 2, 2), "vLit")}, QSize(2, 2)); EditorSession session; bool done = false; session.importImages({url}, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(session.conversionRequest() && !session.conversionRequest().value().isReading); session.finishConversion(true); QTRY_VERIFY(done); QCOMPARE(names(session), QStringList{"Vivid"}); QVERIFY(session.document().value().layers[0].blendMode == LayerBlendMode::normal); // Cancel while reading: nothing lands, nothing is said. EditorSession stopped; done = false; stopped.importImages({url}, std::nullopt, [&done] { done = true; }); QVERIFY(stopped.conversionRequest().value().isReading); stopped.finishConversion(false); QVERIFY(!stopped.showsConversionSheet()); QTRY_VERIFY(done); QVERIFY(!stopped.document() && !stopped.importError() && !stopped.showsConversionSheet()); // Nothing to convert: the sheet goes unasked. const QUrl plain = photoshop(folder, "Plain.psd", {layer("Plain", QRectF(0, 0, 4, 2))}); done = false; stopped.importImages({plain}, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(done); QCOMPARE(names(stopped), QStringList{"Plain"}); } void PSDImportTests::failuresAreReportedAndStepsNamedByTheFiles() { const QTemporaryDir folder; const QUrl broken = write(folder, "Broken.psd", PSDFixture::data(PSDDocument{4, 2, 72, {}}, PSDFixture::colorImage(4, 2, 0, 0, 0)).left(40)); EditorSession session; import(session, {broken}); QCOMPARE(session.importError(), std::optional("Broken.psd: The Photoshop file could not be read. It may be damaged or incomplete.")); QVERIFY(!session.document() && !session.showsConversionSheet()); session.setImportError(std::nullopt); // A request of other files keeps the images' step name. QImage png(3, 3, QImage::Format_RGBA8888); png.fill(Qt::red); QVERIFY(png.save(folder.filePath("Red.png"))); const QUrl url = photoshop(folder, "Sky.psd", {layer("Sky", QRectF(0, 0, 4, 2))}); import(session, {QUrl::fromLocalFile(folder.filePath("Red.png")), url}); QCOMPARE(session.history.undoName(), QString("Import Images")); QCOMPARE(names(session), (QStringList{"Red", "Sky", "Sky"})); } void PSDImportTests::aFileLandsAtThePointInsideTheActiveFolder() { const QTemporaryDir folder; const QUrl url = photoshop(folder, "Pair.psd", {layer("Left", QRectF(0, 0, 2, 2)), layer("Right", QRectF(6, 4, 2, 2))}, QSize(8, 8)); EditorSession session; session.createDocument(100, 100); session.addGroup(); const QUuid active = session.activeLayerID().value(); session.toggleGroupExpansion(active); QVERIFY(session.collapsedGroupIDs().contains(active)); import(session, {url}, QPointF(50, 50)); const std::vector &layers = session.document().value().layers; QCOMPARE(names(session), (QStringList{"Folder 1", "Pair", "Left", "Right"})); QCOMPARE(layers[1].parentID, std::optional(active)); QCOMPARE(layers[2].parentID, std::optional(layers[1].id)); // The pixels, (0, 0) to (8, 6), centre there. QCOMPARE(layers[2].origin(), QPointF(46, 47)); QCOMPARE(layers[3].origin(), QPointF(52, 51)); QVERIFY(!session.collapsedGroupIDs().contains(active) && !session.collapsedGroupIDs().contains(layers[1].id)); QCOMPARE(session.activeLayerID(), std::optional(layers[1].id)); // Beside an active layer inside a folder, within that folder. session.selectLayer(layers[2].id); import(session, {url}); const std::vector &again = session.document().value().layers; QCOMPARE(again[again.size() - 3].name, QString("Pair")); QCOMPARE(again[again.size() - 3].parentID, std::optional(layers[1].id)); } void PSDImportTests::layerLimitsAndDeepFoldersRefuseTheFile() { EditorSession session; QCOMPARE(refusal([&] { session.insertPhotoshop(blanks(10'001), "Many"); }), std::optional(ImageImportError::Kind::tooLarge)); session.insertPhotoshop(blanks(10'000), "Many"); QCOMPARE(session.document().value().layers.size(), size_t(10'000)); QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers.back().id)); EditorSession open; open.createDocument(8, 8); QCOMPARE(refusal([&] { open.insertPhotoshop(blanks(10'000), "Many"); }), std::optional(ImageImportError::Kind::tooLarge)); open.insertPhotoshop(blanks(9'999), "Many"); QCOMPARE(open.document().value().layers.size(), size_t(10'000)); // Folders nested past the hierarchy's depth refuse the whole file. PSDImport deep = blanks(0); std::optional parent; for (int depth = 0; depth < 65; ++depth) { ImageLayer folder(QStringLiteral("Folder"), QSizeF(8, 8)); folder.isGroup = true; folder.parentID = parent; parent = folder.id; deep.layers.push_back(folder); } EditorSession refused; try { refused.insertPhotoshop(deep, "Deep"); QFAIL("a hierarchy past its depth was taken"); } catch (const PSDError &error) { QVERIFY(error.kind == PSDError::Kind::truncated); } QVERIFY(!refused.document()); deep.layers.pop_back(); refused.insertPhotoshop(deep, "Deep"); QCOMPARE(refused.document().value().layers.size(), size_t(64)); QCOMPARE(refused.activeLayerID(), std::optional(deep.layers.front().id)); } void PSDImportTests::theSheetReadsThenListsAndAnswers() { EditorSession session; ContentView view(session); view.resize(900, 600); view.show(); QVERIFY(QTest::qWaitForWindowExposed(&view)); session.beginPSDReading("Open “Trip.psd”?", "Import"); QDialog *sheet = view.findChild("conversionSheet"); QVERIFY(sheet && sheet->isVisible() && sheet->windowModality() == Qt::WindowModal); QCOMPARE(sheet->windowTitle(), QString("Open “Trip.psd”?")); QVERIFY(sheet->findChild("conversionSpinner")); QVERIFY(!sheet->findChild("conversionConfirm")->isEnabled()); QCOMPARE(sheet->findChild("conversionConfirm")->text(), QString("Import")); QStringList words; for (const QLabel *label : sheet->findChildren()) words << label->text(); QVERIFY(words.contains("Reading the file to see what needs converting.") && words.contains("Reading the Photoshop file…")); // The list replaces the reading row; Return answers yes. std::optional answer; session.finishPSDReading({PSDConversion{.layerName = "Vivid", .message = "Blend mode “vLit” isn’t supported."}}, [&answer](bool yes) { answer = yes; }); QCOMPARE(view.findChild("conversionSheet"), sheet); QTRY_VERIFY(!sheet->findChild("conversionSpinner")); QScrollArea *list = sheet->findChild("conversionList"); QVERIFY(list); words.clear(); for (const QLabel *label : list->findChildren()) words << label->text(); QCOMPARE(words, (QStringList{"Vivid", "Blend mode “vLit” isn’t supported."})); QVERIFY(sheet->findChild("conversionConfirm")->isEnabled()); QTest::keyClick(sheet, Qt::Key_Return); QTRY_COMPARE(answer, std::optional(true)); QVERIFY(!session.showsConversionSheet()); QTRY_VERIFY(!view.findChild("conversionSheet")); // Escape and Cancel answer no. for (const bool escape : {true, false}) { answer = std::nullopt; session.beginPSDReading("Open “Trip.psd”?", "Import"); session.finishPSDReading({PSDConversion{.layerName = "Words", .message = "Text"}}, [&answer](bool yes) { answer = yes; }); QDialog *shown = view.findChild("conversionSheet"); QTRY_VERIFY(shown && shown->findChild("conversionList")); if (escape) QTest::keyClick(shown, Qt::Key_Escape); else shown->findChild("conversionCancel")->click(); QTRY_COMPARE(answer, std::optional(false)); QTRY_VERIFY(!view.findChild("conversionSheet")); } // Taken away by the session: the sheet goes, answering nothing. session.beginPSDReading("Open “Trip.psd”?", "Import"); QVERIFY(view.findChild("conversionSheet")); session.endPSDReading(); QTRY_VERIFY(!view.findChild("conversionSheet")); QVERIFY(!session.showsConversionSheet()); } void PSDImportTests::aFileTheSessionRefusesIsReported() { const QTemporaryDir folder; const QUrl url = photoshop(folder, "Sky.psd", {layer("Sky", QRectF(0, 0, 4, 2))}); EditorSession session; session.insertPhotoshop(blanks(9'999), "Many"); import(session, {url}); QCOMPARE(session.importError(), std::optional("Sky.psd: This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.")); QCOMPARE(session.document().value().layers.size(), size_t(9'999)); } void PSDImportTests::aDirectInsertIsOneStepAndFitsTheView() { EditorSession session; session.viewport.viewSize = QSizeF(400, 200); PSDImport imported = blanks(2); imported.resolution = 300; session.insertPhotoshop(imported, "Blanks"); CanvasViewport fitted; fitted.viewSize = QSizeF(400, 200); fitted.fit(QSizeF(8, 8)); QVERIFY(session.viewport == fitted); QCOMPARE(session.document().value().resolution, 300.0); QCOMPARE(session.history.undoName(), QString("Import Photoshop File")); session.undo(); QVERIFY(!session.document()); session.redo(); QCOMPARE(names(session), (QStringList{"Blank", "Blank"})); } void PSDImportTests::theSheetKeepsItsLayoutAndItsContent() { EditorSession session; session.createDocument(8, 8); ContentView view(session); view.resize(900, 600); view.show(); QVERIFY(QTest::qWaitForWindowExposed(&view)); session.beginPSDReading("Open “Trip.psd”?", "Import"); QDialog *sheet = view.findChild("conversionSheet"); QVERIFY(sheet); PSDConversionSheet *reading = sheet->findChild(); QTRY_VERIFY(reading && reading->isVisible()); QCOMPARE(reading->minimumSize(), QSize(520, 360)); QVERIFY(sheet->width() >= 520 && sheet->height() >= 360); QCOMPARE(reading->layout()->contentsMargins(), QMargins(24, 24, 24, 24)); QCOMPARE(reading->layout()->spacing(), 16); const QProgressBar *spinner = sheet->findChild("conversionSpinner"); QVERIFY(spinner->minimum() == 0 && spinner->maximum() == 0 && !spinner->isTextVisible() && spinner->width() == 16); QCOMPARE(spinner->parentWidget()->minimumHeight(), 180); // Unrelated changes keep the content; the spinner is centred. session.selectTool(NavigationTool::hand); QCOMPARE(sheet->findChildren(), QList{reading}); const QRect row = spinner->parentWidget()->rect(); const QLabel *words = spinner->parentWidget()->findChild(); QCOMPARE(words->geometry().left() - spinner->geometry().right() - 1, 10); QVERIFY(std::abs(spinner->geometry().left() - (row.width() - 1 - words->geometry().right())) <= 1); // A title as text, not markup, bold at 17 pixels. std::optional answer; session.finishPSDReading({PSDConversion{.layerName = "Bold", .message = "A note that runs long enough to wrap over the sheet's width, " "which the list keeps whole instead of cutting it off at the edge."}}, [&answer](bool yes) { answer = yes; }); // The reading content hides at once, before it is deleted. QVERIFY(!reading->isVisible()); PSDConversionSheet *listed = nullptr; QTRY_VERIFY((listed = [&] { for (PSDConversionSheet *each : sheet->findChildren()) if (each->isVisible()) return each; return static_cast(nullptr); }()) != nullptr && listed != reading); int visible = 0; for (PSDConversionSheet *each : sheet->findChildren()) visible += each->isVisible(); QCOMPARE(visible, 1); const QList labels = listed->findChildren(); const QLabel *title = labels.value(0); QCOMPARE(title->text(), QString("Open “Trip.psd”?")); QCOMPARE(title->font().pixelSize(), 17); QCOMPARE(title->font().weight(), QFont::Bold); QCOMPARE(labels.value(1)->foregroundRole(), QPalette::PlaceholderText); const QScrollArea *list = listed->findChild("conversionList"); QVERIFY(list->widgetResizable() && list->minimumHeight() == 180 && list->widget()); const QLabel *name = list->widget()->findChildren().value(0), *note = list->widget()->findChildren().value(1); QCOMPARE(name->textFormat(), Qt::PlainText); QVERIFY(name->wordWrap() && note->wordWrap()); QCOMPARE(name->font().weight(), QFont::Bold); QCOMPARE(note->font().pixelSize(), 13); // Rows stack in the list, the name above its note. QCOMPARE(note->geometry().top() - name->geometry().bottom() - 1, 4); QCOMPARE(name->geometry().top(), list->widget()->layout()->contentsMargins().top() + 4); QCOMPARE(name->geometry().left(), list->widget()->layout()->contentsMargins().left() + 8); QVERIFY(note->height() > note->fontMetrics().height()); // Rows keep the height their words need, no more. QCOMPARE(note->height(), note->heightForWidth(note->width())); // Cancel stands left of confirm, both at the right. const QPushButton *cancel = listed->findChild("conversionCancel"), *confirm = listed->findChild("conversionConfirm"); QVERIFY(!cancel->autoDefault() && confirm->isDefault()); QVERIFY(cancel->geometry().right() < confirm->geometry().left()); QCOMPARE(confirm->geometry().right(), listed->width() - 25); QVERIFY(cancel->geometry().left() > listed->width() / 2); session.finishConversion(false); QTRY_COMPARE(answer, std::optional(false)); } QTEST_MAIN(PSDImportTests) #include "PSDImportTests.moc"