Add Framework laptop controls and monitoring with hardware-aware discovery
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "hardware.h"
|
||||
#include "fan.h"
|
||||
#include "cpu.h"
|
||||
#include <QTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
class HardwareTest : public QObject {
|
||||
Q_OBJECT
|
||||
void put(const QString &path, const QByteArray &value) {
|
||||
QVERIFY(QDir().mkpath(QFileInfo(path).absolutePath()));
|
||||
QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size());
|
||||
}
|
||||
private Q_SLOTS:
|
||||
void modelGate() {
|
||||
QTemporaryDir root;
|
||||
QVERIFY(!compatibilityError(root.path()).isEmpty());
|
||||
put(root.path() + "/class/dmi/id/sys_vendor", "Framework\n");
|
||||
put(root.path() + "/class/dmi/id/product_name", "Laptop 13\n");
|
||||
QVERIFY(!compatibilityError(root.path()).isEmpty());
|
||||
put(root.path() + "/class/dmi/id/product_name", "Laptop 13 Pro (Intel Core Ultra Series 3)\n");
|
||||
QVERIFY(compatibilityError(root.path()).isEmpty());
|
||||
}
|
||||
void missingReadingsAreNotZero() {
|
||||
QTemporaryDir root;
|
||||
QVERIFY(!readNumber(root.filePath("absent")));
|
||||
put(root.filePath("value"), "invalid\n"); QVERIFY(!readNumber(root.filePath("value")));
|
||||
put(root.filePath("value"), "0\n"); QCOMPARE(*readNumber(root.filePath("value")), 0.);
|
||||
}
|
||||
void batteryUnits() {
|
||||
QTemporaryDir root; const QString battery = root.path() + "/class/power_supply/BAT1/";
|
||||
put(battery + "type", "Battery"); put(battery + "current_now", "2000000");
|
||||
put(battery + "voltage_now", "16000000"); put(battery + "capacity", "60");
|
||||
QCOMPARE(batteryStatus(root.path())["watts"].toDouble(), 32.);
|
||||
put(battery + "power_now", "12300000");
|
||||
QCOMPARE(batteryStatus(root.path())["watts"].toDouble(), 12.3);
|
||||
QCOMPARE(batteryRate({{"watts", 12.3}, {"state", "Discharging"}}), std::optional<double>(-12.3));
|
||||
QCOMPARE(batteryRate({{"watts", 12.3}, {"state", "Charging"}}), std::optional<double>(12.3));
|
||||
QCOMPARE(batteryRate({{"watts", 0}, {"state", "Full"}}), std::optional<double>(0));
|
||||
QVERIFY(!batteryRate({{"watts", 12.3}, {"state", "Unknown"}}));
|
||||
put(battery + "charge_full", "4500000"); put(battery + "charge_full_design", "5000000");
|
||||
put(battery + "voltage_min_design", "16000000");
|
||||
QCOMPARE(batteryStatus(root.path())["fullMWh"].toDouble(), 72000.);
|
||||
QCOMPARE(batteryStatus(root.path())["health"].toDouble(), 90.);
|
||||
QVERIFY(batteryStatus(root.path())["capacityEstimated"].toBool());
|
||||
put(battery + "energy_full", "75000000"); put(battery + "energy_full_design", "75000000");
|
||||
QCOMPARE(batteryStatus(root.path())["fullMWh"].toDouble(), 75000.);
|
||||
QCOMPARE(batteryStatus(root.path())["health"].toDouble(), 100.);
|
||||
QVERIFY(!batteryStatus(root.path()).contains("capacityEstimated"));
|
||||
}
|
||||
void mainTemperatureSensors() {
|
||||
QTemporaryDir root;
|
||||
const QList<QPair<QString, QString>> fixtures{{"nvme", "Composite"}, {"nvme", "Sensor 1"},
|
||||
{"cros_ec", "peci-temp"}, {"cros_ec", "cpu_f75303@4d"}, {"coretemp", "Core 0"}, {"spd5118", ""}};
|
||||
for (int i = 0; i < fixtures.size(); ++i) {
|
||||
const QString dir = root.path() + "/class/hwmon/hwmon" + QString::number(i) + "/";
|
||||
put(dir + "name", fixtures[i].first.toUtf8()); put(dir + "temp1_label", fixtures[i].second.toUtf8());
|
||||
put(dir + "temp1_input", "45000");
|
||||
}
|
||||
const auto sensors = temperatureSensors(root.path()); QCOMPARE(sensors.size(), 6);
|
||||
for (const auto &sensor : sensors) {
|
||||
const bool main = sensor.name == "NVMe (composite)" || sensor.name == "CPU (PECI)"
|
||||
|| sensor.name == "Memory (SPD)";
|
||||
QCOMPARE(sensor.primary, main); QCOMPARE(sensorValue(sensor), std::optional<double>(45.));
|
||||
}
|
||||
}
|
||||
void fanValidationAndCurve() {
|
||||
QVERIFY(validateFan({{"mode", "auto"}}).isEmpty());
|
||||
QVERIFY(validateFan({{"mode", "manual"}, {"duty", 0}}).isEmpty());
|
||||
QVERIFY(!validateFan({{"mode", "manual"}, {"duty", -1}}).isEmpty());
|
||||
QVERIFY(validateFan({{"mode", "curve"}, {"curve", QVariantList{0, 0, 20, 100}}}).isEmpty());
|
||||
QCOMPARE(curveDuty(40, {0, 0, 20, 100}), 0.);
|
||||
QVERIFY(!validateFan({{"mode", "curve"}, {"curve", QVariantList{30, 70, 40, 100}}}).isEmpty());
|
||||
QVERIFY(!validateFan({{"mode", "curve"}, {"curve", QVariantList{30, 40, 70, 90}}}).isEmpty());
|
||||
QVERIFY(validateFan({{"mode", "curve"}, {"curve", QVariantList{30, 40, 70, 100}}}).isEmpty());
|
||||
QCOMPARE(curveDuty(55, {30, 40, 70, 100}), 40.);
|
||||
QCOMPARE(curveDuty(62.5, {30, 40, 70, 100}), 55.);
|
||||
QCOMPARE(curveDuty(100, {30, 40, 70, 100}), 100.);
|
||||
QCOMPARE(curveDuty(60, {30, 40, 70, 100}, {40, 60, 75, 85}), 40.);
|
||||
QCOMPARE(curveDuty(67.5, {30, 40, 70, 100}, {40, 60, 75, 85}), 55.);
|
||||
QVariantMap config{{"mode", "curve"}, {"curve", QVariantList{30, 40, 70, 100}}, {"temperatures", QVariantList{40, 60, 75, 85}}};
|
||||
QVERIFY(validateFan(config).isEmpty());
|
||||
config["temperatures"] = QVariantList{40, 60, 60, 85}; QVERIFY(!validateFan(config).isEmpty());
|
||||
config["temperatures"] = QVariantList{40, 60, 75, 90}; QVERIFY(!validateFan(config).isEmpty());
|
||||
}
|
||||
void cpuRangeAndApply() {
|
||||
QTemporaryDir root; const QString policy = root.path() + "/devices/system/cpu/cpufreq/policy0/";
|
||||
put(policy + "cpuinfo_min_freq", "400000"); put(policy + "cpuinfo_max_freq", "4800000");
|
||||
put(policy + "scaling_min_freq", "400000"); put(policy + "scaling_max_freq", "3000000");
|
||||
QVERIFY(!setCpuLimits(3500, 3000, root.path()).isEmpty());
|
||||
QCOMPARE(readText(policy + "scaling_max_freq"), QString("3000000"));
|
||||
QVERIFY(setCpuLimits(1000, 4000, root.path()).isEmpty());
|
||||
QCOMPARE(readText(policy + "scaling_min_freq"), QString("1000000"));
|
||||
QCOMPARE(readText(policy + "scaling_max_freq"), QString("4000000"));
|
||||
}
|
||||
void hybridCpuProfiles() {
|
||||
QTemporaryDir root;
|
||||
const QString base = root.path() + "/devices/system/cpu/cpufreq/";
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const QString p = base + "policy" + QString::number(i) + "/";
|
||||
put(p + "cpuinfo_min_freq", "400000"); put(p + "cpuinfo_max_freq", i ? "3300000" : "4800000");
|
||||
put(p + "scaling_min_freq", "400000"); put(p + "scaling_max_freq", "3000000");
|
||||
put(p + "scaling_driver", "intel_pstate");
|
||||
put(p + "scaling_governor", "powersave"); put(p + "energy_performance_preference", "balance_power");
|
||||
put(p + "scaling_available_governors", "powersave performance");
|
||||
put(p + "energy_performance_available_preferences", "performance balance_performance balance_power power");
|
||||
}
|
||||
QVariantMap profile{{"frequencyOverride", true}, {"minimum", 1000}, {"maximum", 4500}, {"governor", "powersave"}, {"preference", "balance_power"}};
|
||||
QCOMPARE(cpuLimits(root.path())["high"].toInt(), 4800);
|
||||
QCOMPARE(cpuLimits(root.path())["capped"].toInt(), 2);
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_max_freq"), QString("4500000"));
|
||||
QCOMPARE(readText(base + "policy1/scaling_max_freq"), QString("3300000"));
|
||||
QCOMPARE(cpuLimits(root.path())["capped"].toInt(), 1); // An E-core at its own ceiling isn't capped.
|
||||
QCOMPARE(readText(base + "policy1/energy_performance_preference"), QString("balance_power"));
|
||||
profile["minimum"] = 4000;
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy1/scaling_min_freq"), QString("3300000"));
|
||||
profile["governor"] = "performance";
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_governor"), QString("powersave"));
|
||||
profile["preference"] = "performance";
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
profile["maximum"] = 99999;
|
||||
QVERIFY(!applyCpuProfile(profile, root.path()).isEmpty());
|
||||
}
|
||||
void independentCpuOverrides() {
|
||||
QTemporaryDir root; const QString p = root.path() + "/devices/system/cpu/cpufreq/policy0/";
|
||||
const QVariantMap automatic{{"frequencyOverride", false}, {"governor", "auto"}, {"preference", "auto"}};
|
||||
QVERIFY(applyCpuProfile(automatic, root.path()).isEmpty()); // No CPU files are required for a no-op.
|
||||
put(p + "scaling_driver", "intel_pstate"); put(p + "scaling_governor", "powersave");
|
||||
put(p + "energy_performance_preference", "balance_power");
|
||||
put(p + "scaling_available_governors", "powersave performance");
|
||||
put(p + "energy_performance_available_preferences", "performance balance_power balance_performance power");
|
||||
put(p + "scaling_min_freq", "555000"); put(p + "scaling_max_freq", "2345000");
|
||||
put(p + "cpuinfo_min_freq", "400000"); put(p + "cpuinfo_max_freq", "4800000");
|
||||
auto profile = automatic; profile["governor"] = "performance";
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("powersave"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("balance_power"));
|
||||
put(p + "energy_performance_preference", "performance");
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("performance"));
|
||||
// Simulate another controller switching governor/EPP after a power-profile change.
|
||||
put(p + "scaling_governor", "powersave"); put(p + "energy_performance_preference", "power");
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("powersave"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("power"));
|
||||
QCOMPARE(readText(p + "scaling_min_freq"), QString("555000"));
|
||||
QCOMPARE(readText(p + "scaling_max_freq"), QString("2345000"));
|
||||
profile = automatic; profile["preference"] = "balance_power";
|
||||
put(p + "scaling_governor", "performance");
|
||||
QVERIFY(!applyCpuProfile(profile, root.path()).isEmpty()); // Auto governor must not be silently changed.
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("performance"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("power"));
|
||||
profile = automatic; profile["frequencyOverride"] = true; profile["minimum"] = 1000; profile["maximum"] = 4000;
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("performance"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("power"));
|
||||
QCOMPARE(readText(p + "scaling_max_freq"), QString("4000000"));
|
||||
QVERIFY(applyCpuProfile(automatic, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_max_freq"), QString("4000000")); // Releasing control is not a reset.
|
||||
}
|
||||
void cpuConfigMigration() {
|
||||
const QVariantMap profile{{"minimum", 1000}, {"maximum", 3000}, {"governor", "performance"}, {"preference", "performance"}};
|
||||
QVariantMap old{{"enabled", false}, {"separate", true}, {"battery", profile}, {"ac", profile}};
|
||||
auto config = normalizeCpuConfig(old); QVERIFY(!config.contains("enabled")); QVERIFY(!hasCpuOverrides(config));
|
||||
QCOMPARE(config["battery"].toMap()["governor"].toString(), QString("auto"));
|
||||
old["enabled"] = true; config = normalizeCpuConfig(old); QVERIFY(hasCpuOverrides(config));
|
||||
QVERIFY(config["battery"].toMap()["frequencyOverride"].toBool());
|
||||
QCOMPARE(normalizeCpuConfig(config), config);
|
||||
}
|
||||
void independentCoreGroups() {
|
||||
QTemporaryDir root;
|
||||
put(root.path() + "/bus/event_source/devices/cpu_core/cpus", "0");
|
||||
put(root.path() + "/bus/event_source/devices/cpu_atom/cpus", "1-2");
|
||||
const QString base = root.path() + "/devices/system/cpu/cpufreq/";
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const auto path = base + "policy" + QString::number(i) + "/";
|
||||
put(path + "related_cpus", QByteArray::number(i));
|
||||
put(path + "cpuinfo_min_freq", "400000"); put(path + "cpuinfo_max_freq", i == 0 ? "4800000" : i == 1 ? "3700000" : "3300000");
|
||||
put(path + "scaling_min_freq", "400000"); put(path + "scaling_max_freq", "3000000");
|
||||
}
|
||||
QCOMPARE(cpuPolicyGroups(root.path())["e"].size(), 2);
|
||||
QVariantMap bounds{{"p", QVariantMap{{"frequencyOverride", true}, {"minimum", 1000}, {"maximum", 4500}}},
|
||||
{"e", QVariantMap{{"frequencyOverride", false}, {"minimum", 400}, {"maximum", 3500}}}};
|
||||
QVariantMap profile{{"bounds", bounds}, {"governor", "auto"}, {"preference", "auto"}};
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_max_freq"), QString("4500000"));
|
||||
QCOMPARE(readText(base + "policy1/scaling_max_freq"), QString("3000000"));
|
||||
bounds["p"] = QVariantMap{{"frequencyOverride", false}};
|
||||
bounds["e"] = QVariantMap{{"frequencyOverride", true}, {"minimum", 500}, {"maximum", 3500}};
|
||||
profile["bounds"] = bounds;
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_max_freq"), QString("4500000"));
|
||||
QCOMPARE(readText(base + "policy1/scaling_max_freq"), QString("3500000"));
|
||||
QCOMPARE(readText(base + "policy2/scaling_max_freq"), QString("3300000"));
|
||||
QVERIFY(hasCpuOverrides({{"battery", profile}}));
|
||||
QCOMPARE(normalizeCpuConfig({{"battery", profile}})["battery"].toMap()["bounds"], QVariant(bounds));
|
||||
put(base + "policy1/related_cpus", "0 1");
|
||||
QVERIFY(cpuPolicyGroups(root.path()).isEmpty());
|
||||
QVERIFY(!applyCpuProfile(profile, root.path()).isEmpty());
|
||||
}
|
||||
void powerSourceAndProfileSelection() {
|
||||
QTemporaryDir root;
|
||||
QVERIFY(!onAcPower(root.path()).has_value());
|
||||
const QString ac = root.path() + "/class/power_supply/ACAD/";
|
||||
put(ac + "type", "Mains"); put(ac + "online", "0");
|
||||
QCOMPARE(onAcPower(root.path()), std::optional<bool>(false));
|
||||
put(ac + "online", "1");
|
||||
QCOMPARE(onAcPower(root.path()), std::optional<bool>(true));
|
||||
const QVariantMap battery{{"maximum", 2500}}, plugged{{"maximum", 4500}};
|
||||
QVariantMap config{{"enabled", true}, {"separate", true}, {"battery", battery}, {"ac", plugged}};
|
||||
QCOMPARE(cpuProfile(config, false), battery); QCOMPARE(cpuProfile(config, true), plugged);
|
||||
config["separate"] = false;
|
||||
QCOMPARE(cpuProfile(config, true), battery);
|
||||
}
|
||||
void fanRefusesMissingOrHotSensors() {
|
||||
QTemporaryDir root; const QString ec = root.path() + "/class/hwmon/hwmon7/";
|
||||
put(ec + "name", "cros_ec"); put(ec + "pwm1_enable", "2"); put(ec + "pwm1", "0"); put(ec + "fan1_fault", "0");
|
||||
const QVariantMap config{{"mode", "manual"}, {"duty", 50}};
|
||||
double last = -1;
|
||||
QVERIFY(!updateFan({{"mode", "auto"}}, last, root.path()).isEmpty());
|
||||
QVERIFY(!updateFan(config, last, root.path()).isEmpty());
|
||||
QCOMPARE(readText(ec + "pwm1_enable"), QString("2"));
|
||||
for (int i = 1; i <= 5; ++i) {
|
||||
const QString stem = ec + "temp" + QString::number(i);
|
||||
put(stem + "_input", "40000"); put(stem + "_fault", "0"); put(stem + "_crit", "90000");
|
||||
}
|
||||
QVERIFY(updateFan(config, last, root.path()).isEmpty());
|
||||
QCOMPARE(readText(ec + "pwm1_enable"), QString("1"));
|
||||
QCOMPARE(last, 50.);
|
||||
put(ec + "temp3_input", "89000");
|
||||
QVERIFY(!updateFan(config, last, root.path()).isEmpty());
|
||||
put(ec + "temp3_input", "40000"); put(ec + "temp1_fault", "1");
|
||||
QVERIFY(!updateFan(config, last, root.path()).isEmpty());
|
||||
}
|
||||
};
|
||||
QTEST_GUILESS_MAIN(HardwareTest)
|
||||
#include "test-hardware.moc"
|
||||
@@ -0,0 +1,361 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "window.h"
|
||||
#include <QTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTabWidget>
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
#include <QToolButton>
|
||||
#include "legend.h"
|
||||
#include "tray.h"
|
||||
#include <cmath>
|
||||
#include <QTextDocument>
|
||||
#include <QTextBlock>
|
||||
#include <QTextFragment>
|
||||
#include "traypage.h"
|
||||
#include "colorbutton.h"
|
||||
#include <QMessageBox>
|
||||
|
||||
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<double>(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<QPointF>{}, QVector<QPointF>{{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<QComboBox *>("trayDisplayMode"); mode->setCurrentIndex(1);
|
||||
auto *metric = page.findChild<QComboBox *>("trayMetric");
|
||||
auto *minimum = page.findChild<QDoubleSpinBox *>("trayMinimum"); auto *maximum = page.findChild<QDoubleSpinBox *>("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<QSlider *>(); auto *number = control.findChild<QSpinBox *>();
|
||||
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<QTabWidget *>(); 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<Chart *>().size(), 4);
|
||||
for (auto *toggle : window.findChildren<QToolButton *>("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<QCheckBox *>("powerAuto"); QVERIFY(powerAuto);
|
||||
auto *brightness = window.findChild<ValueControl *>("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<ValueControl *>("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<QCheckBox *>("enableCpuOverrides"));
|
||||
auto *battery = page.findChild<QGroupBox *>("batteryCpuProfile"); QVERIFY(battery->isEnabled());
|
||||
auto *frequency = battery->findChild<QCheckBox *>("overrideFrequency"); QVERIFY(frequency); QVERIFY(!frequency->isChecked());
|
||||
QVERIFY(!battery->findChild<ValueControl *>()->isEnabled());
|
||||
QCOMPARE(battery->findChild<QComboBox *>("cpuGovernor")->currentText(), QString("auto"));
|
||||
QCOMPARE(battery->findChild<QComboBox *>("cpuPreference")->currentText(), QString("auto"));
|
||||
for (auto *spin : page.findChildren<ValueControl *>()) spin->setRange(400, 10000);
|
||||
for (auto *combo : page.findChildren<QComboBox *>()) {
|
||||
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<QCheckBox *>("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<QGroupBox *>("acCpuProfile"); QVERIFY(ac);
|
||||
auto boxes = ac->findChildren<ValueControl *>(); 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<QWidget *>("pendingChanges"); QVERIFY(bar);
|
||||
auto *save = window.findChild<QPushButton *>("saveAll");
|
||||
auto *undo = window.findChild<QPushButton *>("undoAll");
|
||||
auto *tabs = window.findChild<QTabWidget *>();
|
||||
QTRY_VERIFY(save->isEnabled()); QVERIFY(bar->isHidden());
|
||||
auto *fast = window.findChild<QComboBox *>("fastInterval");
|
||||
auto *mode = window.findChild<QComboBox *>("trayDisplayMode");
|
||||
auto *autostart = window.findChild<QCheckBox *>("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<Chart *>(); QCOMPARE(charts.size(), 4);
|
||||
const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000;
|
||||
charts.first()->hovered(time);
|
||||
for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional<qint64>(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<QPushButton *>("saveAll");
|
||||
QTRY_VERIFY(save->isEnabled());
|
||||
auto *mode = window.findChild<QComboBox *>("fanMode");
|
||||
auto *duty = window.findChild<ValueControl *>("fanDuty");
|
||||
mode->setCurrentIndex(1); duty->setValue(0);
|
||||
QVERIFY(!window.findChild<QLabel *>("lowFanWarning")->isHidden());
|
||||
bool sawConfirmation = false;
|
||||
QTimer::singleShot(0, &window, [&] {
|
||||
auto *dialog = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
|
||||
if (dialog) { sawConfirmation = dialog->text().contains("0%"); dialog->done(QMessageBox::Cancel); }
|
||||
});
|
||||
save->click(); QVERIFY(sawConfirmation);
|
||||
QVERIFY(!window.findChild<QWidget *>("pendingChanges")->isHidden());
|
||||
QVERIFY(save->isEnabled()); // No authorisation or hardware operation started.
|
||||
window.findChild<QPushButton *>("undoAll")->click();
|
||||
QVERIFY(window.findChild<QWidget *>("pendingChanges")->isHidden());
|
||||
}
|
||||
};
|
||||
QTEST_MAIN(WindowTest)
|
||||
#include "test-window.moc"
|
||||
Reference in New Issue
Block a user