#include "DialogDesk.h" #include "IO/ProjectStore.h" #include "UI/ProjectWorkspaceView.h" #include #include #include #include #include #include #include // The window: tabs, the toolbar, closing and quitting. namespace { template Widget &find(QWidget &root, const char *name) { Widget *found = root.findChild(QString::fromLatin1(name)); if (!found) throw std::runtime_error(std::string("no widget named ") + name); return *found; } QAction &action(QWidget &root, const char *name) { QAction *found = root.findChild(QString::fromLatin1(name)); if (!found) throw std::runtime_error(std::string("no action named ") + name); return *found; } QStringList titles(const ProjectTabStrip &strip) { QStringList result; for (const ProjectTabButton *button : strip.buttons()) result << button->findChild("selectTab")->text(); return result; } void store(const QString &path, int layers) { EditorSession session; session.createDocument(8, 6); for (int index = 0; index < layers; ++index) session.addBlankLayer(); ProjectStore::save(session.projectSnapshot().value(), path); } } class ProjectWorkspaceViewTests : public QObject { Q_OBJECT private slots: void testCreateCanvasAndNavigation(); void theFrontTabHasTheEditorAndTheWindow(); void tabsShowTheirTitlesDotsAndState(); void tabButtonsSelectAndClose(); void theToolbarFollowsTheFrontSession(); void closingTheWindowAsksThroughTheWorkspace(); void aReplacedTabOutlivesItsEditor(); void filesAtLaunchGoToTheWorkspace(); }; void ProjectWorkspaceViewTests::testCreateCanvasAndNavigation() { ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); QVERIFY(QTest::qWaitForWindowExposed(&window)); auto &width = find(window, "widthInput"); width.setFocus(); width.selectAll(); QTest::keyClicks(&width, "0"); QVERIFY(!find(window, "createCanvas").isEnabled()); width.selectAll(); QTest::keyClicks(&width, "1200"); auto &height = find(window, "heightInput"); height.setFocus(); height.selectAll(); QTest::keyClicks(&height, "800"); QTest::mouseClick(&find(window, "createCanvas"), Qt::LeftButton); QCOMPARE(find(window, "canvasDimensions").text(), QString("1,200 × 800 px")); workspace.current().session.zoom(0.5); action(window, "actualPixels").trigger(); QCOMPARE(find(window, "zoomStatus").text(), QString("100%")); action(window, "zoomIn").trigger(); QCOMPARE(find(window, "zoomStatus").text(), QString("125%")); action(window, "fitCanvas").trigger(); QVERIFY(workspace.current().session.viewport.followsFit()); QCoreApplication::processEvents(); QVERIFY(window.grab().save(QCoreApplication::applicationDirPath() + QStringLiteral("/EditorFoundation.png"))); // New canvas opens a tab unasked; the canvas stays behind. action(window, "newCanvasToolbar").trigger(); QCOMPARE(int(workspace.tabs().size()), 2); QVERIFY(find(window, "widthInput").isVisible()); workspace.select(workspace.tabs()[0]->id); QCOMPARE(find(window, "canvasDimensions").text(), QString("1,200 × 800 px")); } void ProjectWorkspaceViewTests::theFrontTabHasTheEditorAndTheWindow() { ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); ProjectTab &first = workspace.current(); QVERIFY(workspace.window == &window && first.controller.window == &window); QVERIFY(window.content() && window.centralWidget() == window.content()); window.content()->setProperty("first", true); // A second tab in front: a fresh editor for it. first.session.createDocument(8, 8); workspace.newCanvas(); ProjectTab &second = workspace.current(); QVERIFY(!window.content()->property("first").toBool() && window.centralWidget() == window.content()); QVERIFY(second.controller.window == &window); QVERIFY(find(window, "widthInput").isVisible()); // Back to the first: its canvas, not the welcome. workspace.select(first.id); QCOMPARE(find(window, "canvasDimensions").text(), QString("8 × 8 px")); QVERIFY(window.findChildren("widthInput").isEmpty()); // Managing dims the editor; the tabs stay live. QVERIFY(window.content()->isEnabled()); DialogDesk desk; desk.note = [&] { return window.content()->isEnabled() ? QStringLiteral("enabled") : QStringLiteral("dimmed"); }; desk.replies = {""}; bool done = false; workspace.open(std::nullopt, [&](bool) { done = true; }); QTRY_VERIFY(done); QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||dimmed"})); QVERIFY(window.content()->isEnabled()); } void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState() { QTemporaryDir folder; store(folder.filePath("Trip.comp"), 1); ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); ProjectTabStrip &strip = *window.tabs(); QCOMPARE(strip.height(), 34); QCOMPARE(strip.accessibleName(), QString("Project tabs")); // The strip paints no stripe over the toolbar. QVERIFY(QTest::qWaitForWindowExposed(&window)); const QImage bar = window.grab().toImage(); const QPoint inStrip = strip.mapTo(&window, QPoint(strip.width() - 4, 3)), beside = strip.mapTo(&window, QPoint(strip.width() + 8, 3)); QCOMPARE(bar.pixelColor(inStrip), bar.pixelColor(beside)); QCOMPARE(titles(strip), (QStringList{"Untitled"})); workspace.current().session.createDocument(8, 8); // Unsaved work shows as a dot before the title. QCOMPARE(titles(strip), (QStringList{"● Untitled"})); bool done = false; workspace.open(folder.filePath("Trip.comp"), [&](bool) { done = true; }); QTRY_VERIFY(done); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip"})); const QList buttons = strip.buttons(); QCOMPARE(buttons.size(), 2); QVERIFY(!buttons[0]->isActive() && buttons[1]->isActive()); // Swift's capsule: 28 high, title 35 to 155, cross 16. QCOMPARE(buttons[0]->height(), 28); QCOMPARE(buttons[0]->sizeHint().height(), 28); QCOMPARE(buttons[0]->findChild("closeTab")->size(), QSize(16, 28)); // Titles stop at 155 and start at 35. QToolButton &title = *buttons[0]->findChild("selectTab"); workspace.tabs()[0]->session.setProjectPath(QString("/x/") + QString(80, QLatin1Char('W')) + ".comp"); QTRY_COMPARE(title.width(), 155); QCOMPARE(buttons[0]->height(), 28); workspace.tabs()[0]->session.setProjectPath(QString("/x/I.comp")); QTRY_COMPARE(title.width(), 35); workspace.tabs()[0]->session.setProjectPath(std::nullopt); QCOMPARE(buttons[1]->findChild("selectTab")->font().weight(), QFont::DemiBold); QCOMPARE(buttons[0]->findChild("selectTab")->font().weight(), QFont::Medium); QCOMPARE(buttons[1]->findChild("closeTab")->toolTip(), QString("Close Trip")); QCOMPARE(buttons[1]->findChild("closeTab")->accessibleName(), QString("Close Trip")); // The front tab is painted brighter, at once. const QImage front = buttons[1]->grab().toImage(), behind = buttons[0]->grab().toImage(); QVERIFY(front.pixelColor(3, 14) != behind.pixelColor(3, 14)); workspace.select(workspace.tabs()[0]->id); QCOMPARE(buttons[0]->grab().toImage().pixelColor(3, 14), front.pixelColor(3, 14)); QCOMPARE(buttons[1]->grab().toImage().pixelColor(3, 14), behind.pixelColor(3, 14)); workspace.select(workspace.tabs()[1]->id); // A dot in a tab behind shows there at once. workspace.tabs()[0]->session.history.markSaved(); workspace.tabs()[0]->session.notify(); QCOMPARE(titles(strip), (QStringList{"Untitled", "Trip"})); workspace.tabs()[0]->session.addBlankLayer(); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip"})); // A busy front holds every button but the front's own. workspace.current().session.setIsProjectBusy(true); QVERIFY(!buttons[0]->findChild("selectTab")->isEnabled()); QVERIFY(buttons[1]->findChild("selectTab")->isEnabled()); QVERIFY(!buttons[0]->findChild("closeTab")->isEnabled() && !buttons[1]->findChild("closeTab")->isEnabled()); workspace.current().session.setIsProjectBusy(false); QVERIFY(buttons[0]->findChild("selectTab")->isEnabled() && buttons[1]->findChild("closeTab")->isEnabled()); // Buttons stay: the same widget for the same tab. buttons[0]->setProperty("kept", true); workspace.addTab(false); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Trip", "Untitled 2"})); QVERIFY(strip.buttons()[0]->property("kept").toBool()); workspace.removeTab(workspace.tabs()[1]->id); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2"})); QVERIFY(strip.buttons()[0]->property("kept").toBool()); QCOMPARE(strip.buttons().size(), 2); // A tab made behind shows its title without a nudge. ProjectTab &late = workspace.addTab(false); late.session.setProjectPath(QString("/x/Late.comp")); workspace.select(workspace.tabs()[0]->id); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"})); // More tabs than fit: the front one scrolls into view. QVERIFY(QTest::qWaitForWindowExposed(&window)); for (int index = 0; index < 8; ++index) workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index)); const ProjectTabButton *last = strip.buttons().last(); const auto wholeInView = [&](const ProjectTabButton *button) { return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size())); }; // In view at once, and still after the layout settles. QVERIFY(last->tab->id == workspace.selectedID() && wholeInView(last)); QVERIFY(strip.widget()->width() > strip.viewport()->width()); QVERIFY(last->findChild("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0); QCoreApplication::processEvents(); QVERIFY(wholeInView(last)); // The front title grows: the scroll follows the layout. workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp"); QCoreApplication::processEvents(); QCoreApplication::processEvents(); QVERIFY(last->width() > 180 && wholeInView(last)); workspace.select(workspace.tabs()[0]->id); const ProjectTabButton *head = strip.buttons().first(); QVERIFY(wholeInView(head)); QCoreApplication::processEvents(); QVERIFY(wholeInView(head)); } void ProjectWorkspaceViewTests::tabButtonsSelectAndClose() { ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); QVERIFY(QTest::qWaitForWindowExposed(&window)); const QUuid first = workspace.current().id; workspace.current().session.createDocument(8, 8); workspace.newCanvas(); const QUuid second = workspace.current().id; const QList buttons = window.tabs()->buttons(); QTest::mouseClick(buttons[0]->findChild("selectTab"), Qt::LeftButton); QCOMPARE(workspace.selectedID(), first); QTest::mouseClick(buttons[1]->findChild("selectTab"), Qt::LeftButton); QCOMPARE(workspace.selectedID(), second); // Closing the first asks; Don't Save removes it. DialogDesk desk; desk.replies = {"Don’t Save"}; QTest::mouseClick(buttons[0]->findChild("closeTab"), Qt::LeftButton); QTRY_COMPARE(int(workspace.tabs().size()), 1); QCOMPARE(workspace.selectedID(), second); QCOMPARE(titles(*window.tabs()), (QStringList{"Untitled 2"})); } void ProjectWorkspaceViewTests::theToolbarFollowsTheFrontSession() { ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); QAction &fit = action(window, "fitCanvas"), &actual = action(window, "actualPixels"); QAction &in = action(window, "zoomIn"), &out = action(window, "zoomOut"), &fresh = action(window, "newCanvasToolbar"); QCOMPARE(fit.toolTip(), QString("Fit canvas in window (Ctrl+0)")); QCOMPARE(actual.toolTip(), QString("Actual pixels (Ctrl+1)")); QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl++)")); QCOMPARE(out.toolTip(), QString("Zoom out (Ctrl+−)")); QCOMPARE(fresh.toolTip(), QString("New canvas (Ctrl+N) · Drop images here for new tabs")); // No canvas: the zooms wait; New canvas is ready. QVERIFY(!fit.isEnabled() && !actual.isEnabled() && !in.isEnabled() && !out.isEnabled() && fresh.isEnabled()); EditorSession &session = workspace.current().session; session.createDocument(400, 300); QVERIFY(fit.isEnabled() && actual.isEnabled() && in.isEnabled() && out.isEnabled()); actual.trigger(); out.trigger(); QCOMPARE(session.viewport.zoom(), 0.8); in.trigger(); in.trigger(); QCOMPARE(session.viewport.zoom(), 1.25); // Importing or a busy spell holds New canvas. session.setIsImporting(true); QVERIFY(!fresh.isEnabled()); session.setIsImporting(false); session.setIsProjectBusy(true); QVERIFY(fresh.isEnabled()); QTRY_VERIFY(!fresh.isEnabled()); session.setIsProjectBusy(false); QVERIFY(fresh.isEnabled()); // The window carries the project's state for the frame. QVERIFY(window.isWindowModified()); QCOMPARE(window.windowTitle(), QString("Untitled[*]")); session.setProjectPath(QString("/somewhere/Trip.comp")); QCOMPARE(window.windowFilePath(), QString("/somewhere/Trip.comp")); QCOMPARE(window.windowTitle(), QString("Trip[*]")); session.history.markSaved(); session.notify(); QVERIFY(!window.isWindowModified()); // Another tab in front: the toolbar follows that session. workspace.newCanvas(); QVERIFY(!fit.isEnabled() && !window.isWindowModified()); QCOMPARE(window.windowFilePath(), QString()); QCOMPARE(window.windowTitle(), QString("Untitled 2[*]")); // At its smallest the window still shows the whole editor. window.resize(800, 520); QTRY_COMPARE(window.size(), QSize(800, 520)); QWidget &status = find(window, "statusBar"); QTRY_VERIFY(window.rect().contains(status.mapTo(&window, status.rect().bottomRight()))); } void ProjectWorkspaceViewTests::closingTheWindowAsksThroughTheWorkspace() { ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); QVERIFY(QTest::qWaitForWindowExposed(&window)); workspace.current().session.createDocument(8, 8); DialogDesk desk; desk.replies = {"Cancel", "Don’t Save"}; // Cancel keeps the window and its tab. window.close(); QTRY_COMPARE(desk.seen.size(), 1); QTRY_VERIFY(!workspace.isManaging()); QVERIFY(window.isVisible()); QVERIFY(workspace.current().session.document().has_value()); // Don't Save closes it and leaves one fresh tab behind. window.close(); QTRY_VERIFY(!window.isVisible()); QCOMPARE(int(workspace.tabs().size()), 1); QVERIFY(!workspace.current().session.document().has_value()); // Nothing unsaved: no question at all. window.show(); window.close(); QTRY_VERIFY(!window.isVisible()); QCOMPARE(desk.seen.size(), 2); // Shown again with new work, it asks again. window.show(); workspace.current().session.createDocument(8, 8); desk.replies = {"Cancel"}; window.close(); QTRY_COMPARE(desk.seen.size(), 3); QTRY_VERIFY(!workspace.isManaging()); QVERIFY(window.isVisible()); } void ProjectWorkspaceViewTests::aReplacedTabOutlivesItsEditor() { QTemporaryDir folder; store(folder.filePath("Trip.comp"), 1); ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); // The project replaces the empty tab; its editor goes first. const std::weak_ptr untitled = workspace.tab(workspace.current().id); std::optional aliveAtTheEnd; connect(window.content(), &QObject::destroyed, this, [&] { aliveAtTheEnd = !untitled.expired(); }); bool opened = false; workspace.open(folder.filePath("Trip.comp"), [&](bool done) { opened = done; }); QTRY_VERIFY(opened); QVERIFY(aliveAtTheEnd.value()); QVERIFY(untitled.expired()); QCOMPARE(int(workspace.tabs().size()), 1); QCOMPARE(find(window, "canvasDimensions").text(), QString("8 × 6 px")); } void ProjectWorkspaceViewTests::filesAtLaunchGoToTheWorkspace() { QTemporaryDir folder; store(folder.filePath("Trip.comp"), 2); QImage picture(4, 2, QImage::Format_RGBA8888); picture.fill(Qt::green); QVERIFY(picture.save(folder.filePath("Picture.png"))); // The real app opens its arguments; the log says so. QProcess app; app.setProcessChannelMode(QProcess::MergedChannels); // A Qt built for journald logs there unless told otherwise. QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert("QT_FORCE_STDERR_LOGGING", "1"); app.setProcessEnvironment(environment); app.start(QCoreApplication::applicationDirPath() + "/omaphoto", {folder.filePath("Trip.comp"), folder.filePath("Picture.png")}); QVERIFY(app.waitForStarted(10'000)); QByteArray log; QTRY_VERIFY2_WITH_TIMEOUT((log += app.readAll()).contains("imported a request of 1 files"), log.constData(), 30'000); app.kill(); app.waitForFinished(10'000); const qsizetype opened = log.indexOf("installed a project of 2 layers from " + folder.filePath("Trip.comp").toUtf8()); const qsizetype imported = log.indexOf("imported " + folder.filePath("Picture.png").toUtf8() + " 4 x 2"); // Both arrived, the project first, as they were named. QVERIFY2(opened >= 0 && imported > opened, log.constData()); QVERIFY2(log.contains("OmaPhoto " OMAPHOTO_VERSION " on Qt"), log.constData()); } QTEST_MAIN(ProjectWorkspaceViewTests) #include "ProjectWorkspaceViewTests.moc"