// SPDX-License-Identifier: MIT #include "window.h" #include #include #include #include #include #include #include "legend.h" #include "tray.h" #include #include #include #include #include "traypage.h" #include "colorbutton.h" #include class WindowTest : public QObject { Q_OBJECT private Q_SLOTS: void logicalTicks() { const auto freq = AxisTicks::covering(0, 4681, 4); QCOMPARE(freq.minimum, 0.); QCOMPARE(freq.maximum, 6000.); QCOMPARE(freq.step, 2000.); const auto rate = AxisTicks::covering(-13, 42, 4); QCOMPARE(rate.minimum, -20.); QCOMPARE(rate.maximum, 60.); QCOMPARE(rate.step, 20.); QCOMPARE(timeTickStep(3600000, 6), 600000.); QCOMPARE(timeTickStep(3600000, 3), 1800000.); QCOMPARE(ageLabel(7200000), QString("2h")); QCOMPARE(ageLabel(1200000), QString("20min")); QCOMPARE(ageLabel(5000), QString("5s")); QCOMPARE(ageLabel(0), QString("Now")); } void historyHoverAndSleep() { Chart chart("%"); chart.addSeries("battery", "Charge level"); chart.setSelected("battery", true); chart.addSeries("rate", "Battery rate", "W"); chart.setSelected("rate", true); chart.sample({{"battery", 75}, {"rate", -10}}, 30000, 100000); chart.setPowerState(false, true, 100000); QVERIFY(chart.readingAt(100000).contains("Charge level: 75 %")); QVERIFY(chart.readingAt(100000).contains("Battery rate: -10 W")); QVERIFY(chart.readingAt(100000).contains("Charger connected")); chart.setPowerState(true, true, 105000); QVERIFY(chart.readingAt(106000).contains("Asleep")); QVERIFY(chart.readingAt(106000).contains("Battery rate: —")); QVERIFY(!chart.readingAt(106000).contains("Charger connected")); chart.setPowerState(false, false, 110000); QVERIFY(chart.readingAt(111000).contains("Charge level: —")); // Don't reach back across even a short sleep. chart.sample({{"battery", 74}, {"rate", -8}}, 30000, 112000); QVERIFY(chart.readingAt(112000).contains("Charge level: 74 %")); chart.setSelected("rate", false); QVERIFY(!chart.readingAt(112000).contains("Battery rate")); QVERIFY(chart.readingAt(200000).contains("Charge level: —")); for (int i = 0; i < 601; ++i) chart.sample({}, 30000, 200000 + i * 30000); QVERIFY(!chart.readingAt(106000).contains("Asleep")); // Annotations expire with samples. } void clickableLegend() { Chart chart("MHz"); chart.addSeries("cpu"); chart.setSelected("cpu", true); Legend legend(&chart, {{"cpu", "CPU average", {}, "MHz"}}); QCOMPARE(legend.enabledCount(), 1); QSignalSpy changed(&legend, &Legend::selectionChanged); QVERIFY(QMetaObject::invokeMethod(&legend, "linkActivated", Q_ARG(QString, "0"))); QVERIFY(!chart.selected("cpu")); QCOMPARE(changed.count(), 1); QVERIFY(legend.text().contains("line-through")); QTextDocument doc; doc.setHtml(legend.text()); bool strike = false; for (auto block = doc.begin(); block.isValid(); block = block.next()) for (auto it = block.begin(); !it.atEnd(); ++it) { const auto format = it.fragment().charFormat(); QVERIFY(!format.fontUnderline()); strike |= format.fontStrikeOut(); } QVERIFY(strike); QVERIFY(QMetaObject::invokeMethod(&legend, "linkActivated", Q_ARG(QString, "0"))); QVERIFY(chart.selected("cpu")); QCOMPARE(legend.enabledCount(), 1); } void cpuUsageAndTray() { CpuUsage usage; QVERIFY(!usage.sample("cpu 10 0 10 80 0 0 0 0 10 0")); QCOMPARE(usage.sample("cpu 30 0 10 160 0 0 0 0 30 0"), std::optional(20.)); usage.reset(); QVERIFY(!usage.sample("cpu 30 0 10 160 0 0 0 0")); QVERIFY(!usage.sample("cpu 1 0 1 1 0 0 0 0")); // Counter reset. QVERIFY(!usage.sample("not available")); TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black; style.lineColor = Qt::cyan; style.fillColor = Qt::blue; style.outsideColor = Qt::red; for (bool graph : {false, true}) for (const auto &values : {QVector{}, QVector{{0, 10}, {1, 20}, {2, NAN}, {3, 30}}}) { const auto icon = telemetryIcon(graph, values, "%", style); QVERIFY(!icon.isNull()); QVERIFY(!icon.pixmap(24, 24).isNull()); } } void trayBoundsAndAppearance() { TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black; style.lineColor = Qt::green; style.fillColor = Qt::blue; style.outsideColor = Qt::red; style.fill = false; auto render = [&] { return telemetryIcon(true, {{0, 120}, {10, 120}}, "%", style).pixmap(64, 64).toImage(); }; auto image = render(); QCOMPARE(image.pixelColor(32, 1), QColor(Qt::white)); QCOMPARE(image.pixelColor(32, 3), QColor(Qt::red)); QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black)); style.clamp = false; image = render(); QCOMPARE(image.pixelColor(32, 3), QColor(Qt::black)); style.border = false; style.transparent = true; image = render(); QCOMPARE(image.pixelColor(32, 1).alpha(), 0); style.clamp = true; style.overflowColor = false; image = render(); QCOMPARE(image.pixelColor(32, 1), QColor(Qt::green)); QVERIFY(trayPlotRect(true).width() < trayPlotRect(false).width()); style.fill = true; image = telemetryIcon(true, {{0, 50}, {10, 50}}, "%", style).pixmap(64, 64).toImage(); QCOMPARE(image.pixelColor(32, 50), QColor(Qt::blue)); QCOMPARE(image.pixelColor(32, 10).alpha(), 0); } void sharedHistoryAndTrayScales() { Chart chart("MHz"); chart.addSeries("cpu"); chart.addSeries("gpu"); chart.sample({{"cpu", 1000}, {"gpu", 500}}, 1000, 1000); chart.sample({{"cpu", 1200}, {"gpu", 600}}, 1000, 2000); QCOMPARE(chart.history("gpu").size(), 2); // Even never-selected sensors already have history. chart.setSelected("cpu", true); chart.setSelected("cpu", false); QCOMPARE(chart.history("cpu").last().y(), 1200.); chart.setPowerState(true, {}, 2100); chart.setPowerState(false, {}, 2500); chart.sample({{"cpu", 1100}}, 1000, 2600); QVERIFY(std::isnan(chart.history("cpu")[2].y())); QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat); TrayPage page(settings, {{"cpu", "CPU", {}, "MHz"}, {"temp", "CPU temperature", {}, "°C"}, {"rate", "Battery rate", {}, "W"}}); const auto original = page.draft(); auto *mode = page.findChild("trayDisplayMode"); mode->setCurrentIndex(1); auto *metric = page.findChild("trayMetric"); auto *minimum = page.findChild("trayMinimum"); auto *maximum = page.findChild("trayMaximum"); maximum->setValue(4500); QCOMPARE(page.iconStyle().maximum, 4500.); metric->setCurrentIndex(1); minimum->setValue(30); maximum->setValue(90); QCOMPARE(page.iconStyle().minimum, 30.); QCOMPARE(page.iconStyle().maximum, 90.); metric->setCurrentIndex(0); QCOMPARE(page.iconStyle().minimum, 0.); QCOMPARE(page.iconStyle().maximum, 4500.); metric->setCurrentIndex(1); QCOMPARE(page.iconStyle().minimum, 30.); QCOMPARE(page.iconStyle().maximum, 90.); metric->setCurrentIndex(2); minimum->setValue(-40); maximum->setValue(65); QCOMPARE(page.iconStyle().minimum, -40.); QCOMPARE(page.iconStyle().maximum, 65.); metric->setCurrentIndex(1); metric->setCurrentIndex(2); QCOMPARE(page.iconStyle().minimum, -40.); QCOMPARE(page.iconStyle().maximum, 65.); const QString shot = qEnvironmentVariable("FRAMEWORK_TOOLS_TRAY_SCREENSHOT"); if (!shot.isEmpty()) { page.resize(700, 780); page.show(); QTest::qWait(10); QVERIFY(page.grab().save(shot)); } QVERIFY(settings.allKeys().isEmpty()); // Editing never writes persistent settings. page.load(original); QCOMPARE(mode->currentIndex(), 0); QCOMPARE(metric->currentIndex(), 0); QVERIFY(!settings.contains("tray/scales/rate/minimum")); } void chartRendering() { Chart chart("%"); chart.resize(850, 240); chart.addSeries("battery", "Battery"); chart.setSelected("battery", true); chart.addSeries("rate", "Charge / discharge", "W"); chart.setSelected("rate", true); const qint64 now = QDateTime::currentMSecsSinceEpoch(); for (int i = 0; i <= 120; ++i) { const auto t = now - 3600000 + i * 30000; if (i == 20) chart.setPowerState(true, {}, t); if (i == 40) chart.setPowerState(false, false, t); if (i == 60) chart.setPowerState(false, true, t); if (i < 20 || i >= 40) chart.sample({{"battery", 50 + i * .25}, {"rate", i < 60 ? -12. : 35.}}, 30000, t); } chart.show(); QTest::qWait(10); const QString screenshot = qEnvironmentVariable("FRAMEWORK_TOOLS_CHART_SCREENSHOT"); if (!screenshot.isEmpty()) QVERIFY(chart.grab().save(screenshot)); QPalette dark = chart.palette(); dark.setColor(QPalette::Window, QColor("#232629")); dark.setColor(QPalette::Text, QColor("#eff0f1")); dark.setColor(QPalette::Mid, QColor("#45494c")); chart.setPalette(dark); chart.setAutoFillBackground(true); if (!screenshot.isEmpty()) QVERIFY(chart.grab().save(screenshot + ".dark.png")); const auto before = chart.grab().toImage(); QTest::mouseMove(&chart, QPoint(400, 100)); QTest::qWait(10); QVERIFY(chart.grab().toImage() != before); if (!screenshot.isEmpty()) QVERIFY(chart.grab().save(screenshot + ".hover.png")); QEvent leave(QEvent::Leave); QApplication::sendEvent(&chart, &leave); chart.resize(320, 175); QTest::qWait(10); QVERIFY(!chart.grab().isNull()); } void hoverDoesNotAdvanceChart() { Chart chart("MHz"); chart.resize(850, 240); chart.addSeries("cpu"); chart.setSelected("cpu", true); const qint64 now = QDateTime::currentMSecsSinceEpoch(); chart.sample({{"cpu", 1000}}, 1000, now - 2000); chart.sample({{"cpu", 3000}}, 1000, now - 1000); chart.sample({{"cpu", 2000}}, 1000, now); chart.show(); QTest::qWait(10); QTest::mouseMove(&chart, QPoint(1, 1)); const auto before = chart.grab().toImage(); QTest::mouseMove(&chart, QPoint(400, 100)); QVERIFY(chart.hoverTime().has_value()); QVERIFY(chart.grab().toImage() != before); QTest::qWait(100); QEvent leave(QEvent::Leave); QApplication::sendEvent(&chart, &leave); QCOMPARE(chart.grab().toImage(), before); chart.sample({{"cpu", 2500}}, 1000, now + 1000); QVERIFY(chart.grab().toImage() != before); } void sliderAndNumberStaySynchronized() { ValueControl control; control.setRange(400, 4800); control.setSuffix(" MHz"); control.setSingleStep(100); auto *slider = control.findChild(); auto *number = control.findChild(); QVERIFY(slider); QVERIFY(number); QCOMPARE(slider->minimum(), 400); QCOMPARE(slider->maximum(), 4800); QCOMPARE(slider->singleStep(), 100); QSignalSpy changed(&control, &ValueControl::valueChanged); slider->setValue(4500); QCOMPARE(number->value(), 4500); QCOMPARE(control.value(), 4500); QCOMPARE(changed.count(), 1); number->setValue(1234); QCOMPARE(slider->value(), 1234); QCOMPARE(changed.count(), 2); control.setRange(1500, 3300); QCOMPARE(slider->value(), 1500); QCOMPARE(number->value(), 1500); control.setValue(5000); QCOMPARE(slider->value(), 3300); QCOMPARE(control.value(), 3300); control.setEnabled(false); QVERIFY(!slider->isEnabled()); QVERIFY(!number->isEnabled()); control.setEnabled(true); control.setRange(0, 75); control.setSuffix(" W"); control.setSpecialValueText("Firmware default"); slider->setValue(0); QCOMPARE(number->text(), QString("Firmware default")); } void pagesAndReadOnlyStartup() { QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QSettings settings("fedora-tools", "framework-laptop-tools"); settings.setValue("sampling/fast", 0); settings.setValue("sampling/battery", -5); Window window; window.show(); QTest::qWait(500); QCOMPARE(settings.value("sampling/fast").toInt(), 1000); QCOMPARE(settings.value("sampling/battery").toInt(), 30000); auto *tabs = window.findChild(); QVERIFY(tabs); QCOMPARE(tabs->count(), 7); QCOMPARE(tabs->tabText(1), QString("Lighting")); QCOMPARE(tabs->tabText(2), QString("Cooling")); QCOMPARE(tabs->tabText(3), QString("Battery")); QCOMPARE(tabs->tabText(4), QString("CPU")); QCOMPARE(tabs->tabText(5), QString("Tray icon")); QCOMPARE(window.findChildren().size(), 4); for (auto *toggle : window.findChildren("extraTemperatureSensors")) QVERIFY(!toggle->isChecked()); const QString monitor = qEnvironmentVariable("FRAMEWORK_TOOLS_MONITOR_SCREENSHOT"); if (!monitor.isEmpty()) QVERIFY(window.grab().save(monitor)); tabs->setCurrentIndex(1); QTest::qWait(50); auto *powerAuto = window.findChild("powerAuto"); QVERIFY(powerAuto); auto *brightness = window.findChild("powerBrightness"); QVERIFY(brightness); powerAuto->setChecked(true); QVERIFY(!brightness->isEnabledTo(brightness->parentWidget())); powerAuto->setChecked(false); QVERIFY(brightness->isEnabledTo(brightness->parentWidget())); brightness->setValue(42); auto *keyboard = window.findChild("keyboardBrightness"); QVERIFY(keyboard); keyboard->setValue(37); QTest::qWait(2300); // Automatic firmware refresh must not discard unsaved lighting edits. QCOMPARE(brightness->value(), 42); QVERIFY(!powerAuto->isChecked()); QCOMPARE(keyboard->value(), 37); const QString screenshot = qEnvironmentVariable("FRAMEWORK_TOOLS_SCREENSHOT"); if (!screenshot.isEmpty()) QVERIFY(window.grab().save(screenshot)); tabs->setCurrentIndex(2); QTest::qWait(50); } void cpuProfileDrafts() { CpuPage page; QVERIFY(!page.findChild("enableCpuOverrides")); auto *battery = page.findChild("batteryCpuProfile"); QVERIFY(battery->isEnabled()); auto *frequency = battery->findChild("overrideFrequency"); QVERIFY(frequency); QVERIFY(!frequency->isChecked()); QVERIFY(!battery->findChild()->isEnabled()); QCOMPARE(battery->findChild("cpuGovernor")->currentText(), QString("auto")); QCOMPARE(battery->findChild("cpuPreference")->currentText(), QString("auto")); for (auto *spin : page.findChildren()) spin->setRange(400, 10000); for (auto *combo : page.findChildren()) { if (combo->count() == 1) combo->addItems({"powersave", "performance", "balance_power"}); } const QVariantMap profile{{"minimum", 1000}, {"maximum", 2000}, {"governor", "powersave"}, {"preference", "balance_power"}}; const QVariantMap saved{{"enabled", true}, {"separate", false}, {"battery", profile}}; page.load(saved, true); auto *split = page.findChild("separateCpuProfiles"); QVERIFY(split); QVERIFY(!page.dirty()); QVERIFY(!page.draft().contains("ac")); split->setChecked(true); QCOMPARE(page.draft()["ac"], page.draft()["battery"]); QVERIFY(frequency->isChecked()); auto *ac = page.findChild("acCpuProfile"); QVERIFY(ac); auto boxes = ac->findChildren(); QVERIFY(boxes.size() >= 2); const bool grouped = page.draft()["ac"].toMap().contains("bounds"); boxes[grouped ? 3 : 1]->setValue(3000); split->setChecked(false); QVERIFY(page.dirty()); QVERIFY(!page.draft().contains("ac")); page.load(saved); // Background refresh must not replace unsaved edits. split->setChecked(true); QCOMPARE(grouped ? page.draft()["ac"].toMap()["bounds"].toMap()["p"].toMap()["maximum"].toInt() : page.draft()["ac"].toMap()["maximum"].toInt(), 3000); split->setChecked(false); const auto joined = page.draft(); page.load(joined, true); // Successful save commits removal of the AC copy. split->setChecked(true); QCOMPARE(page.draft()["ac"], page.draft()["battery"]); QVERIFY(page.dirty()); page.undo(); QVERIFY(!page.dirty()); QVERIFY(!page.draft().contains("ac")); const QString screenshot = qEnvironmentVariable("FRAMEWORK_TOOLS_CPU_SCREENSHOT"); if (!screenshot.isEmpty()) { split->setChecked(true); page.resize(1000, 720); page.show(); QTest::qWait(50); QVERIFY(page.grab().save(screenshot)); } } void colorSwatchUsesRgb() { ColorButton button; button.setColor(QColor(0, 255, 0, 80)); button.resize(button.sizeHint()); button.show(); QTest::qWait(10); QVERIFY(button.text().contains("31% opacity")); const auto picture = button.grab().toImage(); bool pureGreen = false; for (int y = 0; y < picture.height(); ++y) for (int x = 0; x < picture.width(); ++x) pureGreen |= picture.pixelColor(x, y) == QColor(0, 255, 0); QVERIFY(pureGreen); } void coloursFollowActivationOrder() { Chart chart("°C"); for (const auto &key : {"base", "x", "y"}) chart.addSeries(key); chart.setSelected("base", true); QCOMPARE(chart.color("base"), QColor("#3daee9")); chart.setSelected("x", true); const auto second = chart.color("x"); chart.setSelected("y", true); const auto third = chart.color("y"); QVERIFY(second != third); QVERIFY(second != chart.color("base")); QCOMPARE(chart.color("x"), second); chart.setSelected("x", false); chart.setSelected("y", false); chart.setSelected("y", true); chart.setSelected("x", true); QCOMPARE(chart.color("y"), second); QCOMPARE(chart.color("x"), third); } void sharedSaveAndUndo() { QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QSettings settings("fedora-tools", "framework-laptop-tools"); Window window; window.show(); auto *bar = window.findChild("pendingChanges"); QVERIFY(bar); auto *save = window.findChild("saveAll"); auto *undo = window.findChild("undoAll"); auto *tabs = window.findChild(); QTRY_VERIFY(save->isEnabled()); QVERIFY(bar->isHidden()); auto *fast = window.findChild("fastInterval"); auto *mode = window.findChild("trayDisplayMode"); auto *autostart = window.findChild("loginAutostart"); const QString path = config.path() + "/autostart/se.ajpanton.framework-laptop-tools.desktop"; fast->setCurrentIndex(fast->findData(4000)); QVERIFY(!bar->isHidden()); tabs->setCurrentIndex(5); mode->setCurrentIndex(1); autostart->setChecked(true); QCOMPARE(settings.value("sampling/fast").toInt(), 1000); QVERIFY(!settings.contains("tray/mode")); QVERIFY(!QFile::exists(path)); tabs->setCurrentIndex(0); QVERIFY(!bar->isHidden()); QTRY_VERIFY(undo->isEnabled()); undo->click(); QVERIFY(bar->isHidden()); QCOMPARE(fast->currentData().toInt(), 1000); QCOMPARE(mode->currentIndex(), 0); QVERIFY(!autostart->isChecked()); QVERIFY(!QFile::exists(path)); fast->setCurrentIndex(fast->findData(2000)); mode->setCurrentIndex(2); autostart->setChecked(true); QTRY_VERIFY(save->isEnabled()); save->click(); QVERIFY(bar->isHidden()); QCOMPARE(settings.value("sampling/fast").toInt(), 2000); QCOMPARE(settings.value("tray/mode").toString(), QString("number")); QVERIFY(QFile::exists(path)); fast->setCurrentIndex(fast->findData(500)); mode->setCurrentIndex(1); QTRY_VERIFY(undo->isEnabled()); undo->click(); QCOMPARE(fast->currentData().toInt(), 2000); QCOMPARE(mode->currentIndex(), 2); QVERIFY(bar->isHidden()); // Hover is a timestamp shared by every graph, not the same screen coordinate. const auto charts = window.findChildren(); QCOMPARE(charts.size(), 4); const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000; charts.first()->hovered(time); for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional(time)); charts.first()->hovered(-1); for (auto *chart : charts) QVERIFY(!chart->hoverTime()); } void lowFanConfirmationCanBeCancelled() { QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); Window window; window.show(); auto *save = window.findChild("saveAll"); QTRY_VERIFY(save->isEnabled()); auto *mode = window.findChild("fanMode"); auto *duty = window.findChild("fanDuty"); mode->setCurrentIndex(1); duty->setValue(0); QVERIFY(!window.findChild("lowFanWarning")->isHidden()); bool sawConfirmation = false; QTimer::singleShot(0, &window, [&] { auto *dialog = qobject_cast(QApplication::activeModalWidget()); if (dialog) { sawConfirmation = dialog->text().contains("0%"); dialog->done(QMessageBox::Cancel); } }); save->click(); QVERIFY(sawConfirmation); QVERIFY(!window.findChild("pendingChanges")->isHidden()); QVERIFY(save->isEnabled()); // No authorisation or hardware operation started. window.findChild("undoAll")->click(); QVERIFY(window.findChild("pendingChanges")->isHidden()); } }; QTEST_MAIN(WindowTest) #include "test-window.moc"