v0.1.3: redesign the Sankey pipeline layout
Lint / REUSE compliance (push) Failing after 13s
Build and Test / Build and run tests (push) Failing after 44s
Lint / clang-format (push) Failing after 57s
Build and Publish Flatpak / Build Flatpak (push) Failing after 14s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped

- Spread columns by rank among the stages actually present, so sparse
  pipelines fill the width instead of leaving dead stretches
- Top-align columns: the funnel's success path runs level along the top
  and drop-off ribbons only ever descend
- Give each ribbon its slot per node side (outgoing by target height,
  incoming by source height) so ribbons stop braiding over each other
- Move the last column's labels to its left so they always stay
  on-screen, with a theme halo to stay legible over ribbons
- Never hide small-node labels; elide with tooltip instead
- Guarantee columns and ribbons fit the viewport (clamp-aware scale,
  spill-proof ribbon thickness)
- Debounce resize relayouts and stop re-querying SQLite on resize
- Canonicalize stage names on write (CLI "rejected" -> "Rejected") and
  repair pre-existing rows on database open
- Take Sankey node width/padding from Kirigami units
- Add QuickShapesPrivate configure-time guard for QtQuick.Shapes
- Extend sankeylayouttest: viewport fit, ribbon containment, slot
  ordering, sparse-column spread, canonicalization, degenerate sizes
