Initial commit: Kareer, a Kirigami job application tracker
C++/Qt6/KDE Frameworks 6 app with a SQLite-backed data layer, a Sankey-diagram dashboard, and a full CLI (add/list/show/update/stage/ delete/stats/stages) for scripting from other tools.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_executable(jobsdatabasetest
|
||||
jobsdatabasetest.cpp
|
||||
../src/jobstage.cpp
|
||||
../src/jobsdatabase.cpp
|
||||
)
|
||||
|
||||
target_include_directories(jobsdatabasetest PRIVATE ../src)
|
||||
|
||||
target_link_libraries(jobsdatabasetest PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Sql
|
||||
Qt6::Test
|
||||
KF6::I18n
|
||||
)
|
||||
|
||||
add_test(NAME jobsdatabasetest COMMAND jobsdatabasetest)
|
||||
|
||||
add_executable(sankeylayouttest
|
||||
sankeylayouttest.cpp
|
||||
../src/jobstage.cpp
|
||||
../src/jobsdatabase.cpp
|
||||
../src/sankeymodel.cpp
|
||||
)
|
||||
|
||||
target_include_directories(sankeylayouttest PRIVATE ../src)
|
||||
|
||||
target_link_libraries(sankeylayouttest PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Qml
|
||||
Qt6::Sql
|
||||
Qt6::Test
|
||||
KF6::I18n
|
||||
)
|
||||
|
||||
add_test(NAME sankeylayouttest COMMAND sankeylayouttest)
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString dbPathIn(const QTemporaryDir &dir)
|
||||
{
|
||||
return dir.path() + QStringLiteral("/test.sqlite");
|
||||
}
|
||||
}
|
||||
|
||||
class JobsDatabaseTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void addAndRetrieve()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
QVERIFY(dir.isValid());
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
QVERIFY(db.isOpen());
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("Acme Corp");
|
||||
job.title = QStringLiteral("Software Engineer");
|
||||
job.dateApplied = QDate(2026, 6, 1);
|
||||
job.salaryMin = 120000;
|
||||
job.salaryMax = 150000;
|
||||
|
||||
QVERIFY(db.addJob(job));
|
||||
QVERIFY(job.id > 0);
|
||||
QCOMPARE(job.stage, QStringLiteral("Applied"));
|
||||
QVERIFY(job.createdAt.isValid());
|
||||
|
||||
const auto fetched = db.jobById(job.id);
|
||||
QVERIFY(fetched.has_value());
|
||||
QCOMPARE(fetched->company, QStringLiteral("Acme Corp"));
|
||||
QCOMPARE(fetched->salaryMin, 120000);
|
||||
QCOMPARE(fetched->salaryMax, 150000);
|
||||
QCOMPARE(fetched->stage, QStringLiteral("Applied"));
|
||||
|
||||
const auto transitions = db.stageTransitions();
|
||||
QCOMPARE(transitions.size(), 1);
|
||||
QVERIFY(transitions.first().fromStage.isEmpty());
|
||||
QCOMPARE(transitions.first().toStage, QStringLiteral("Applied"));
|
||||
}
|
||||
|
||||
void rejectsUnknownStage()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("Acme");
|
||||
job.title = QStringLiteral("Engineer");
|
||||
job.stage = QStringLiteral("Bogus");
|
||||
|
||||
QVERIFY(!db.addJob(job));
|
||||
QVERIFY(!db.lastError().isEmpty());
|
||||
}
|
||||
|
||||
void updateJobDoesNotTouchStage()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
|
||||
Job edited = *db.jobById(job.id);
|
||||
edited.company = QStringLiteral("Updated Co");
|
||||
edited.stage = QStringLiteral("Rejected"); // updateJob must ignore this
|
||||
QVERIFY(db.updateJob(edited));
|
||||
|
||||
const auto fetched = db.jobById(job.id);
|
||||
QCOMPARE(fetched->company, QStringLiteral("Updated Co"));
|
||||
QCOMPARE(fetched->stage, QStringLiteral("Applied"));
|
||||
}
|
||||
|
||||
void stageTransitionsRecordHistory()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Screening")));
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Screening"))); // no-op: same stage
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Rejected")));
|
||||
|
||||
const auto transitions = db.stageTransitions();
|
||||
// Start->Applied, Applied->Screening, Screening->Rejected (the no-op adds nothing)
|
||||
QCOMPARE(transitions.size(), 3);
|
||||
QCOMPARE(transitions.at(1).fromStage, QStringLiteral("Applied"));
|
||||
QCOMPARE(transitions.at(1).toStage, QStringLiteral("Screening"));
|
||||
QCOMPARE(transitions.at(2).fromStage, QStringLiteral("Screening"));
|
||||
QCOMPARE(transitions.at(2).toStage, QStringLiteral("Rejected"));
|
||||
|
||||
const auto fetched = db.jobById(job.id);
|
||||
QCOMPARE(fetched->stage, QStringLiteral("Rejected"));
|
||||
}
|
||||
|
||||
void setStageRejectsUnknownStage()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
|
||||
QVERIFY(!db.setStage(job.id, QStringLiteral("Bogus")));
|
||||
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Applied"));
|
||||
}
|
||||
|
||||
void deleteCascadesHistory()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Screening")));
|
||||
|
||||
QVERIFY(db.deleteJob(job.id));
|
||||
QVERIFY(!db.jobById(job.id).has_value());
|
||||
QVERIFY(db.stageTransitions().isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(JobsDatabaseTest)
|
||||
#include "jobsdatabasetest.moc"
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
#include "sankeymodel.h"
|
||||
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <memory>
|
||||
|
||||
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points
|
||||
// that at a fresh temporary file via the KAREER_DB_PATH override.
|
||||
class SankeyLayoutTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void init()
|
||||
{
|
||||
m_dir = std::make_unique<QTemporaryDir>();
|
||||
QVERIFY(m_dir->isValid());
|
||||
qputenv("KAREER_DB_PATH", (m_dir->path() + QStringLiteral("/test.sqlite")).toUtf8());
|
||||
}
|
||||
|
||||
void layoutReflectsTransitionCounts()
|
||||
{
|
||||
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"));
|
||||
|
||||
SankeyModel model;
|
||||
model.relayout(600, 400);
|
||||
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
const QVariantList nodes = model.nodes();
|
||||
const QVariantList links = model.links();
|
||||
|
||||
QHash<QString, QVariantMap> nodeByStage;
|
||||
for (const QVariant &v : nodes) {
|
||||
const QVariantMap m = v.toMap();
|
||||
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
|
||||
QVERIFY(m.value(QStringLiteral("x")).toReal() >= 0);
|
||||
QVERIFY(m.value(QStringLiteral("y")).toReal() >= 0);
|
||||
QVERIFY(m.value(QStringLiteral("width")).toReal() > 0);
|
||||
QVERIFY(m.value(QStringLiteral("height")).toReal() > 0);
|
||||
}
|
||||
|
||||
QVERIFY(nodeByStage.contains(QStringLiteral("Start")));
|
||||
QCOMPARE(nodeByStage.value(QStringLiteral("Start")).value(QStringLiteral("value")).toInt(), 4);
|
||||
|
||||
int totalLinkValue = 0;
|
||||
for (const QVariant &v : links) {
|
||||
totalLinkValue += v.toMap().value(QStringLiteral("value")).toInt();
|
||||
}
|
||||
// Start->Applied(4), Applied->Screening(1), Applied->Rejected(1)
|
||||
QCOMPARE(totalLinkValue, 6);
|
||||
|
||||
const qreal startX = nodeByStage.value(QStringLiteral("Start")).value(QStringLiteral("x")).toReal();
|
||||
for (auto it = nodeByStage.constBegin(); it != nodeByStage.constEnd(); ++it) {
|
||||
QVERIFY(it.value().value(QStringLiteral("x")).toReal() >= startX);
|
||||
}
|
||||
}
|
||||
|
||||
void emptyDatabaseProducesEmptyLayout()
|
||||
{
|
||||
JobsDatabase db;
|
||||
QVERIFY(db.isOpen());
|
||||
|
||||
SankeyModel model;
|
||||
model.relayout(400, 300);
|
||||
QVERIFY(model.isEmpty());
|
||||
QVERIFY(model.nodes().isEmpty());
|
||||
QVERIFY(model.links().isEmpty());
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<QTemporaryDir> m_dir;
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(SankeyLayoutTest)
|
||||
#include "sankeylayouttest.moc"
|
||||
Reference in New Issue
Block a user