test(tasks): add SVG regression coverage

This commit is contained in:
2026-06-13 05:18:47 +02:00
parent b6369338dd
commit 2f8b306991
4 changed files with 582 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
/compile_commands.json
/widgets/*.svgz
/tests/.build/
+32 -1
View File
@@ -1,5 +1,13 @@
INSTALL_PREFIX ?= /usr
DESTDIR ?= $(INSTALL_PREFIX)/share/plasma/desktoptheme/warg
CXX ?= c++
COMPILE_COMMANDS := compile_commands.json
TASK_SVG_TEST := tests/.build/task_svg_test
QT6_CFLAGS ?= $(shell pkg-config --cflags Qt6Core Qt6Gui 2>/dev/null)
QT6_LIBS ?= $(shell pkg-config --libs Qt6Core Qt6Gui 2>/dev/null)
KSVG_CFLAGS ?= -I/usr/include/KF6/KSvg -I/usr/include/KF6/KConfig -I/usr/include/KF6/KConfigCore
KSVG_LIBS ?= -lKF6Svg -lKF6ConfigCore
TASK_SVG_TEST_CFLAGS := -std=c++17 -fPIC $(CXXFLAGS) $(QT6_CFLAGS) $(KSVG_CFLAGS)
SVG_FILES := widgets/tasks.svg
SVGZ_FILES := $(SVG_FILES:.svg=.svgz)
@@ -17,6 +25,14 @@ clean:
rm -f $$file; \
fi; \
done
@if [ -d tests/.build ]; then \
echo "Removing tests/.build"; \
rm -rf tests/.build; \
fi
@if [ -f $(COMPILE_COMMANDS) ]; then \
echo "Removing $(COMPILE_COMMANDS)"; \
rm -f $(COMPILE_COMMANDS); \
fi
install: metadata.json plasmarc $(SVGZ_FILES)
@mkdir -p $(DESTDIR)
@@ -25,4 +41,19 @@ install: metadata.json plasmarc $(SVGZ_FILES)
install -Dm644 $$file $(DESTDIR)/$$file; \
done
.PHONY: all clean install
$(COMPILE_COMMANDS): Makefile tests/task_svg_test.cpp
@printf '[\n {\n "directory": "%s",\n "command": "%s %s -c %s",\n "file": "%s"\n }\n]\n' \
"$(CURDIR)" \
"$(CXX)" \
"$(TASK_SVG_TEST_CFLAGS)" \
"$(CURDIR)/tests/task_svg_test.cpp" \
"$(CURDIR)/tests/task_svg_test.cpp" > $@
test: $(COMPILE_COMMANDS) $(SVGZ_FILES) $(TASK_SVG_TEST)
@QT_QPA_PLATFORM=offscreen $(TASK_SVG_TEST) widgets/tasks.svg widgets/tasks.svgz
$(TASK_SVG_TEST): tests/task_svg_test.cpp
@mkdir -p $(@D)
$(CXX) $(TASK_SVG_TEST_CFLAGS) $< -o $@ $(LDFLAGS) $(KSVG_LIBS) $(QT6_LIBS)
.PHONY: all clean install test
+18
View File
@@ -30,5 +30,23 @@ Alternatively, you can install it with Make by running one of the following:
make INSTALL_PREFIX=~/.local install
```
## Testing
Run the C++ regression test. It builds the compressed asset, checks the SVG
frame contract, and verifies the generated asset with KSVG:
```shell
make test
```
This requires the Qt 6, KSvg, and KConfig development files.
`make test` also generates `compile_commands.json` for clangd. To refresh only
the compile database, run:
```shell
make compile_commands.json
```
## License
Apache-2.0, see [LICENSE](LICENSE).
+529
View File
@@ -0,0 +1,529 @@
#include <KSvg/FrameSvg>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QHash>
#include <QIODevice>
#include <QSet>
#include <QSizeF>
#include <QString>
#include <QXmlStreamReader>
#include <QtTypes>
#include <cmath>
#include <cstddef>
#include <initializer_list>
#include <iostream>
namespace {
struct ElementInfo {
QString name;
QHash<QString, QString> attributes;
};
struct Orientation {
const char* name;
qreal originX;
qreal left;
qreal top;
qreal right;
qreal bottom;
};
struct State {
const char* name;
qreal originY;
};
struct Geometry {
qreal x;
qreal y;
qreal width;
qreal height;
};
constexpr qreal comparisonTolerance = 0.001;
constexpr qreal markerOffset = 20;
constexpr qreal markerSize = 4;
constexpr qreal testFrameHeight = 32;
constexpr qreal testFrameWidth = 36;
constexpr qreal tileSize = 44;
constexpr std::initializer_list<Orientation> orientations = {
{"", 0, 6, 8, 6, 12},
{"north", 44, 6, 12, 6, 8 },
{"west", 88, 12, 6, 8, 6 },
{"east", 132, 8, 6, 12, 6 },
};
constexpr std::initializer_list<State> frameStates = {
{"normal", 0 },
{"minimized", 44 },
{"focus", 88 },
{"hover", 132},
{"progress", 0 },
{"attention", 0 },
};
constexpr std::initializer_list<State> marginStates = {
{"normal", 0 },
{"minimized", 44 },
{"focus", 88 },
{"hover", 132},
};
constexpr std::initializer_list<const char*> frameParts = {
"center",
"top",
"bottom",
"left",
"right",
"topleft",
"topright",
"bottomleft",
"bottomright",
};
constexpr std::initializer_list<const char*> marginSides = {
"top",
"bottom",
"left",
"right",
};
QString framePrefix(const Orientation& orientation, const State& state) {
if (QString::fromLatin1(orientation.name).isEmpty()) {
return QString::fromLatin1(state.name);
}
return QStringLiteral("%1-%2").arg(
QString::fromLatin1(orientation.name), QString::fromLatin1(state.name)
);
}
QString marginHintId(
const Orientation& orientation, const State& state, const char* side
) {
return QStringLiteral("%1-hint-%2-margin")
.arg(framePrefix(orientation, state), QString::fromLatin1(side));
}
bool closeEnough(qreal actual, qreal expected) {
return std::abs(actual - expected) < comparisonTolerance;
}
bool checkValue(
const QString& context, const char* name, qreal actual, qreal expected
) {
if (closeEnough(actual, expected)) {
return true;
}
std::cerr << context.toStdString() << ": expected " << name << "=" << expected
<< ", got " << actual << "\n";
return false;
}
bool readElements(const QString& path, QHash<QString, ElementInfo>& elements) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
std::cerr << path.toStdString() << ": cannot open file\n";
return false;
}
QXmlStreamReader reader(&file);
bool success = true;
while (!reader.atEnd()) {
reader.readNext();
if (!reader.isStartElement()) {
continue;
}
const QString elementId =
reader.attributes().value(QStringLiteral("id")).toString();
if (elementId.isEmpty()) {
continue;
}
if (elements.contains(elementId)) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": duplicate element id\n";
success = false;
continue;
}
ElementInfo element;
element.name = reader.name().toString();
for (const QXmlStreamAttribute& attribute : reader.attributes()) {
element.attributes.insert(
attribute.name().toString(), attribute.value().toString()
);
}
elements.insert(elementId, element);
}
if (reader.hasError()) {
std::cerr << path.toStdString()
<< ": invalid XML: " << reader.errorString().toStdString()
<< "\n";
success = false;
}
return success;
}
bool parseNumber(
const QString& path,
const QString& elementId,
const ElementInfo& element,
const char* attributeName,
qreal& value
) {
const QString attribute = QString::fromLatin1(attributeName);
if (!element.attributes.contains(attribute)) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": missing " << attributeName << "\n";
return false;
}
bool parsed = false;
value = element.attributes.value(attribute).toDouble(&parsed);
if (!parsed) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": invalid " << attributeName << " value "
<< element.attributes.value(attribute).toStdString() << "\n";
return false;
}
return true;
}
Geometry expectedMarginGeometry(
const Orientation& orientation, const State& state, const QString& side
) {
if (side == QStringLiteral("top")) {
return {
orientation.originX + markerOffset,
state.originY,
markerSize,
orientation.top
};
}
if (side == QStringLiteral("bottom")) {
return {
orientation.originX + markerOffset,
state.originY + tileSize - orientation.bottom,
markerSize,
orientation.bottom,
};
}
if (side == QStringLiteral("left")) {
return {
orientation.originX,
state.originY + markerOffset,
orientation.left,
markerSize
};
}
return {
orientation.originX + tileSize - orientation.right,
state.originY + markerOffset,
orientation.right,
markerSize,
};
}
bool checkFrameContract(
const QString& path, const QHash<QString, ElementInfo>& elements
) {
bool success = true;
for (const Orientation& orientation : orientations) {
for (const State& state : frameStates) {
const QString prefix = framePrefix(orientation, state);
if (!elements.contains(prefix)) {
std::cerr << path.toStdString() << ": " << prefix.toStdString()
<< ": missing frame group\n";
success = false;
}
for (const char* part : frameParts) {
const QString elementId = QStringLiteral("%1-%2").arg(
prefix, QString::fromLatin1(part)
);
if (!elements.contains(elementId)) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": missing frame part\n";
success = false;
}
}
}
}
return success;
}
bool checkMarginHint(
const QString& path,
const QHash<QString, ElementInfo>& elements,
const QString& elementId,
const Geometry& expected
) {
const ElementInfo element = elements.value(elementId);
if (element.name != QStringLiteral("rect")) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": expected rect element\n";
return false;
}
bool success = true;
qreal actual = 0;
if (parseNumber(path, elementId, element, "x", actual)) {
success = checkValue(
QStringLiteral("%1: %2").arg(path, elementId),
"x",
actual,
expected.x
) &&
success;
} else {
success = false;
}
if (parseNumber(path, elementId, element, "y", actual)) {
success = checkValue(
QStringLiteral("%1: %2").arg(path, elementId),
"y",
actual,
expected.y
) &&
success;
} else {
success = false;
}
if (parseNumber(path, elementId, element, "width", actual)) {
success = checkValue(
QStringLiteral("%1: %2").arg(path, elementId),
"width",
actual,
expected.width
) &&
success;
} else {
success = false;
}
if (parseNumber(path, elementId, element, "height", actual)) {
success = checkValue(
QStringLiteral("%1: %2").arg(path, elementId),
"height",
actual,
expected.height
) &&
success;
} else {
success = false;
}
return success;
}
bool checkMarginContract(
const QString& path, const QHash<QString, ElementInfo>& elements
) {
bool success = true;
QSet<QString> expectedIds;
for (const Orientation& orientation : orientations) {
for (const State& state : marginStates) {
for (const char* sideName : marginSides) {
const QString side = QString::fromLatin1(sideName);
const QString elementId = marginHintId(orientation, state, sideName);
expectedIds.insert(elementId);
if (!elements.contains(elementId)) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": missing margin hint\n";
success = false;
continue;
}
const Geometry expected = expectedMarginGeometry(
orientation, state, side
);
success = checkMarginHint(path, elements, elementId, expected) &&
success;
}
}
}
for (auto iterator = elements.constBegin(); iterator != elements.constEnd();
++iterator) {
const QString& elementId = iterator.key();
bool isMarginHint = false;
for (const char* side : marginSides) {
isMarginHint = isMarginHint ||
elementId.endsWith(QStringLiteral("-hint-%1-margin")
.arg(QString::fromLatin1(side)));
}
if (isMarginHint && !expectedIds.contains(elementId)) {
std::cerr << path.toStdString() << ": " << elementId.toStdString()
<< ": unexpected margin hint\n";
success = false;
}
}
return success;
}
bool checkStretchHint(
const QString& path, const QHash<QString, ElementInfo>& elements
) {
const QString elementId = QStringLiteral("hint-stretch-borders");
if (!elements.contains(elementId)) {
std::cerr << path.toStdString()
<< ": hint-stretch-borders: missing stretch hint\n";
return false;
}
const QString name = elements.value(elementId).name;
if (name == QStringLiteral("path") || name == QStringLiteral("rect")) {
return true;
}
std::cerr << path.toStdString()
<< ": hint-stretch-borders: expected path or rect element\n";
return false;
}
bool checkSvgContract(const QString& path) {
QHash<QString, ElementInfo> elements;
if (!readElements(path, elements)) {
return false;
}
bool success = true;
success = checkFrameContract(path, elements) && success;
success = checkMarginContract(path, elements) && success;
success = checkStretchHint(path, elements) && success;
return success;
}
bool checkKsvgPrefixes(const QString& path) {
KSvg::FrameSvg frame;
frame.setUsingRenderingCache(false);
frame.setImagePath(path);
bool success = true;
for (const Orientation& orientation : orientations) {
for (const State& state : frameStates) {
const QString prefix = framePrefix(orientation, state);
if (!frame.hasElementPrefix(prefix)) {
std::cerr << prefix.toStdString() << ": missing KSVG frame prefix\n";
success = false;
}
}
}
return success;
}
bool checkKsvgMargins(const QString& path) {
bool success = true;
for (const Orientation& orientation : orientations) {
for (const State& state : marginStates) {
const QString prefix = framePrefix(orientation, state);
KSvg::FrameSvg frame;
frame.setUsingRenderingCache(false);
frame.setImagePath(path);
frame.setElementPrefix(prefix);
frame.resizeFrame(QSizeF(testFrameWidth, testFrameHeight));
qreal left = 0;
qreal top = 0;
qreal right = 0;
qreal bottom = 0;
frame.getFixedMargins(left, top, right, bottom);
success = checkValue(prefix, "left", left, orientation.left) && success;
success = checkValue(prefix, "top", top, orientation.top) && success;
success = checkValue(prefix, "right", right, orientation.right) &&
success;
success = checkValue(prefix, "bottom", bottom, orientation.bottom) &&
success;
success = checkValue(
prefix,
"minimumWidth",
frame.minimumDrawingWidth(),
orientation.left + orientation.right
) &&
success;
success = checkValue(
prefix,
"minimumHeight",
frame.minimumDrawingHeight(),
orientation.top + orientation.bottom
) &&
success;
}
}
return success;
}
} // namespace
int main(int argc, char** argv) {
const QGuiApplication app(argc, argv);
if (argc != 3) {
std::cerr << "usage: " << argv[0]
<< " widgets/tasks.svg widgets/tasks.svgz\n";
return 2;
}
const QString svgPath =
QFileInfo(QString::fromLocal8Bit(argv[1])).absoluteFilePath();
const QString svgzPath =
QFileInfo(QString::fromLocal8Bit(argv[2])).absoluteFilePath();
bool success = true;
success = checkSvgContract(svgPath) && success;
success = checkKsvgPrefixes(svgzPath) && success;
success = checkKsvgMargins(svgzPath) && success;
if (!success) {
return 1;
}
const size_t frameCount = orientations.size() * frameStates.size();
const size_t hintCount = orientations.size() * marginStates.size() *
marginSides.size();
std::cout << "ok: " << frameCount << " task frames, " << hintCount
<< " margin hints, and KSVG task margins\n";
return 0;
}