- README: build-dependency and generator fixes, document SettingsPage

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
This commit is contained in:
2026-08-31 18:51:49 -05:00
co-authored by Claude Fable 5
parent 078343ff96
commit 506b66cfdd
11 changed files with 572 additions and 87 deletions
+265 -16
View File
@@ -8,6 +8,8 @@
#include "jobsdatabase.h"
#include "sankeymodel.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir>
#include <QtTest>
#include <memory>
@@ -18,6 +20,13 @@ class SankeyLayoutTest : public QObject
{
Q_OBJECT
// Defaults of SankeyModel::reload()/relayout(), which every test uses.
static constexpr qreal nodeWidth = 16.0;
static constexpr qreal rowPadding = 10.0;
// The label margin/floor baked into the layout (sankeymodel.cpp).
static constexpr qreal labelMargin = 8.0;
static constexpr qreal eps = 0.01;
private Q_SLOTS:
void init()
{
@@ -30,23 +39,13 @@ private Q_SLOTS:
{
JobsDatabase db;
auto makeJob = [&](const QString &stage) {
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
};
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Screening"));
makeJob(QStringLiteral("Rejected"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Screening"));
makeJob(db, QStringLiteral("Rejected"));
SankeyModel model;
model.relayout(600, 400);
model.reload(600, 400);
QVERIFY(!model.isEmpty());
@@ -85,13 +84,263 @@ private Q_SLOTS:
QVERIFY(db.isOpen());
SankeyModel model;
model.relayout(400, 300);
model.reload(400, 300);
QVERIFY(model.isEmpty());
QVERIFY(model.nodes().isEmpty());
QVERIFY(model.links().isEmpty());
}
void geometryFitsViewport()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(600, 40)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
QHash<qreal, qreal> columnHeights;
QHash<qreal, int> columnCounts;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
const qreal x = m.value(QStringLiteral("x")).toReal();
const qreal y = m.value(QStringLiteral("y")).toReal();
const qreal w = m.value(QStringLiteral("width")).toReal();
const qreal h = m.value(QStringLiteral("height")).toReal();
QVERIFY(x >= -eps);
QVERIFY(x + w <= size.width() + eps);
QVERIFY(y >= -eps);
QVERIFY(y + h <= size.height() + eps);
columnHeights[x] += h;
columnCounts[x] += 1;
// The label rect must lie inside the viewport too.
const qreal labelWidth = m.value(QStringLiteral("labelWidth")).toReal();
if (m.value(QStringLiteral("labelOnRight")).toBool()) {
QVERIFY(x + w + labelMargin + labelWidth <= size.width() + eps);
} else {
QVERIFY(x - labelMargin - labelWidth >= -eps);
}
}
for (auto it = columnHeights.constBegin(); it != columnHeights.constEnd(); ++it) {
const qreal gaps = rowPadding * (columnCounts.value(it.key()) - 1);
QVERIFY(it.value() + gaps <= size.height() + eps);
}
}
}
void ribbonsStayInsideNodes()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
QHash<QString, qreal> outboundTotal;
QHash<QString, qreal> inboundTotal;
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
const qreal thickness = m.value(QStringLiteral("thickness")).toReal();
QVERIFY(thickness > 0);
const QVariantMap fromNode = nodeByStage.value(m.value(QStringLiteral("fromStage")).toString());
const QVariantMap toNode = nodeByStage.value(m.value(QStringLiteral("toStage")).toString());
const qreal sourceY = m.value(QStringLiteral("sourceY")).toReal();
const qreal targetY = m.value(QStringLiteral("targetY")).toReal();
QVERIFY(sourceY >= fromNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(sourceY + thickness <= fromNode.value(QStringLiteral("y")).toReal() + fromNode.value(QStringLiteral("height")).toReal() + eps);
QVERIFY(targetY >= toNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(targetY + thickness <= toNode.value(QStringLiteral("y")).toReal() + toNode.value(QStringLiteral("height")).toReal() + eps);
outboundTotal[m.value(QStringLiteral("fromStage")).toString()] += thickness;
inboundTotal[m.value(QStringLiteral("toStage")).toString()] += thickness;
}
for (auto it = outboundTotal.constBegin(); it != outboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
for (auto it = inboundTotal.constBegin(); it != inboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
}
void ribbonSlotsFollowEndpointHeights()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
const auto center = [&](const QString &stage) {
const QVariantMap n = nodeByStage.value(stage);
return n.value(QStringLiteral("y")).toReal() + n.value(QStringLiteral("height")).toReal() / 2.0;
};
// Outgoing ribbons must stack top-to-bottom in order of their
// target's height, incoming ones by their source's height, or the
// ribbons twist over each other right at the node.
QHash<QString, QList<QPair<qreal, qreal>>> outgoing; // sourceY -> target center
QHash<QString, QList<QPair<qreal, qreal>>> incoming; // targetY -> source center
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
outgoing[m.value(QStringLiteral("fromStage")).toString()].append(
{m.value(QStringLiteral("sourceY")).toReal(), center(m.value(QStringLiteral("toStage")).toString())});
incoming[m.value(QStringLiteral("toStage")).toString()].append(
{m.value(QStringLiteral("targetY")).toReal(), center(m.value(QStringLiteral("fromStage")).toString())});
}
const auto verifySorted = [](QList<QPair<qreal, qreal>> slots) {
std::sort(slots.begin(), slots.end());
for (int i = 1; i < slots.size(); ++i) {
QVERIFY(slots.at(i).second >= slots.at(i - 1).second - 0.01);
}
};
for (const auto &slots : std::as_const(outgoing)) {
verifySorted(slots);
}
for (const auto &slots : std::as_const(incoming)) {
verifySorted(slots);
}
}
void sparseColumnsFillWidth()
{
JobsDatabase db;
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
// Only Start, Applied and Rejected are present: their columns should
// spread evenly over the full width, not sit at canonical positions.
QList<qreal> xs;
for (const QVariant &v : model.nodes()) {
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
if (!xs.contains(x)) {
xs.append(x);
}
}
std::sort(xs.begin(), xs.end());
QCOMPARE(xs.size(), 3);
QVERIFY(qAbs(xs.at(0)) < eps);
QVERIFY(qAbs(xs.at(1) - (600 - nodeWidth) / 2.0) < eps);
QVERIFY(qAbs(xs.at(2) - (600 - nodeWidth)) < eps);
}
void caseVariantStageIsCanonicalized()
{
JobsDatabase db;
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
QVERIFY(db.setStage(job.id, QStringLiteral("rejected")));
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
QStringList stages;
for (const QVariant &v : model.nodes()) {
stages.append(v.toMap().value(QStringLiteral("stage")).toString());
}
QVERIFY(stages.contains(QStringLiteral("Rejected")));
QVERIFY(!stages.contains(QStringLiteral("rejected")));
// Pre-canonicalization rows already in the database are repaired the
// next time it is opened (migrate() normalization).
{
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("fixup"));
raw.setDatabaseName(m_dir->path() + QStringLiteral("/test.sqlite"));
QVERIFY(raw.open());
QSqlQuery q(raw);
QVERIFY(q.exec(QStringLiteral("UPDATE jobs SET stage = 'ghosted'")));
QVERIFY(q.exec(QStringLiteral("UPDATE stage_history SET to_stage = 'ghosted' WHERE to_stage = 'Rejected'")));
raw.close();
}
QSqlDatabase::removeDatabase(QStringLiteral("fixup"));
JobsDatabase reopened;
QVERIFY(reopened.isOpen());
QCOMPARE(reopened.jobById(job.id)->stage, QStringLiteral("Ghosted"));
const auto transitions = reopened.stageTransitions();
for (const StageTransition &t : transitions) {
QVERIFY(t.toStage != QStringLiteral("ghosted"));
}
}
void degenerateSizesDoNotCrash()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(0, 0);
QVERIFY(model.isEmpty());
model.relayout(-5, 100);
QVERIFY(model.isEmpty());
model.relayout(5, 5);
model.relayout(2000, 2);
model.relayout(600, 300);
QVERIFY(!model.isEmpty());
}
private:
static void makeJob(JobsDatabase &db, const QString &stage)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
}
static void walkJob(JobsDatabase &db, const QStringList &stages)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
for (const QString &stage : stages) {
QVERIFY(db.setStage(job.id, stage));
}
}
/// Populates every column, including all four terminal outcomes sharing
/// the last one.
static void seedDensePipeline(JobsDatabase &db)
{
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Offer"), QStringLiteral("Accepted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Ghosted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Withdrawn")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Rejected")});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
}
std::unique_ptr<QTemporaryDir> m_dir;
};