// SPDX-License-Identifier: MIT #include "window.h" #include #include #include #include #include #include #include #include #include #include "legend.h" #include "tray.h" #include #include #include #include #include "traypage.h" #include "colorbutton.h" #include #include #include "graphdata.h" #include "tooltip.h" #include "readingformat.h" #include "processusage.h" #include #include #include #include #include #include "widgets.h" #include class WindowTest : public QObject { Q_OBJECT private Q_SLOTS: void readingFormat_data() { QTest::addColumn("value"); QTest::addColumn("text"); QTest::newRow("zero") << 0. << QString("0.0"); QTest::newRow("small") << 1.26 << QString("1.3"); QTest::newRow("negative") << -1.26 << QString("-1.3"); QTest::newRow("negative zero") << -0.01 << QString("0.0"); QTest::newRow("whole below ten") << 9. << QString("9.0"); QTest::newRow("round to boundary") << 9.96 << QString("10.0"); QTest::newRow("ten") << 10. << QString("10"); QTest::newRow("minus ten") << -10. << QString("-10"); QTest::newRow("round up") << 12.6 << QString("13"); QTest::newRow("round down") << 12.4 << QString("12"); QTest::newRow("negative round") << -12.5 << QString("-13"); QTest::newRow("unavailable") << double(NAN) << QString("—"); QTest::newRow("infinite") << double(INFINITY) << QString("—"); } void readingFormat() { QFETCH(double, value); QFETCH(QString, text); QCOMPARE(formatReading(value), text); } void trayNumberPrecision() { TrayStyle style; style.border = false; style.backgroundColor = Qt::black; style.lineColor = Qt::white; const auto render = [&](double value, const QString &unit) { return telemetryIcon(false, {{0, value}}, unit, style, 0).pixmap(64, 64).toImage(); }; QCOMPARE(render(1250, "MHz"), render(1.25, "W")); // GHz in the icon. QCOMPARE(render(1.25, "W"), render(1.25, "°C")); QVERIFY(render(1.2, "W") != render(1.4, "W")); QCOMPARE(render(12.6, "W"), render(13, "%")); QCOMPARE(render(10000, "MHz"), render(10, "%")); QVERIFY(render(-1.2, "W") != render(1.2, "W")); } void stackedTrayNumbers() { TrayStyle style; style.border = false; style.backgroundColor = Qt::black; style.lineColor = Qt::white; const auto render = [&](double first, double second, const QString &unit = "W") { return telemetryIcon(false, {{0, first}}, "W", style, 0, TrayReading{second, unit}).pixmap(64, 64).toImage(); }; const auto both = render(1.2, 3.4); const auto changedTop = render(5.6, 3.4), changedBottom = render(1.2, 7.8); QVERIFY(both.copy(0, 0, 64, 32) != changedTop.copy(0, 0, 64, 32)); QCOMPARE(both.copy(0, 32, 64, 32), changedTop.copy(0, 32, 64, 32)); QCOMPARE(both.copy(0, 0, 64, 32), changedBottom.copy(0, 0, 64, 32)); QVERIFY(both.copy(0, 32, 64, 32) != changedBottom.copy(0, 32, 64, 32)); QCOMPARE(both, render(1.2, 3400, "MHz")); QVERIFY(both != render(1.2, NAN)); // Missing is a dash, not removal of the second row. // Both rows use their available height with small margins. for (int row = 0; row < 2; ++row) { int first = 64, last = -1; for (int y = row * 32; y < (row + 1) * 32; ++y) for (int x = 0; x < 64; ++x) if (qRed(both.pixel(x, y)) > 128) { first = std::min(first, y); last = std::max(last, y); } QVERIFY(first <= row * 32 + 5); QVERIFY(last >= row * 32 + 26); } const auto single = telemetryIcon(false, {{0, 1.2}}, "W", style, 0).pixmap(64, 64).toImage(); QCOMPARE(single, telemetryIcon(false, {{0, 1.2}}, "W", style, 0, std::nullopt).pixmap(64, 64).toImage()); const QVector history{{0, 1.2}, {1000, 3.4}}; QCOMPARE(telemetryIcon(true, history, "W", style, 1000).pixmap(64, 64).toImage(), telemetryIcon(true, history, "W", style, 1000, TrayReading{90, "%"}).pixmap(64, 64).toImage()); } void secondTrayReading() { const QVector sensors{{"cpu-usage", "CPU usage", {}, "%"}, {"cpu/p/max", "P-cores maximum", {}, "MHz"}}; TrayPage page(QVariantMap{{"tray/mode", "number"}}, sensors); auto *second = page.findChild("traySecondMetric"); auto *mode = page.findChild("trayDisplayMode"); QVERIFY(second); QCOMPARE(second->currentText(), QString("None")); QVERIFY(!page.secondMetric()); QVERIFY(!second->isHidden()); const auto original = page.draft(); second->setCurrentIndex(second->findData("cpu/p/max")); QVERIFY(page.secondMetric()); QCOMPARE(page.secondMetric()->id, QString("cpu/p/max")); const auto chosen = page.draft(); TrayPage restored(chosen, sensors); QCOMPARE(restored.secondMetric()->id, QString("cpu/p/max")); mode->setCurrentIndex(mode->findData("graph")); QVERIFY(second->isHidden()); mode->setCurrentIndex(mode->findData("number")); QVERIFY(!second->isHidden()); QCOMPARE(page.secondMetric()->id, QString("cpu/p/max")); page.load(original); QVERIFY(!page.secondMetric()); page.load(chosen); QCOMPARE(page.secondMetric()->id, QString("cpu/p/max")); TrayPage missing(chosen, {sensors[0]}); QVERIFY(!missing.secondMetric()); } void powerHoverOptions() { const QVector sensors{{"power/rapl/0", "CPU package", {}, "W"}, {"power/rapl/0:0", "CPU cores", {}, "W"}, {"battery-rate", "Battery rate", {}, "W"}}; QVariantMap settings{{"tray/hover/cpu", false}, {"tray/hover/fan", false}, {"tray/hover/battery", false}}; const QVector power{sensors[0], sensors[1]}; const QMap values{{power[0].id, 4.25}, {power[1].id, 12.6}}; TrayPage page(settings, sensors); auto *package = page.findChild("hover/" + power[0].id); auto *cores = page.findChild("hover/" + power[1].id); QVERIFY(package); QVERIFY(cores); QVERIFY(!package->isChecked()); QVERIFY(!cores->isChecked()); QVERIFY(!page.findChild("hover/battery-rate")); QVERIFY(trayTooltip(page.draft(), values, {}, {}, {}, power).isEmpty()); const auto saved = page.draft(); package->setChecked(true); QCOMPARE(trayTooltip(page.draft(), values, {}, {}, {}, power), QString("CPU package: 4.3 W")); cores->setChecked(true); QCOMPARE(trayTooltip(page.draft(), values, {}, {}, {}, power), QString("Power consumption:\n\u2003CPU package: 4.3 W\n\u2003CPU cores: 13 W")); QCOMPARE(trayTooltip(page.draft(), {}, {}, {}, {}, power), QString("Power consumption:\n\u2003CPU package: — W\n\u2003CPU cores: — W")); const auto chosen = page.draft(); page.load(saved); QVERIFY(!package->isChecked()); QVERIFY(!cores->isChecked()); page.load(chosen); QVERIFY(package->isChecked()); QVERIFY(cores->isChecked()); TrayPage restored(chosen, sensors); QCOMPARE(trayTooltip(restored.draft(), values, {}, {}, {}, power), trayTooltip(chosen, values, {}, {}, {}, power)); } void hoverFormatting() { const QVector sensors{{"cros_ec/peci-temp", "CPU", {}, "°C"}, {"spd5118/temp1", "Memory", {}, "°C"}, {"nvme/Composite", "NVMe", {}, "°C"}}; const auto temperatures = tooltipSensors(sensors); QCOMPARE(temperatures[0].id, sensors[0].id); QVERIFY(temperatures[3].id.isEmpty()); const QMap readings{{"cpu-usage", 12.3}, {"fan", 2500}, {"fan-duty", 40}, {sensors[0].id, 47.}, {sensors[1].id, 38.}, {sensors[2].id, 41.}}; QVariantMap settings{{"tray/hover/topApp", true}}; auto text = trayTooltip(settings, readings, temperatures, "Battery 80%", {"Firefox: 5.4 %"}); QCOMPARE(text, QString("CPU usage: 12.3 %\n\u2003Firefox: 5.4 %\nBattery 80%\nFan speed: 2500 RPM (40 %)\nTemperatures:\n\u2003CPU 47°C\n\u2003RAM 38°C\n\u2003NVMe 41°C")); const QStringList apps{"Firefox: 5.4 %", "Dolphin: 2.0 %", "Konsole: 1.0 %"}; for (int count = 0; count <= 3; ++count) { settings["tray/hover/topApps"] = count; const auto popup = trayTooltip(settings, readings, temperatures, {}, apps); for (int i = 0; i < 3; ++i) QCOMPARE(popup.contains(apps[i]), i < count); } settings["tray/hover/cpu"] = false; settings["tray/hover/battery"] = false; settings["tray/hover/fan"] = false; settings["tray/hover/temperature/memory"] = false; settings["tray/hover/temperature/nvme"] = false; QCOMPARE(trayTooltip(settings, readings, temperatures, {}, apps), QString("CPU 47°C")); QCOMPARE(trayTooltip(settings, {}, temperatures, {}, {}), QString("CPU —°C")); QCOMPARE(trayTooltip(settings, {{sensors[0].id, 3.2}}, temperatures, {}, {}), QString("CPU 3.2°C")); settings["tray/hover/temperature/cpu"] = false; QVERIFY(trayTooltip(settings, readings, temperatures, {}, {}).isEmpty()); QVariantMap battery{{"state", "Discharging"}, {"capacity", 75}, {"watts", 12.5}, {"remainingMWh", 56250}, {"fullMWh", 75000}}; QCOMPARE(batteryTooltip(battery, 80, {{"TimeToEmpty", 5400}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003-13 W · 1 h 30 min to 0%")); battery["state"] = "Charging"; QCOMPARE(batteryTooltip(battery, 100, {{"TimeToFull", 3600}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+13 W · 1 h 0 min to 100%")); battery["charge_full"] = 10000; battery["charge_now"] = 7500; battery["current_now"] = 1000; QCOMPARE(batteryTooltip(battery, 80, {}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+13 W · ≈0 h 30 min to 80%")); battery["current_now"] = 0; QVERIFY(batteryTooltip(battery, 80, {}).contains("Time remaining unavailable")); battery["capacity"] = 80; QVERIFY(batteryTooltip(battery, 80, {}).contains("80% limit reached")); QVERIFY(batteryTooltip({}, 100, {}).contains("unavailable")); } void processCpuAccounting() { QTemporaryDir proc; const auto writeStat = [&](int pid, const QString &command, int ticks, int start) { QVERIFY(QDir().mkpath(proc.filePath(QString::number(pid)))); QStringList fields; for (int i = 0; i < 20; ++i) fields << "0"; fields[0] = "R"; fields[11] = QString::number(ticks); fields[19] = QString::number(start); QFile file(proc.filePath(QString::number(pid) + "/stat")); QVERIFY(file.open(QIODevice::WriteOnly)); file.write((QString::number(pid) + " (" + command + ") " + fields.join(' ')).toUtf8()); }; ProcessUsage usage; writeStat(1, "Test) App", 100, 1); writeStat(2, "Test) App", 50, 2); writeStat(3, "Other", 100, 3); QVERIFY(usage.sample({}, proc.path()).isEmpty()); writeStat(1, "Test) App", 110, 1); writeStat(2, "Test) App", 70, 2); writeStat(3, "Other", 125, 3); auto top = usage.sample(100, proc.path()); QCOMPARE(top.size(), 2); QCOMPARE(top[0].name, QString("Test) App")); QCOMPARE(top[0].percent, 30.); // Sum matching app processes, not just the busiest PID. QCOMPARE(top[1].name, QString("Other")); QCOMPARE(top[1].percent, 25.); writeStat(1, "Replacement", 5000, 9); // Reused PID must not look like a CPU spike. writeStat(3, "Other", 130, 3); top = usage.sample(100, proc.path()); QVERIFY(!top.isEmpty()); QCOMPARE(top[0].name, QString("Other")); QCOMPARE(top[0].percent, 5.); QVERIFY(QFile::remove(proc.filePath("2/stat"))); // Exit/unreadable stat is an expected race. top = usage.sample(100, proc.path()); QVERIFY(!top.isEmpty()); QCOMPARE(top[0].percent, 0.); // More than three apps: limit the list and rank by usage, with stable name ordering on ties. writeStat(4, "Fourth", 10, 4); writeStat(5, "Fifth", 10, 5); usage.sample(100, proc.path()); writeStat(1, "Replacement", 5020, 9); writeStat(3, "Other", 170, 3); writeStat(4, "Fourth", 20, 4); writeStat(5, "Fifth", 30, 5); top = usage.sample(100, proc.path()); QCOMPARE(top.size(), 3); QCOMPARE(top[0].name, QString("Other")); QCOMPARE(top[1].name, QString("Fifth")); QCOMPARE(top[2].name, QString("Replacement")); usage.reset(); QVERIFY(usage.sample(100, proc.path()).isEmpty()); } void trayOptionHierarchy() { QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat); TrayPage page(settings, {{"cpu-usage", "CPU", {}, "%"}, {"cros_ec/peci-temp", "CPU temperature", {}, "°C"}}); page.show(); auto *mode = page.findChild("trayDisplayMode"); auto *graph = page.findChild("trayGraphOptions"); auto *overflow = page.findChild("trayOverflowOptions"); auto *outside = page.findChild("trayOutside"); QVERIFY(graph->isHidden()); mode->setCurrentIndex(mode->findData("graph")); QVERIFY(graph->isVisible()); QVERIFY(overflow->isVisible()); auto *border = page.findChild("border"); auto *borderColour = page.findChild("borderColor"); auto *colours = qobject_cast(borderColour->parentWidget()->layout()); auto *borderLabel = colours->labelForField(borderColour); const auto bordered = page.draft(); border->setChecked(false); QVERIFY(borderColour->isHidden()); QVERIFY(borderLabel->isHidden()); QCOMPARE(page.draft().value("tray/borderColor"), bordered.value("tray/borderColor")); page.load(bordered); QVERIFY(borderColour->isVisible()); QVERIFY(borderLabel->isVisible()); outside->setCurrentIndex(outside->findData("hide")); QVERIFY(overflow->isHidden()); outside->setCurrentIndex(outside->findData("clamp")); QVERIFY(overflow->isVisible()); auto *cpu = page.findChild("hover/cpu"); auto *hoverTitle = page.findChild("trayHoverTitle"); auto *hoverSettings = page.findChild("trayHoverSettings"); QVERIFY(hoverTitle); QVERIFY(hoverSettings); QVERIFY(!qobject_cast(hoverSettings)); QCOMPARE(hoverSettings->layout()->contentsMargins().left(), 24); QVERIFY(cpu->mapTo(&page, QPoint()).x() > hoverTitle->mapTo(&page, QPoint()).x()); auto *app = page.findChild("hover/topApps"); QCOMPARE(app->minimum(), 0); QCOMPARE(app->maximum(), 3); const auto saved = page.draft(); app->setValue(3); cpu->setChecked(false); QVERIFY(!app->isEnabled()); QCOMPARE(app->value(), 3); // Retain the choice when its parent is disabled. QVERIFY(!page.findChild("hover/temperature/memory")->isEnabled()); page.load(saved); QVERIFY(cpu->isChecked()); QVERIFY(app->isEnabled()); QCOMPARE(app->value(), 0); mode->setCurrentIndex(mode->findData("number")); QVERIFY(graph->isHidden()); QVERIFY(cpu->isVisible()); QVERIFY(settings.allKeys().isEmpty()); // Staged until Save and Apply. settings.setValue("tray/hover/topApp", true); TrayPage migrated(settings, {{"cpu-usage", "CPU", {}, "%"}}); QCOMPARE(migrated.findChild("hover/topApps")->value(), 1); QVERIFY(!migrated.draft().contains("tray/hover/topApp")); QCOMPARE(tooltipAppCount({{"tray/hover/topApps", 3}, {"tray/hover/topApp", false}}), 3); } void backgroundOpacity() { const QVector sensors{{"cpu-usage", "CPU", {}, "%"}}; TrayPage page(QVariantMap{{"tray/transparent", true}, {"tray/backgroundColor", "#ff123456"}, {"tray/mode", "number"}}, sensors); QVERIFY(!page.findChild("transparent")); QVERIFY(!page.draft().contains("tray/transparent")); QCOMPARE(page.iconStyle().backgroundColor, QColor(0x12, 0x34, 0x56, 0)); const auto transparent = page.draft(); page.show(); bool alphaAvailable = false; QTimer::singleShot(0, &page, [&] { auto *dialog = qobject_cast(QApplication::activeModalWidget()); if (!dialog) return; alphaAvailable = dialog->testOption(QColorDialog::ShowAlphaChannel); dialog->setCurrentColor(QColor(20, 40, 60, 128)); dialog->accept(); }); page.findChild("backgroundColor")->click(); QVERIFY(alphaAvailable); QCOMPARE(page.iconStyle().backgroundColor, QColor(20, 40, 60, 128)); const auto half = page.draft(); page.load(transparent); QCOMPARE(page.iconStyle().backgroundColor.alpha(), 0); page.load(half); QCOMPARE(page.iconStyle().backgroundColor.alpha(), 128); auto style = page.iconStyle(); style.border = false; style.fill = false; const auto image = telemetryIcon(true, {}, "%", style).pixmap(64, 64).toImage(); QCOMPARE(image.pixelColor(1, 1).alpha(), 128); TrayPage oldOpaque(QVariantMap{{"tray/transparent", false}, {"tray/backgroundColor", "#ff123456"}}, sensors); QCOMPARE(oldOpaque.iconStyle().backgroundColor.alpha(), 255); } void liveProcessSampling() { CpuUsage cpu; ProcessUsage processes; cpu.sample(readText("/proc/stat")); QVERIFY(processes.sample(cpu.totalDelta()).isEmpty()); QTest::qWait(200); QVERIFY(cpu.sample(readText("/proc/stat"))); const auto apps = processes.sample(cpu.totalDelta()); QVERIFY(!apps.isEmpty()); QVERIFY(apps.size() <= 3); double previous = 100; for (const auto &app : apps) { QVERIFY(!app.name.isEmpty()); QVERIFY(std::isfinite(app.percent) && app.percent >= 0 && app.percent <= previous); previous = app.percent; } } void multipleTrayDraftsAndLayout() { QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat); settings.setValue("tray/mode", "number"); settings.setValue("tray/hover/topApp", true); const QVector sensors{{"cpu-usage", "CPU usage", {}, "%"}}; TrayIconsPage page(settings, sensors); page.show(); auto *count = page.findChild("trayIconCount"); QCOMPARE(count->minimum(), 1); QCOMPARE(count->maximum(), 10); QCOMPARE(page.configurations().first().mode, QString("number")); QCOMPARE(tooltipAppCount(page.configurations().first().values), 1); const auto original = page.draft(); QVERIFY(!original.contains("tray/mode")); QCOMPARE(original.value("tray/icons/1/mode").toString(), QString("number")); count->setValue(3); auto editors = page.findChildren(); QCOMPARE(editors.size(), 3); QCOMPARE(page.configurations()[1].mode, QString("icon")); auto *secondMode = editors[1]->findChild("trayDisplayMode"); secondMode->setCurrentIndex(secondMode->findData("graph")); editors[1]->findChild("trayHistory")->setCurrentIndex(0); count->setValue(1); QCOMPARE(page.draft(), original); // Hidden cards do not get serialised. count->setValue(3); QCOMPARE(page.configurations()[1].mode, QString("graph")); QCOMPARE(page.configurations()[1].style.historyMs, qint64(10000)); const auto three = page.draft(); page.load(original); count->setValue(3); QCOMPARE(page.configurations()[1].mode, QString("icon")); // Undo discards unsaved additions. page.load(three); count->setValue(1); page.discardHidden(); count->setValue(3); QCOMPARE(page.configurations()[1].mode, QString("icon")); // Successful save forgets removed cards. page.load(three); const auto cards = page.findChildren(QString(), Qt::FindDirectChildrenOnly); QCOMPARE(cards.size(), 3); const int width = cards[0]->minimumWidth(); page.resize(width * 3 + 100, 1600); QTRY_COMPARE(cards[0]->y(), cards[1]->y()); QCOMPARE(cards[1]->y(), cards[2]->y()); QVERIFY(cards[1]->x() > cards[0]->x()); page.resize(width + 40, 3000); QTRY_VERIFY(cards[1]->y() > cards[0]->y()); QCOMPARE(cards[1]->x(), cards[0]->x()); count->setValue(10); QCOMPARE(page.configurations().size(), 10); QCOMPARE(page.draft().value("tray/icons/10/mode").toString(), QString("icon")); for (const auto &key : settings.allKeys()) settings.remove(key); const auto saved = page.draft(); for (auto it = saved.cbegin(); it != saved.cend(); ++it) settings.setValue(it.key(), it.value()); TrayIconsPage restored(settings, sensors); QCOMPARE(restored.draft(), saved); QCOMPARE(restored.configurations().size(), 10); const QString shot = qEnvironmentVariable("FRAMEWORK_TRAY_MULTI_SCREENSHOT"); if (!shot.isEmpty()) { page.load(three); page.resize(width * 2 + 70, 1500); QTest::qWait(50); QVERIFY(page.grab().save(shot)); } } void multipleLiveTrayIcons() { QTemporaryDir root; qputenv("XDG_CONFIG_HOME", root.path().toUtf8()); QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, root.path()); QSettings settings("fedora-tools", "framework-laptop-tools"); settings.setValue("tray/mode", "number"); Window window; window.show(); auto *page = window.findChild(); auto *count = window.findChild("trayIconCount"); auto *save = window.findChild("saveAll"); auto *undo = window.findChild("undoAll"); auto icons = window.findChildren(); QCOMPARE(icons.size(), 1); auto *first = icons[0]; count->setValue(2); QCOMPARE(window.findChildren().size(), 1); auto *tabs = window.findChild(); tabs->setCurrentIndex(5); const auto cards = page->findChildren(QString(), Qt::FindDirectChildrenOnly); const int cardWidth = cards[0]->minimumWidth(); window.resize(cardWidth * 2 + 150, 830); QTRY_COMPARE(cards[0]->y(), cards[1]->y()); window.resize(cardWidth + 150, 830); QTRY_VERIFY(cards[1]->y() > cards[0]->y()); auto *scroll = qobject_cast(tabs->widget(5)); QTRY_COMPARE(scroll->horizontalScrollBar()->maximum(), 0); QVERIFY(scroll->verticalScrollBar()->maximum() > 0); auto *second = page->findChildren()[1]; second->findChild("hover/cpu")->setChecked(false); QTRY_VERIFY(save->isEnabled()); save->click(); icons = window.findChildren(); QCOMPARE(icons.size(), 2); QCOMPARE(icons[0], first); QVERIFY(icons[0]->toolTip().startsWith("CPU usage:")); QVERIFY(!icons[1]->toolTip().startsWith("CPU usage:")); QVERIFY(icons[0]->contextMenu() != icons[1]->contextMenu()); QCOMPARE(icons[0]->contextMenu()->actions().size(), icons[1]->contextMenu()->actions().size()); QCOMPARE(settings.value("tray/count").toInt(), 2); QVERIFY(!settings.contains("tray/mode")); const auto ordered = page->draft(); cards[0]->findChild("moveIconRight")->click(); QVERIFY(icons[0]->toolTip().startsWith("CPU usage:")); // Move is still only a draft. QTRY_VERIFY(save->isEnabled()); save->click(); QVERIFY(!icons[0]->toolTip().startsWith("CPU usage:")); QVERIFY(icons[1]->toolTip().startsWith("CPU usage:")); QVERIFY(!settings.value("tray/icons/1/hover/cpu").toBool()); cards[0]->findChild("moveIconRight")->click(); QTRY_VERIFY(undo->isEnabled()); undo->click(); QVERIFY(!page->configurations()[0].values.value("tray/hover/cpu").toBool()); cards[0]->findChild("moveIconRight")->click(); QCOMPARE(page->draft(), ordered); QTRY_VERIFY(save->isEnabled()); save->click(); count->setValue(1); QTRY_VERIFY(undo->isEnabled()); undo->click(); QCOMPARE(count->value(), 2); QVERIFY(!page->findChildren()[1]->findChild("hover/cpu")->isChecked()); count->setValue(1); QTRY_VERIFY(save->isEnabled()); save->click(); QCOMPARE(window.findChildren().size(), 1); for (const auto &key : settings.allKeys()) QVERIFY(!key.startsWith("tray/icons/2/")); count->setValue(2); QVERIFY(page->findChildren()[1]->findChild("hover/cpu")->isChecked()); QCOMPARE(page->configurations()[1].mode, QString("icon")); } void moveTrayConfigurations() { QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat); TrayIconsPage page(settings, {{"cpu-usage", "CPU", {}, "%"}}); auto *count = page.findChild("trayIconCount"); count->setValue(3); const auto cards = page.findChildren(QString(), Qt::FindDirectChildrenOnly); auto *first = cards[0]->findChild(); auto *last = cards[2]->findChild(); first->findChild("trayDisplayMode")->setCurrentIndex(1); first->findChild("trayHistory")->setCurrentIndex(0); first->findChild("border")->setChecked(false); last->findChild("trayDisplayMode")->setCurrentIndex(2); last->findChild("traySecondMetric")->setCurrentIndex(1); last->findChild("hover/topApps")->setValue(3); const auto firstValues = first->draft(), lastValues = last->draft(); const auto original = page.draft(); QVERIFY(!cards[0]->findChild("moveIconLeft")->isEnabled()); QVERIFY(!cards[2]->findChild("moveIconRight")->isEnabled()); QSignalSpy changed(&page, &TrayIconsPage::settingsChanged); cards[2]->findChild("moveIconLeft")->click(); QCOMPARE(page.configurations()[1].values, lastValues); QCOMPARE(changed.count(), 1); QVERIFY(page.configurations()[1].secondMetric); QCOMPARE(page.configurations()[1].secondMetric->id, QString("cpu-usage")); cards[0]->findChild("moveIconRight")->click(); QCOMPARE(page.configurations()[0].values, lastValues); QCOMPARE(page.configurations()[1].values, firstValues); // Crossing a grid row still exchanges adjacent logical positions. cards[1]->findChild("moveIconRight")->click(); QCOMPARE(page.configurations()[2].values, firstValues); count->setValue(2); QVERIFY(!cards[1]->findChild("moveIconRight")->isEnabled()); count->setValue(3); QCOMPARE(page.configurations()[2].values, firstValues); page.load(original); QCOMPARE(page.draft(), original); count->setValue(1); QVERIFY(!cards[0]->findChild("moveIconRight")->isEnabled()); QVERIFY(!cards[0]->findChild("moveIconLeft")->isEnabled()); } void timeWeightedColumns() { const auto connected = [](const QPointF &, const QPointF &) { return true; }; const QVector points{{0, 0}, {2, 10}, {10, 10}}; auto columns = timeAverages(points.begin(), points.end(), 0, 10, 2, connected); QCOMPARE(columns.size(), 2); QCOMPARE(columns[0].mean, 8.); QCOMPARE(columns[0].maximum, 10.); QCOMPARE(columns[0].lower(), columns[0].mean); QVERIFY(columns[0].compressed); QCOMPARE(columns[1].mean, 10.); columns = timeAverages(points.begin(), points.end(), 1, 3, 1, connected); QCOMPARE(columns[0].mean, 8.75); // Segment clipped and weighted at both pixel edges. columns = timeAverages(points.begin(), points.end(), 0, 10, 7, connected); double integral = 0; for (const auto &column : columns) integral += column.mean * (column.end - column.begin); QVERIFY(std::abs(integral - 90) < 1e-10); // Fractional boundaries preserve the total area. columns = timeAverages(points.begin(), points.end(), -10, 20, 1, connected); QCOMPARE(columns[0].mean, 9.); // Missing history contributes no artificial zeroes. QCOMPARE(columns[0].begin, 0.); QCOMPARE(columns[0].end, 10.); const QVector ramp{{0, -10}, {10, 10}}; columns = timeAverages(ramp.begin(), ramp.end(), 0, 10, 100, connected); QCOMPARE(columns.size(), 100); for (const auto &column : columns) { QVERIFY(std::abs(column.mean - (column.begin + column.end - 10)) < 1e-10); QVERIFY(std::abs(column.maximum - (column.end * 2 - 10)) < 1e-10); QVERIFY(!column.compressed); QCOMPARE(column.lower(), column.minimum); QVERIFY(std::abs(column.minimum - (column.begin * 2 - 10)) < 1e-10); } const QVector gaps{{0, 0}, {2, 10}, {3, NAN}, {4, 10}, {6, 0}}; columns = timeAverages(gaps.begin(), gaps.end(), 0, 6, 1, connected); QCOMPARE(columns.size(), 2); QCOMPARE(columns[0].mean, 5.); QCOMPARE(columns[1].mean, 5.); QCOMPARE(columns[0].end, 2.); QCOMPARE(columns[1].begin, 4.); // Gaps stay separate even in one pixel. columns = timeAverages(points.begin(), points.end(), 0, 10, 1, [](const QPointF &, const QPointF &b) { return b.x() < 10; }); QCOMPARE(columns.size(), 1); QCOMPARE(columns[0].end, 2.); // Explicit suspend break. } void columnThicknessIsVerticalOnly() { QCOMPARE(columnBand(5, 6, 10, 10), QRectF(5, 9, 1, 2)); QCOMPARE(columnBand(5, 6, 10, 11, 2, 2), QRectF(5, 9.5, 1, 2)); QCOMPARE(columnBand(5, 6, 10, 50), QRectF(5, 10, 1, 40)); for (double ratio : {1., 1.25, 1.5, 2.}) for (double bottom : {10., 11., 20.}) { QImage image(qRound(32 * ratio), qRound(32 * ratio), QImage::Format_ARGB32_Premultiplied); image.setDevicePixelRatio(ratio); image.fill(Qt::transparent); { QPainter painter(&image); painter.fillRect(columnBand(5 / ratio, 6 / ratio, 10, bottom, 2, ratio), Qt::green); } int pixels = 0; for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) { if (!image.pixelColor(x, y).alpha()) continue; QCOMPARE(x, 5); ++pixels; // Thickness never leaks into either neighbour. } QVERIFY(pixels >= std::ceil(2 * ratio)); } } void sparseSlopeStaysInItsColumns() { TrayStyle style; style.border = false; style.fill = false; style.backgroundColor = Qt::black; style.lineColor = Qt::green; style.historyMs = 6200; // Two samples span exactly two raster columns and most of the graph height. const auto image = telemetryIcon(true, {{1000, 10}, {1200, 90}}, "%", style, 6200).pixmap(64, 64).toImage(); int pixels = 0; for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) { if (image.pixelColor(x, y) != QColor(Qt::green)) continue; QVERIFY(x == 11 || x == 12); ++pixels; } QVERIFY(pixels > 40); } void peakHeightSurvivesPixelPhase() { QVector points; for (int i = 0; i <= 1000; ++i) points.append({double(i), i == 500 ? 100. : 10.}); for (int phase = 0; phase <= 32; ++phase) { const double first = 200 + phase / 32. * 7.5; const auto columns = timeAverages(points.cbegin(), points.cend(), first, first + 600, 80, [](const QPointF &, const QPointF &) { return true; }); QImage image(80, 120, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); { QPainter painter(&image); for (const auto &column : columns) painter.fillRect(columnBand(column.index, column.index + 1, 110 - column.maximum, 110 - column.lower()), Qt::green); } int top = image.height(); for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) if (image.pixelColor(x, y).alpha()) top = std::min(top, y); QCOMPARE(top, 10); // The peak's rasterised height, not merely its numeric maximum. } } void dayHistoryAndStretch() { Chart chart("MHz"); chart.addSeries("hidden"); const qint64 start = 100000000; for (int i = 0; i <= 7200; ++i) chart.sample({{"hidden", double(i)}}, 500, start + i * 500); QCOMPARE(chart.history("hidden").size(), 7201); // Not capped at 600, even when hidden. chart.setSelected("hidden", true); QVERIFY(chart.readingAt(start + 500).contains("hidden: 1.0 MHz")); const auto end = double(start + 3600000); chart.setHistoryWindow(historyRetentionMs, true); QCOMPARE(chart.timeRange(), qMakePair(double(start), end)); chart.setHistoryWindow(historyRetentionMs, false); QCOMPARE(chart.timeRange(), qMakePair(end - historyRetentionMs, end)); chart.setHistoryWindow(300000, true); QCOMPARE(chart.timeRange(), qMakePair(end - 300000, end)); QCOMPARE(chart.history("hidden", end - 10000).size(), 22); // Includes the clipping boundary. chart.sample({{"hidden", 1}}, 500, start + historyRetentionMs + 1000); const auto retained = chart.history("hidden"); QCOMPARE(retained.first().x(), double(start + 1000)); chart.setHistoryWindow(historyRetentionMs, true); QCOMPARE(chart.timeRange().first, double(start + 1000)); } void monitorHistorySettings() { QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path()); { Window window; auto *slider = window.findChild("monitorHistory"); QVERIFY(slider); auto *stretch = window.findChild("monitorStretch"); QVERIFY(stretch); QCOMPARE(slider->value(), 1000); QVERIFY(stretch->isChecked()); slider->setValue(0); stretch->setChecked(false); for (auto *chart : window.findChildren()) QCOMPARE(chart->timeRange().second - chart->timeRange().first, 300000.); slider->setValue(500); QSettings settings("fedora-tools", "framework-laptop-tools"); const int minutes = settings.value("monitor/historyMinutes").toInt(); QVERIFY(minutes >= 84 && minutes <= 86); // Geometric, not arithmetic, midpoint. } Window restored; QCOMPARE(restored.findChild("monitorHistory")->value(), 500); QVERIFY(!restored.findChild("monitorStretch")->isChecked()); QSettings settings("fedora-tools", "framework-laptop-tools"); settings.setValue("tray/historyMs", 86400000); TrayPage tray(settings, {{"cpu-usage", "CPU usage", {}, "%"}}); QCOMPARE(tray.iconStyle().historyMs, qint64(60000)); // Retired choices use the new default. } void denseHistoryShowsPeakColumns() { Chart chart("%"); chart.resize(600, 200); chart.addSeries("sensor"); chart.setSelected("sensor", true); const qint64 start = 100000000; // A half-second spike fills its column up to the maximum. for (int i = 0; i <= 172800; ++i) chart.sample({{"sensor", i == 43200 ? 100. : 10.}}, 500, start + i * 500); QCOMPARE(chart.history("sensor").size(), 172801); QVERIFY(chart.readingAt(start + 43200 * 500).contains("sensor: 100 %")); const auto image = chart.grab().toImage(); const auto shot = qEnvironmentVariable("FRAMEWORK_TOOLS_AVERAGE_SCREENSHOT"); if (!shot.isEmpty()) QVERIFY(image.save(shot)); bool peak = false; for (int y = 15; y < 60; ++y) for (int x = 100; x < 550; ++x) { const auto colour = image.pixelColor(x, y); peak |= colour == chart.color("sensor"); } QVERIFY(peak); chart.setHistoryWindow(300000, true); const auto recent = chart.grab().toImage(); for (int y = 15; y < 60; ++y) for (int x = 100; x < 550; ++x) QVERIFY(recent.pixelColor(x, y) != chart.color("sensor")); } 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), 720000.); // 0.2 h, not a repeating decimal. QCOMPARE(timeTickStep(3600000, 3), 1800000.); QCOMPARE(timeTickStep(240000, 8), 30000.); QCOMPARE(timeTickStep(60000, 8), 12000.); // 0.2 min. QCOMPARE(ageLabel(7200000, 7200000), QString("2h")); QCOMPARE(ageLabel(1800000, 7200000), QString("0.5h")); QCOMPARE(ageLabel(1200000, 1200000), QString("20min")); QCOMPARE(ageLabel(210000, 240000), QString("3.5min")); QCOMPARE(ageLabel(150000, 240000), QString("2.5min")); QCOMPARE(ageLabel(90000, 240000), QString("1.5min")); QCOMPARE(ageLabel(30000, 240000), QString("0.5min")); QCOMPARE(ageLabel(5000, 10000), QString("5s")); QCOMPARE(ageLabel(0, 240000), 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: —")); chart.sample({}, 30000, historyRetentionMs + 200000); chart.sample({}, 30000, historyRetentionMs + 230000); QVERIFY(!chart.readingAt(106000).contains("Asleep")); // Annotations expire with samples. } void samplingIntervalChanges_data() { QTest::addColumn("before"); QTest::addColumn("after"); QTest::newRow("battery-faster") << 60000 << 15000; QTest::newRow("battery-slower") << 15000 << 60000; QTest::newRow("fast-sensors-faster") << 2000 << 500; QTest::newRow("fast-sensors-slower") << 500 << 2000; } void samplingIntervalChanges() { QFETCH(int, before); QFETCH(int, after); Chart changed("%"), reference("%"); for (auto *chart : {&changed, &reference}) { chart->resize(850, 240); chart->addSeries("battery", "Battery"); chart->setSelected("battery", true); chart->setHistoryWindow(300000, false); for (int i = 0; i < 4; ++i) chart->sample({{"battery", 50}}, before, i * before); } const qint64 now = 3 * before + after; changed.sample({{"battery", 50}}, after, now); reference.sample({{"battery", 50}}, std::max(before, after), now); const auto history = changed.history("battery"); QCOMPARE(history.size(), 5); for (const auto &point : history) QVERIFY(std::isfinite(point.y())); QVERIFY(changed.readingAt(before / 2).contains("Battery: 50 %")); QCOMPARE(changed.grab().toImage(), reference.grab().toImage()); TrayStyle style; style.historyMs = 300000; QCOMPARE(telemetryIcon(true, history, "%", style, now).pixmap(64, 64).toImage(), telemetryIcon(true, reference.history("battery"), "%", style, now).pixmap(64, 64).toImage()); } void intervalChangesPreserveGaps() { Chart chart("%"); chart.addSeries("battery", "Battery"); chart.setSelected("battery", true); for (qint64 time : {0, 15000, 90000}) chart.sample({{"battery", 50}}, 15000, time); chart.sample({}, 15000, 105000); chart.sample({{"battery", 50}}, 15000, 120000); chart.setPowerState(true, {}, 125000); chart.setPowerState(false, false, 130000); chart.sample({{"battery", 50}}, 15000, 135000); chart.sample({{"battery", 50}}, 60000, 195000); const auto history = chart.history("battery"); for (double gap : {90000., 105000., 135000.}) QVERIFY(std::any_of(history.cbegin(), history.cend(), [gap](const QPointF &p) { return p.x() == gap && !std::isfinite(p.y()); })); QVERIFY(chart.readingAt(45000).contains("Battery: —")); QVERIFY(chart.readingAt(105000).contains("Battery: —")); QVERIFY(chart.readingAt(127000).contains("Battery: —")); QVERIFY(chart.readingAt(120000).contains("Battery: 50 %")); } void intervalHistoryRetainsBoundary() { Chart chart("%"); chart.addSeries("battery", "Battery"); chart.setSelected("battery", true); chart.sample({{"battery", 50}}, 60000, 0); chart.sample({{"battery", 50}}, 60000, 60000); chart.sample({{"battery", 50}}, 15000, 120000); chart.sample({{"battery", 50}}, 15000, historyRetentionMs + 61000); const auto history = chart.history("battery"); QCOMPARE(history.first().x(), 60000.); QCOMPARE(history[1], QPointF(120000, 50)); // No artificial gap at the cadence transition. QVERIFY(chart.readingAt(90000).contains("Battery: 50 %")); chart.sample({{"battery", 50}}, 15000, historyRetentionMs + 121000); QVERIFY(chart.readingAt(30000).contains("Battery: —")); // The old boundary has expired. } 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.historyMs = 10; 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, 10).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.backgroundColor = Qt::transparent; 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, 10).pixmap(64, 64).toImage(); QCOMPARE(image.pixelColor(32, 50), QColor(Qt::blue)); QCOMPARE(image.pixelColor(32, 10).alpha(), 0); } void trayHistoryWindow() { TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black; style.lineColor = Qt::green; style.fillColor = Qt::blue; style.fill = false; style.historyMs = 60000; const QVector points{{0, 50}, {60000, 50}, {90000, 50}}; auto render = [&](qint64 now) { return telemetryIcon(true, points, "%", style, now).pixmap(64, 64).toImage(); }; auto image = render(120000); QCOMPARE(image.pixelColor(10, 32), QColor(Qt::green)); // Segment crossing the left boundary is clipped. QCOMPARE(image.pixelColor(50, 32), QColor(Qt::black)); // No extrapolation from the latest sample to now. QCOMPARE(image.pixelColor(1, 32), QColor(Qt::white)); // Clipping preserves the border. image = render(240000); QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black)); // All readings have expired from the view. style.historyMs = 120000; image = render(90000); QCOMPARE(image.pixelColor(5, 32), QColor(Qt::black)); // Unavailable older history stays blank. QCOMPARE(image.pixelColor(32, 32), QColor(Qt::green)); style.fill = true; image = telemetryIcon(true, {{0, 50}, {30000, 50}, {60000, NAN}, {90000, 50}, {120000, 50}}, "%", style, 120000).pixmap(64, 64).toImage(); QCOMPARE(image.pixelColor(10, 50), QColor(Qt::blue)); QCOMPARE(image.pixelColor(32, 50), QColor(Qt::black)); // Sleep/data gaps remain gaps. const auto number = telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage(); style.historyMs = 10000; QCOMPARE(telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage(), number); QCOMPARE(image.pixelColor(60, 32), QColor(Qt::green)); } void trayShowsPeakColumns() { TrayStyle style; style.border = false; style.backgroundColor = Qt::black; style.lineColor = Qt::green; style.fill = false; style.historyMs = 300000; QVector points; for (int i = 0; i <= 600; ++i) points.append({double(i * 500), i == 300 ? 100. : 10.}); const auto image = telemetryIcon(true, points, "%", style, 300000).pixmap(64, 64).toImage(); const auto shot = qEnvironmentVariable("FRAMEWORK_TOOLS_AVERAGE_SCREENSHOT"); if (!shot.isEmpty()) QVERIFY(image.save(shot + ".tray.png")); bool band = false; for (int y = 4; y < 30; ++y) for (int x = 20; x < 45; ++x) { const auto colour = image.pixelColor(x, y); band |= colour == QColor(Qt::green); } QVERIFY(band); bool mean = false; for (int y = 50; y < 62; ++y) for (int x = 5; x < 59; ++x) mean |= image.pixelColor(x, y) == QColor(Qt::green); QVERIFY(mean); } 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 *history = page.findChild("trayHistory"); QVERIFY(history); QCOMPARE(page.iconStyle().historyMs, qint64(60000)); QCOMPARE(history->count(), 7); const QList spans{10000, 15000, 20000, 30000, 60000, 120000, 300000}; for (int i = 0; i < spans.size(); ++i) QCOMPARE(history->itemData(i).toInt(), spans[i]); history->setCurrentIndex(history->findData(300000)); QCOMPARE(page.iconStyle().historyMs, qint64(300000)); 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(history->currentData().toInt(), 60000); 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 chartRefreshesAfterDisplayChanges() { Chart chart("MHz"); chart.resize(650, 240); chart.addSeries("cpu"); chart.setSelected("cpu", true); chart.sample({{"cpu", 1000}}, 1000, 100000); chart.sample({{"cpu", 2000}}, 1000, 101000); chart.show(); QVERIFY(QTest::qWaitForWindowExposed(&chart)); QTest::mouseMove(&chart, QPoint(1, 1)); const auto original = chart.grab().toImage(); QCOMPARE(chart.grab().toImage(), original); chart.setSelected("cpu", false); QVERIFY(chart.grab().toImage() != original); chart.setSelected("cpu", true); QCOMPARE(chart.grab().toImage(), original); chart.setHistoryWindow(300000, false); const auto unstretched = chart.grab().toImage(); QVERIFY(unstretched != original); auto palette = chart.palette(); palette.setColor(QPalette::Text, Qt::red); chart.setPalette(palette); const auto recoloured = chart.grab().toImage(); QVERIFY(recoloured != unstretched); auto font = chart.font(); font.setPointSizeF(font.pointSizeF() + 2); chart.setFont(font); QVERIFY(chart.grab().toImage() != recoloured); chart.resize(800, 240); QCOMPARE(chart.grab().size(), QSize(800, 240) * chart.devicePixelRatioF()); } void stationaryHoverFollowsSamples() { Chart chart("W"); chart.resize(850, 240); chart.addSeries("power"); chart.setSelected("power", true); chart.setHistoryWindow(300000, false); const qint64 now = QDateTime::currentMSecsSinceEpoch(); chart.sample({{"power", 1}}, 1000, now); chart.show(); QVERIFY(QTest::qWaitForWindowExposed(&chart)); // Wayland does not support QTest's global cursor warp. Send a local move. const QPoint position(400, 100); QMouseEvent move(QEvent::MouseMove, position, chart.mapToGlobal(position), Qt::NoButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(&chart, &move); auto *popup = chart.findChild("chartReadingPopup"); QVERIFY(popup); QVERIFY(popup->isVisible()); const auto time = chart.hoverTime(); QVERIFY(time); const QString text = popup->text(); chart.sample({{"power", 2}}, 1000, now + 1000); QCOMPARE(chart.hoverTime(), std::optional(*time + 1000)); QVERIFY(popup->text() != text); QCOMPARE(popup->text(), chart.readingAt(*chart.hoverTime())); QTest::qWait(11000); // Past the usual tooltip inactivity timeout, without moving. QVERIFY(popup->isVisible()); chart.hide(); QVERIFY(!popup->isVisible()); QVERIFY(!chart.hoverTime()); } 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 slidersJumpToClick() { JumpSlider slider(Qt::Horizontal); slider.setRange(0, 100); slider.setPageStep(10); slider.resize(400, 40); slider.show(); const QPoint target(300, 20); for (bool inverted : {false, true}) { slider.setInvertedAppearance(inverted); slider.setValue(50); QTest::mousePress(&slider, Qt::LeftButton, Qt::NoModifier, target); QVERIFY(slider.isSliderDown()); QVERIFY(inverted ? slider.value() < 30 : slider.value() > 70); const int pressed = slider.value(); QTest::qWait(550); QCOMPARE(slider.value(), pressed); // No page-step auto-repeat. QTest::mouseMove(&slider, QPoint(200, 20)); QVERIFY(std::abs(slider.value() - 50) <= 1); QTest::mouseRelease(&slider, Qt::LeftButton, Qt::NoModifier, QPoint(200, 20)); QVERIFY(!slider.isSliderDown()); } slider.setInvertedAppearance(false); slider.setLayoutDirection(Qt::RightToLeft); slider.setValue(50); QTest::mouseClick(&slider, Qt::LeftButton, Qt::NoModifier, target); QVERIFY(slider.value() < 30); slider.setLayoutDirection(Qt::LeftToRight); slider.setValue(50); QTest::keyClick(&slider, Qt::Key_Right); QCOMPARE(slider.value(), 51); slider.setEnabled(false); QTest::mouseClick(&slider, Qt::LeftButton, Qt::NoModifier, target); QCOMPARE(slider.value(), 51); } void pagesAndReadOnlyStartup() { QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path()); 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(), 5); auto *powerChart = window.findChild("powerChart"); QVERIFY(powerChart); for (QWidget *ancestor = powerChart; ancestor; ancestor = ancestor->parentWidget()) QVERIFY(ancestor->toolTip().isEmpty()); // No inherited help tooltip obscures graph readings. const qreal titleSize = window.findChild("chartTitle")->font().pointSizeF(); for (auto *group : window.findChildren("settingsSection")) { group->ensurePolished(); QCOMPARE(group->font().pointSizeF(), titleSize); } for (const auto *name : {"batteryCpuProfile", "acCpuProfile"}) { auto *group = window.findChild(name); QVERIFY(group); group->ensurePolished(); QCOMPARE(group->font().pointSizeF(), titleSize); for (auto *combo : group->findChildren()) QCOMPARE(combo->font().pointSizeF(), titleSize - 2); } QCOMPARE(window.findChild("trayHoverTitle")->font().pointSizeF(), titleSize - 2); QCOMPARE(window.findChild("trayIconTitle")->font().pointSizeF(), titleSize); QCOMPARE(window.findChild("hover/cpu")->font().pointSizeF(), titleSize - 2); for (auto *slider : window.findChildren()) { QVERIFY(slider->style()->styleHint(QStyle::SH_Slider_AbsoluteSetButtons, nullptr, slider) & Qt::LeftButton); QVERIFY(!(slider->style()->styleHint(QStyle::SH_Slider_PageSetButtons, nullptr, slider) & Qt::LeftButton)); } for (auto *title : window.findChildren("chartTitle")) { if (title->text() == "Fan speed" || title->text() == "Battery") continue; QVERIFY(std::abs(title->geometry().center().x() - title->parentWidget()->rect().center().x()) <= 2); } 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::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path()); 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 *history = window.findChild("trayHistory"); auto *autostart = window.findChild("loginAutostart"); auto *hoverCpu = window.findChild("hover/cpu"); auto *hoverApps = window.findChild("hover/topApps"); auto *trayIcon = window.findChild(); 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); history->setCurrentIndex(history->findData(300000)); autostart->setChecked(true); hoverCpu->setChecked(false); hoverApps->setValue(2); QVERIFY(trayIcon->toolTip().startsWith("CPU ")); // Unsaved edits do not alter the live tooltip. QCOMPARE(settings.value("sampling/fast").toInt(), 1000); QVERIFY(!settings.contains("tray/mode")); QVERIFY(!QFile::exists(path)); QVERIFY(!settings.contains("tray/historyMs")); 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)); QVERIFY(hoverCpu->isChecked()); QCOMPARE(hoverApps->value(), 0); QCOMPARE(history->currentData().toInt(), 60000); fast->setCurrentIndex(fast->findData(2000)); mode->setCurrentIndex(2); autostart->setChecked(true); history->setCurrentIndex(history->findData(60000)); hoverCpu->setChecked(false); hoverApps->setValue(3); QTRY_VERIFY(save->isEnabled()); save->click(); QVERIFY(bar->isHidden()); QCOMPARE(settings.value("sampling/fast").toInt(), 2000); QCOMPARE(settings.value("tray/icons/1/mode").toString(), QString("number")); QVERIFY(QFile::exists(path)); QCOMPARE(settings.value("tray/icons/1/historyMs").toInt(), 60000); QVERIFY(!settings.value("tray/icons/1/hover/cpu").toBool()); QCOMPARE(settings.value("tray/icons/1/hover/topApps").toInt(), 3); QVERIFY(!trayIcon->toolTip().startsWith("CPU ")); hoverCpu->setChecked(true); hoverApps->setValue(1); history->setCurrentIndex(history->findData(30000)); 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()); QCOMPARE(history->currentData().toInt(), 60000); QVERIFY(!hoverCpu->isChecked()); QCOMPARE(hoverApps->value(), 3); // Hover is a timestamp shared by every graph, not the same screen coordinate. const auto charts = window.findChildren(); QCOMPARE(charts.size(), 5); 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()); QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path()); 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"