Open Recent, print sizes through an invalid resolution, Color Grading under Color (12.14b)

This commit is contained in:
雷电芽衣 committed 2026-09-30 01:43:23 -04:00
1 parent 94b884a2ef
commit 2183348aa6
21 files changed
+717 -38

No files matched your search

+73 -2
View File
@@ -104,6 +104,7 @@ private slots:
void aTypedNumberAppliesAndIsHeldInRange();
void altOnALightSliderShowsItsClippingUntilLetGo();
void eachGroupOpensAndItsEyeHidesItFromThePreview();
void eachEyeHidesItsOwnGroup();
void theScopeSwitchesAndItsTrianglesShowClipping();
void theReadoutNamesThePixel();
void whiteBalanceAutoCustomAndTheEyedropper();
@@ -214,11 +215,18 @@ void CameraRawPanelTests::altOnALightSliderShowsItsClippingUntilLetGo()
void CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview()
{
Panel panel;
const QStringList groups{"Light", "Color", "Effects", "Curve", "ColorMixer", "ColorGrading", "Detail", "Optics", "Geometry", "Calibration"};
// Swift's 7e9afbe: Color Grading under Color, open.
const QStringList groups{"Light", "Color", "ColorGrading", "Effects", "Curve", "ColorMixer", "Detail", "Optics", "Geometry", "Calibration"};
int above = -1;
for (const QString &group : groups) {
const int top = panel.child<QToolButton>(group + QStringLiteral("Section")).mapTo(panel.controls.get(), QPoint()).y();
QVERIFY2(top > above, qPrintable(group));
above = top;
}
for (const QString &group : groups) {
auto &disclosure = panel.child<QToolButton>(group + QStringLiteral("Section"));
auto *body = disclosure.parentWidget()->findChildren<QWidget *>(QString(), Qt::FindDirectChildrenOnly).last();
const bool open = group == QLatin1String("Light") || group == QLatin1String("Color");
const bool open = group == QLatin1String("Light") || group == QLatin1String("Color") || group == QLatin1String("ColorGrading");
QCOMPARE(body->isVisible(), open);
disclosure.click();
QCOMPARE(body->isVisible(), !open);
@@ -238,6 +246,69 @@ void CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview()
QVERIFY(panel.panel().shows.light);
}
namespace {
// Undoes the eye test's one adjustment to that group.
void reset(CameraRawSettings &raw, const QString &eye)
{
if (eye == QLatin1String("ColorGradingEye"))
raw.grading.shadows.saturation = 0;
else if (eye == QLatin1String("EffectsEye"))
raw.clarity = 0;
else if (eye == QLatin1String("CurveEye"))
raw.curve.shadows = 0;
else
raw.mixer.hue[0] = 0;
}
}
void CameraRawPanelTests::eachEyeHidesItsOwnGroup()
{
Panel panel;
FilterSettings adjusted = panel.session.filterEdit().value().settings;
adjusted.cameraRaw.grading.shadows.saturation = 20;
adjusted.cameraRaw.clarity = 30;
adjusted.cameraRaw.curve.shadows = 20;
adjusted.cameraRaw.mixer.hue[0] = 20;
panel.session.updateFilter(adjusted, true);
using Adjusts = bool (*)(const CameraRawSettings &);
const std::array<std::tuple<QString, bool CameraRawGroups::*, Adjusts>, 4> eyes{{
{QStringLiteral("ColorGradingEye"), &CameraRawGroups::grading, [](const CameraRawSettings &raw) { return raw.grading.adjusts(); }},
{QStringLiteral("EffectsEye"), &CameraRawGroups::effects, [](const CameraRawSettings &raw) { return raw.adjustsEffects(); }},
{QStringLiteral("CurveEye"), &CameraRawGroups::curve, [](const CameraRawSettings &raw) { return raw.curve.adjusts(); }},
{QStringLiteral("ColorMixerEye"), &CameraRawGroups::mixer, [](const CameraRawSettings &raw) { return raw.mixer.adjusts(); }},
}};
// Alone, each adjustment shows its own eye only.
for (const auto &[name, group, adjusts] : eyes) {
FilterSettings alone = adjusted;
for (const auto &[other, otherGroup, otherAdjusts] : eyes) {
if (other != name)
reset(alone.cameraRaw, other);
}
panel.session.updateFilter(alone, true);
for (const auto &[other, otherGroup, otherAdjusts] : eyes)
QVERIFY2(panel.child<QToolButton>(other).isVisible() == (other == name), qPrintable(name + " " + other));
reset(alone.cameraRaw, name);
panel.session.updateFilter(alone, true);
QVERIFY2(!panel.child<QToolButton>(name).isVisible(), qPrintable(name));
}
panel.session.updateFilter(adjusted, true);
for (const auto &[name, group, adjusts] : eyes) {
auto &eye = panel.child<QToolButton>(name);
QVERIFY2(eye.isVisible(), qPrintable(name));
eye.click();
// That group alone leaves the preview.
CameraRawGroups expected;
expected.*group = false;
QVERIFY2(panel.panel().shows == expected, qPrintable(name));
const CameraRawSettings previewed = panel.session.filterEdit().value().previewJob().settings.cameraRaw;
for (const auto &[other, otherGroup, otherAdjusts] : eyes)
QVERIFY2(otherAdjusts(previewed) == (other != name), qPrintable(name + " " + other));
eye.click();
QVERIFY2(panel.panel().shows == CameraRawGroups(), qPrintable(name));
QVERIFY(adjusts(panel.session.filterEdit().value().previewJob().settings.cameraRaw));
}
}
void CameraRawPanelTests::theScopeSwitchesAndItsTrianglesShowClipping()
{
Panel panel;
+17 -6
View File
@@ -50,7 +50,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
for (const QMenu *menu : menus)
titles << menu->title();
QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"}));
QCOMPARE(menus[0]->actions().size(), 11);
QCOMPARE(menus[0]->actions().size(), 12);
QCOMPARE(menus[1]->actions().size(), 13);
QCOMPARE(menus[2]->actions().size(), 16);
QCOMPARE(menus[3]->actions().size(), 10);
@@ -76,7 +76,8 @@ void CompositorMenusTests::fileEntriesFollowTheControllersGate()
{
QTemporaryDir folder;
Bar bar;
QVERIFY(bar.action("newCanvas").isEnabled() && bar.action("openProject").isEnabled() && bar.action("closeProject").isEnabled());
QVERIFY(bar.action("newCanvas").isEnabled() && bar.action("openProject").isEnabled() && bar.action("openRecent").isEnabled()
&& bar.action("closeProject").isEnabled());
QVERIFY(bar.action("importImages").isEnabled());
// Nothing to save without a canvas.
QVERIFY(!bar.action("save").isEnabled() && !bar.action("saveAs").isEnabled());
@@ -84,26 +85,30 @@ void CompositorMenusTests::fileEntriesFollowTheControllersGate()
QVERIFY(bar.action("save").isEnabled() && bar.action("saveAs").isEnabled());
// Busy holds the file entries; a sheet holds Import.
bar.session().setIsProjectBusy(true);
QVERIFY(!bar.action("newCanvas").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("save").isEnabled()
QVERIFY(!bar.action("newCanvas").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("openRecent").isEnabled()
&& !bar.action("save").isEnabled()
&& !bar.action("saveAs").isEnabled() && !bar.action("closeProject").isEnabled());
QVERIFY(bar.action("importImages").isEnabled());
QTRY_VERIFY(!bar.action("importImages").isEnabled());
bar.session().setIsProjectBusy(false);
bar.session().setShowsNewDocument(true);
QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("newCanvas").isEnabled());
QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("newCanvas").isEnabled() && !bar.action("openRecent").isEnabled());
bar.session().setShowsNewDocument(false);
QVERIFY(bar.action("openRecent").isEnabled());
// Importing alone holds Import as well.
bar.session().setIsImporting(true);
QVERIFY(!bar.action("importImages").isEnabled());
QVERIFY(!bar.action("importImages").isEnabled() && !bar.action("openProject").isEnabled() && !bar.action("openRecent").isEnabled());
bar.session().setIsImporting(false);
QVERIFY(bar.action("importImages").isEnabled());
QVERIFY(bar.action("importImages").isEnabled() && bar.action("openRecent").isEnabled());
// The entries do what the toolbar and the controller do.
bar.action("newCanvas").trigger();
QCOMPARE(int(bar.workspace.tabs().size()), 2);
bar.workspace.select(bar.workspace.tabs()[0]->id);
bar.action("importImages").trigger();
QVERIFY(bar.session().showsImporter());
QVERIFY(!bar.action("openRecent").isEnabled());
bar.session().setShowsImporter(false);
QVERIFY(bar.action("openRecent").isEnabled());
DialogDesk desk;
desk.replies = {folder.filePath("Saved.comp")};
bar.action("save").trigger();
@@ -119,8 +124,14 @@ void CompositorMenusTests::fileEntriesFollowTheControllersGate()
QTRY_COMPARE(bar.session().projectPath(), std::optional(folder.filePath("Copy.comp")));
QCOMPARE(desk.seen.size(), 2);
desk.replies = {"<cancel>"};
// The workspace's panel holds Open Recent until answered.
desk.note = [&] { return QStringLiteral("managing=%1 recent=%2").arg(bar.workspace.isManaging()).arg(bar.action("openRecent").isEnabled()); };
bar.action("openProject").trigger();
QTRY_COMPARE(desk.seen.size(), 3);
QVERIFY2(desk.seen[2].endsWith("|managing=1 recent=0"), qPrintable(desk.seen[2]));
desk.note = nullptr;
QTRY_VERIFY(!bar.workspace.isManaging());
QVERIFY(bar.action("openRecent").isEnabled());
// Close Project closes this tab, not the window.
bar.window.show();
bar.action("closeProject").trigger();
+103 -5
View File
@@ -16,6 +16,7 @@ private slots:
void theSheetShowsTheCanvas();
void pixelsFollowUnitsAndTheLock();
void aNewResolutionKeepsThePrintSize();
void aDocumentsOwnResolutionIsTheLastUsable();
void withoutResamplingOnlyTheResolutionMoves();
void anInvalidSizeRestsResize();
void returnWithoutTypingKeepsTheNumbers();
@@ -24,11 +25,18 @@ private slots:
};
namespace {
CanvasDocument document(int width, int height, double resolution)
{
CanvasDocument made{width, height};
made.resolution = resolution;
return made;
}
struct Sheet {
std::optional<std::optional<ImageSizeOptions>> answer;
ImageSizeSheet sheet;
explicit Sheet(int width = 1200, int height = 800)
: sheet(CanvasDocument{width, height}, [this](std::optional<ImageSizeOptions> options) { answer = options; })
explicit Sheet(int width = 1200, int height = 800, double resolution = 72)
: sheet(document(width, height, resolution), [this](std::optional<ImageSizeOptions> options) { answer = options; })
{
sheet.show();
}
@@ -152,16 +160,99 @@ void ImageSizeSheetTests::aNewResolutionKeepsThePrintSize()
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
shown.find<QPushButton>("imageResize").click();
QCOMPARE(shown.answer.value().value().resolution, 300.0);
// Zero changes nothing but the resolution, which is refused.
// Swift's 555417e: zero and below keep 8 by 5.333 inches.
shown.unit("Inches");
QCOMPARE(shown.shown("imageWidth"), QString("8"));
shown.type("imageResolution", "0");
QCOMPARE(shown.result(), QStringLiteral("Use 1–30,000 pixels per side, up to %1 megapixels, and 1–9,600 pixels/inch.").arg(DocumentLimits::maxSurfaceMegapixels()));
// A size typed meanwhile is ignored.
shown.type("imageWidth", "2");
shown.type("imageResolution", "-5");
shown.type("imageHeight", "3");
shown.type("imageResolution", "150");
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels"));
QCOMPARE(shown.shown("imageWidth"), QString("8"));
// Centimetres keep the print size alike.
shown.unit("Centimeters");
shown.type("imageResolution", "0");
shown.type("imageResolution", "300");
QCOMPARE(shown.result(), QString("Result: 4,800 × 3,200 pixels"));
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
}
void ImageSizeSheetTests::aDocumentsOwnResolutionIsTheLastUsable()
{
for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) {
Sheet shown(1200, 800, 300);
shown.unit(unit);
shown.type("imageResolution", "0");
shown.type("imageWidth", "1");
shown.type("imageResolution", "-5");
shown.type("imageResolution", "150");
QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels"));
}
// Pixels and Percent need no resolution: those edits apply.
for (const auto &[unit, width] : {std::pair(QStringLiteral("Pixels"), QStringLiteral("600")), std::pair(QStringLiteral("Percent"), QStringLiteral("50"))}) {
Sheet shown(1200, 800, 300);
shown.unit(unit);
shown.type("imageResolution", "0");
shown.type("imageWidth", width);
shown.type("imageResolution", "150");
QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels"));
}
// Infinity is no usable resolution either.
for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) {
Sheet shown(1200, 800, 300);
shown.unit(unit);
shown.type("imageResolution", "inf");
QVERIFY(!shown.find<QPushButton>("imageResize").isEnabled());
shown.type("imageWidth", "1");
shown.type("imageResolution", "150");
QCOMPARE(shown.result(), QString("Result: 600 × 400 pixels"));
shown.find<QPushButton>("imageResize").click();
const ImageSizeOptions options = shown.answer.value().value();
QVERIFY(options.width == 600 && options.height == 400 && options.resolution == 150);
}
// Without resampling, print sizes wait for a usable resolution too.
for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) {
for (const QString &invalid : {QStringLiteral("0"), QStringLiteral("-5"), QStringLiteral("inf")}) {
Sheet held(1200, 800, 300);
held.unit(unit);
held.find<QCheckBox>("imageResample").click();
held.type("imageResolution", invalid);
const QString shownResolution = held.shown("imageResolution");
held.type("imageWidth", "1");
held.type("imageHeight", "2");
QCOMPARE(held.shown("imageResolution"), shownResolution);
QVERIFY(!held.find<QPushButton>("imageResize").isEnabled());
held.type("imageResolution", "150");
QCOMPARE(held.result(), QString("Result: 1,200 × 800 pixels"));
held.find<QCheckBox>("imageResample").click();
held.type("imageResolution", "300");
QCOMPARE(held.result(), QString("Result: 2,400 × 1,600 pixels"));
}
}
// Any finite positive resolution counts, even past Resize's range.
for (const QString &unit : {QStringLiteral("Inches"), QStringLiteral("Centimeters")}) {
Sheet low(12, 8, 300);
low.type("imageResolution", "0.5");
low.unit(unit);
low.type("imageResolution", "150");
QCOMPARE(low.result(), QString("Result: 3,600 × 2,400 pixels"));
Sheet high(300, 200, 300);
high.type("imageResolution", "12000");
high.unit(unit);
high.type("imageResolution", "6000");
QCOMPARE(high.result(), QString("Result: 150 × 100 pixels"));
}
// Without resampling the pixels stay, across the toggle.
Sheet shown(1200, 800, 300);
shown.type("imageResolution", "0");
shown.find<QCheckBox>("imageResample").click();
shown.type("imageResolution", "150");
QCOMPARE(shown.result(), QString("Result: 1,200 × 800 pixels"));
shown.find<QCheckBox>("imageResample").click();
shown.type("imageResolution", "300");
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
}
void ImageSizeSheetTests::withoutResamplingOnlyTheResolutionMoves()
@@ -195,6 +286,13 @@ void ImageSizeSheetTests::withoutResamplingOnlyTheResolutionMoves()
QCOMPARE(shown.units(), (QStringList{"Pixels", "Percent", "Inches", "Centimeters"}));
QCOMPARE(shown.find<QComboBox>("imageUnits").currentText(), QString("Centimeters"));
QVERIFY(shown.find<QCheckBox>("imageLocked").isEnabled());
// A print-size edit updates the last usable resolution.
shown.find<QCheckBox>("imageResample").click();
shown.type("imageHeight", "25.4");
QCOMPARE(shown.shown("imageResolution"), QString("80"));
shown.find<QCheckBox>("imageResample").click();
shown.type("imageResolution", "160");
QCOMPARE(shown.result(), QString("Result: 2,400 × 1,600 pixels"));
}
void ImageSizeSheetTests::anInvalidSizeRestsResize()
+5
View File
@@ -4,6 +4,7 @@
#include <QApplication>
#include <QDir>
#include <QLabel>
#include <QMessageBox>
#include <QStyle>
#include <QStyleFactory>
#include <QTemporaryDir>
@@ -202,6 +203,10 @@ void OmarchyThemeTests::withoutAThemeTheBuiltInDarkApplies()
QCOMPARE(QApplication::palette().color(QPalette::PlaceholderText), QColor(0x8a, 0x8a, 0x8a));
QCOMPARE(QApplication::palette().color(QPalette::BrightText), QColor(0xff, 0x9f, 0x0a));
QCOMPARE(QApplication::style()->objectName(), QString("fusion"));
// Swift's d62a132: alerts are dark too.
QMessageBox alert(QMessageBox::Warning, QStringLiteral("Title"), QStringLiteral("Text"));
QCOMPARE(alert.palette().color(QPalette::Window), QColor(0x24, 0x24, 0x24));
QCOMPARE(alert.style()->objectName(), QString("fusion"));
Swatch swatch;
QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24));
// A theme appearing later is taken up, unnamed or not.
+273
View File
@@ -0,0 +1,273 @@
#include "ExternalChangeFixtures.h"
#include "IO/RecentProjects.h"
#include "MenuFixtures.h"
#include <QApplication>
#include <QLabel>
#include <QMenu>
#include <QSettings>
#include <QStandardPaths>
// Swift's 73dc0f4: File > Open Recent.
namespace {
QString canonical(const QString &path)
{
return QFileInfo(path).canonicalFilePath();
}
// A saved project named `name` in `root`.
QString project(const QTemporaryDir &root, const QString &name)
{
const QString path = root.filePath(name + QStringLiteral(".comp"));
if (!QDir().rename(savedProject(root), path))
throw std::runtime_error("could not name " + path.toStdString());
return path;
}
QStringList titles(QMenu &menu)
{
QStringList shown;
for (const QAction *entry : menu.actions())
shown << (entry->isSeparator() ? QStringLiteral("-") : entry->text() + (entry->isEnabled() ? "" : " (off)"));
return shown;
}
}
class OpenRecentTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void init();
void opensAndSavesAreNotedNewestFirst();
void theListKeepsTenAndOneEntryAPlace();
void movedProjectsLeaveTheList();
void theMenuListsOpensAndClears();
void aProjectDeletedWhileListedIsNamed();
void comingBackToTheAppChecksAgain();
void onlyWhatLandsIsNoted();
void aReloadAfterSavingNotesItsPlaceLast();
private:
QTemporaryDir m_stored;
};
void OpenRecentTests::initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
// Starts from what earlier runs stored, missing places dropped.
QVERIFY(m_stored.isValid());
QSettings().setValue(QStringLiteral("recentProjects"), QStringList{m_stored.filePath("Missing.comp"), m_stored.path()});
QCOMPARE(RecentProjects::shared().paths(), QStringList{m_stored.path()});
}
void OpenRecentTests::init()
{
RecentProjects::shared().clear();
}
void OpenRecentTests::opensAndSavesAreNotedNewestFirst()
{
QTemporaryDir root;
const QString opened = project(root, QStringLiteral("Opened"));
EditorSession session;
ProjectController controller(session);
QVERIFY(answered([&](auto done) { controller.open(opened + QStringLiteral("/"), done); }));
QCOMPARE(RecentProjects::shared().paths(), QStringList{canonical(opened)});
// Saved elsewhere: the copy comes first.
const QString copy = root.filePath(QStringLiteral("Copy.comp"));
DialogDesk desk;
desk.replies = {copy};
QVERIFY(answered([&](auto done) { controller.save(true, done); }));
QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(copy), canonical(opened)}));
// Opened again: back to the front, listed once.
QVERIFY(answered([&](auto done) { controller.open(opened, done); }));
QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(opened), canonical(copy)}));
// A failed open notes nothing.
QDir(root.filePath("Broken.comp")).mkpath(".");
desk.replies = {"OK"};
QVERIFY(!answered([&](auto done) { controller.open(root.filePath("Broken.comp"), done); }));
QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(opened), canonical(copy)}));
QCOMPARE(QSettings().value(QStringLiteral("recentProjects")).toStringList(), RecentProjects::shared().paths());
}
void OpenRecentTests::theListKeepsTenAndOneEntryAPlace()
{
QTemporaryDir root;
QStringList made;
for (int index = 0; index < 12; ++index) {
const QString place = root.filePath(QString::number(index));
QVERIFY(QDir().mkpath(place));
made.prepend(canonical(place));
RecentProjects::shared().note(place);
}
QCOMPARE(RecentProjects::shared().paths(), made.mid(0, 10));
// A link to a listed place is that place.
QVERIFY(QFile::link(root.filePath("5"), root.filePath("link")));
RecentProjects::shared().note(root.filePath("link"));
QCOMPARE(RecentProjects::shared().paths().front(), made[6]);
QCOMPARE(RecentProjects::shared().paths().size(), 10);
}
void OpenRecentTests::movedProjectsLeaveTheList()
{
QTemporaryDir root;
const QString kept = project(root, QStringLiteral("Kept"));
const QString moved = project(root, QStringLiteral("Moved"));
RecentProjects::shared().note(kept);
RecentProjects::shared().note(moved);
int announced = 0;
connect(&RecentProjects::shared(), &RecentProjects::changed, this, [&] { ++announced; });
QVERIFY(QDir().rename(moved, root.filePath("Elsewhere.comp")));
RecentProjects::shared().refresh();
QCOMPARE(RecentProjects::shared().paths(), QStringList{canonical(kept)});
QCOMPARE(announced, 1);
// Nothing changed: no signal.
RecentProjects::shared().refresh();
QCOMPARE(announced, 1);
// Back in place, it is listed again.
QVERIFY(QDir().rename(root.filePath("Elsewhere.comp"), moved));
RecentProjects::shared().refresh();
QCOMPARE(RecentProjects::shared().paths().size(), 2);
disconnect(&RecentProjects::shared(), nullptr, this, nullptr);
}
void OpenRecentTests::theMenuListsOpensAndClears()
{
QTemporaryDir root;
const QString first = project(root, QStringLiteral("First"));
const QString second = project(root, QStringLiteral("Second"));
Bar bar;
QMenu &recent = *bar.action("openRecent").menu();
// Swift's place: right after Open Project…, no key.
const QList<QAction *> file = bar.menus->parent()->findChildren<QMenu *>(QString(), Qt::FindDirectChildrenOnly).front()->actions();
QCOMPARE(file.indexOf(&bar.action("openRecent")), file.indexOf(&bar.action("openProject")) + 1);
QCOMPARE(file.indexOf(&bar.action("importImages")), file.indexOf(&bar.action("openRecent")) + 1);
QCOMPARE(bar.action("openRecent").text(), QString("Open Recent"));
QVERIFY(bar.action("openRecent").shortcut().isEmpty());
QCOMPARE(titles(recent), (QStringList{"-", "Clear Menu (off)"}));
RecentProjects::shared().note(first);
RecentProjects::shared().note(second);
QCOMPARE(titles(recent), (QStringList{"Second", "First", "-", "Clear Menu"}));
// An entry opens its project in a tab.
recent.actions()[1]->trigger();
QTRY_VERIFY(bar.session().projectPath().has_value());
QCOMPARE(bar.session().projectPath().value(), canonical(first));
QCOMPARE(titles(recent), (QStringList{"First", "Second", "-", "Clear Menu"}));
// Busy holds the whole menu, as Open Project.
QVERIFY(bar.action("openRecent").isEnabled());
bar.session().setIsProjectBusy(true);
QVERIFY(!bar.action("openRecent").isEnabled());
bar.session().setIsProjectBusy(false);
QVERIFY(bar.action("openRecent").isEnabled());
bar.action("clearRecent").trigger();
QCOMPARE(titles(recent), (QStringList{"-", "Clear Menu (off)"}));
QVERIFY(RecentProjects::shared().paths().isEmpty());
// An ampersand is part of the name, no mnemonic.
RecentProjects::shared().note(project(root, QStringLiteral("R&D")));
QCOMPARE(recent.actions()[0]->text(), QString("R&&D"));
QCOMPARE(recent.actions()[0]->iconText(), QString("R&D"));
}
void OpenRecentTests::aProjectDeletedWhileListedIsNamed()
{
QTemporaryDir root;
const QString gone = project(root, QStringLiteral("<b>Gone"));
RecentProjects::shared().note(gone);
const QString listed = canonical(gone);
QVERIFY(QDir(gone).removeRecursively());
EditorSession session;
ProjectController controller(session);
DialogDesk desk;
desk.replies = {"OK"};
// The name shows literally, never as markup.
desk.note = [] {
for (QWidget *top : QApplication::topLevelWidgets()) {
if (auto *label = top->findChild<QLabel *>(QStringLiteral("qt_msgbox_informativelabel")))
return QStringLiteral("format=%1").arg(int(label->textFormat()));
}
return QStringLiteral("no label");
};
QVERIFY(!answered([&](auto done) { controller.open(listed, done); }));
QCOMPARE(desk.seen.size(), 1);
QVERIFY2(desk.seen[0].startsWith("alert|2|Couldn’t open the project|The file “<b>Gone.comp” couldn’t be opened because there is no such file.")
&& desk.seen[0].endsWith("|format=0"),
qPrintable(desk.seen[0]));
QVERIFY(RecentProjects::shared().paths().isEmpty());
QVERIFY(!session.isProjectBusy());
}
void OpenRecentTests::comingBackToTheAppChecksAgain()
{
QTemporaryDir root;
const QString moved = project(root, QStringLiteral("Moved"));
RecentProjects::shared().note(moved);
QVERIFY(QDir().rename(moved, root.filePath("Elsewhere.comp")));
// Only becoming active looks again.
emit qGuiApp->applicationStateChanged(Qt::ApplicationInactive);
QCOMPARE(RecentProjects::shared().paths().size(), 1);
emit qGuiApp->applicationStateChanged(Qt::ApplicationActive);
QVERIFY(RecentProjects::shared().paths().isEmpty());
}
void OpenRecentTests::onlyWhatLandsIsNoted()
{
QTemporaryDir root;
const QString kept = project(root, QStringLiteral("Kept")), other = project(root, QStringLiteral("Other"));
const QString incoming = project(root, QStringLiteral("Incoming"));
EditorSession session;
ProjectController controller(session);
QVERIFY(answered([&](auto done) { controller.open(kept, done); }));
RecentProjects::shared().note(other);
const QStringList before{canonical(other), canonical(kept)};
// Replacing unsaved work cancelled: the incoming project stays out.
session.addBlankLayer();
DialogDesk desk;
desk.replies = {"Cancel", "<cancel>"};
QVERIFY(!answered([&](auto done) { controller.open(incoming, done); }));
QCOMPARE(RecentProjects::shared().paths(), before);
// Save As cancelled.
QVERIFY(!answered([&](auto done) { controller.save(true, done); }));
QCOMPARE(RecentProjects::shared().paths(), before);
// A failed save over a listed place promotes nothing.
QVERIFY(QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::ExeOwner));
desk.replies = {"OK"};
QVERIFY(!answered([&](auto done) { controller.save(false, done); }));
QVERIFY(QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner));
QCOMPARE(RecentProjects::shared().paths(), before);
// A save is noted once it lands.
std::optional<bool> saved;
controller.save(false, [&](bool value) { saved = value; });
QVERIFY(controller.externalChanges.saving);
QCOMPARE(RecentProjects::shared().paths(), before);
QTRY_VERIFY(saved.has_value());
QVERIFY(saved.value());
QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(kept), canonical(other)}));
QCOMPARE(QSettings().value(QStringLiteral("recentProjects")).toStringList(), RecentProjects::shared().paths());
}
void OpenRecentTests::aReloadAfterSavingNotesItsPlaceLast()
{
QTemporaryDir root;
const QString reopened = project(root, QStringLiteral("Reopened")), other = project(root, QStringLiteral("Other"));
EditorSession session;
ProjectController controller(session);
QVERIFY(answered([&](auto done) { controller.open(reopened, done); }));
RecentProjects::shared().note(other);
// Another place noted as the save lands, before the reload.
bool armed = true;
connect(&RecentProjects::shared(), &RecentProjects::changed, this, [&] {
if (armed && RecentProjects::shared().paths().front() == canonical(reopened)) {
armed = false;
RecentProjects::shared().note(other);
}
});
session.addBlankLayer();
DialogDesk desk;
desk.replies = {"Save"};
QVERIFY(answered([&](auto done) { controller.open(reopened, done); }));
disconnect(&RecentProjects::shared(), nullptr, this, nullptr);
QVERIFY(!armed);
QCOMPARE(RecentProjects::shared().paths(), (QStringList{canonical(reopened), canonical(other)}));
}
QTEST_MAIN(OpenRecentTests)
#include "OpenRecentTests.moc"
+2 -2
View File
@@ -246,11 +246,11 @@ void ProjectControllerTests::aCompletionNeverRunsInsideTheCall()
int calls = 0;
controller.save(false, [&](bool) { calls += 1; });
controller.confirmQuit([&](bool) { calls += 1; });
controller.open(QString("/nowhere.comp"), [&](bool) { calls += 1; });
QCOMPARE(calls, 0);
DialogDesk desk;
desk.replies = {"OK"};
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+"));
controller.open(QString("/nowhere.comp"), [&](bool) { calls += 1; });
QCOMPARE(calls, 0);
QTRY_COMPARE(calls, 3);
}
+4 -1
View File
@@ -155,9 +155,12 @@ void ProjectOpenTests::aDamagedOrMissingProjectKeepsTheLiveDocument()
QCOMPARE(desk.seen, (QStringList{damaged + "|busy"}));
QCOMPARE(described(session), before);
QVERIFY(session.isModified() && !session.isProjectBusy());
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged.");
// A missing project is named.
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: The file “Nowhere.comp” couldn’t be opened because there is no such file.");
QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false));
QCOMPARE(desk.seen.size(), 2);
QVERIFY2(desk.seen[1].startsWith("alert|2|Couldn’t open the project|The file “Nowhere.comp” couldn’t be opened because there is no such file.|"),
qPrintable(desk.seen[1]));
QCOMPARE(described(session), before);
}
+78
View File
@@ -0,0 +1,78 @@
#include "IO/RecentProjects.h"
#include <QDir>
#include <QProcess>
#include <QSettings>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QtTest>
#include <cstdio>
// The list without a GUI application: no activation to follow.
class RecentProjectsCoreTests : public QObject {
Q_OBJECT
private slots:
void theListWorksWithoutAGuiApplication();
void theListOutlivesItsProcess();
};
namespace {
// One step in a fresh process: its output.
QString step(const QString &what)
{
QProcess child;
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert(QStringLiteral("RECENT_STEP"), what);
child.setProcessEnvironment(environment);
child.start(QCoreApplication::applicationFilePath(), {});
if (!child.waitForFinished(30'000) || child.exitStatus() != QProcess::NormalExit || child.exitCode() != 0)
throw std::runtime_error("the step failed: " + what.toStdString());
return QString::fromUtf8(child.readAllStandardOutput());
}
}
void RecentProjectsCoreTests::theListWorksWithoutAGuiApplication()
{
QVERIFY(!qobject_cast<QGuiApplication *>(QCoreApplication::instance()));
QTemporaryDir root;
RecentProjects::shared().clear();
RecentProjects::shared().note(root.path());
QCOMPARE(RecentProjects::shared().paths(), QStringList{QDir(root.path()).canonicalPath()});
QCOMPARE(QSettings().value(QStringLiteral("recentProjects")).toStringList(), RecentProjects::shared().paths());
RecentProjects::shared().clear();
QVERIFY(RecentProjects::shared().paths().isEmpty());
}
void RecentProjectsCoreTests::theListOutlivesItsProcess()
{
QTemporaryDir first, second;
step(QStringLiteral("clear"));
step(QStringLiteral("note:") + first.path());
step(QStringLiteral("note:") + second.path());
// A later launch reads what the earlier ones stored.
QCOMPARE(step(QStringLiteral("read")), QDir(second.path()).canonicalPath() + "\n" + QDir(first.path()).canonicalPath() + "\n");
step(QStringLiteral("clear"));
QCOMPARE(step(QStringLiteral("read")), QString());
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QStandardPaths::setTestModeEnabled(true);
// A child run does one step, then exits.
const QString what = qEnvironmentVariable("RECENT_STEP");
if (what == QLatin1String("clear")) {
RecentProjects::shared().clear();
} else if (what.startsWith(QLatin1String("note:"))) {
RecentProjects::shared().note(what.mid(5));
} else if (what == QLatin1String("read")) {
for (const QString &path : RecentProjects::shared().paths())
std::printf("%s\n", qPrintable(path));
} else if (!what.isEmpty()) {
return 2;
} else {
RecentProjectsCoreTests tests;
return QTest::qExec(&tests, argc, argv);
}
return 0;
}
#include "RecentProjectsCoreTests.moc"
+2
View File
@@ -166,6 +166,8 @@ compositor_test(ProjectReceiveTests)
compositor_test(ProjectReplaceTests)
compositor_test(ProjectWorkspaceFlowTests)
compositor_test(QuitWhileEditingTextTests)
compositor_test(OpenRecentTests)
compositor_test(RecentProjectsCoreTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
compositor_test(ProjectWorkspaceViewTests)