First vertical slice of velox-gui, built entirely against tools/mockd (no daemon dependency): - rpc/: RpcConnection runs a QLocalSocket on a worker thread with newline-delimited JSON-RPC framing, drives the session.hello / session.subscribe handshake, and reconnects with exponential backoff (250 ms -> 8 s). RpcClient is the main-thread face: marshals calls onto the worker, delivers replies as main-thread callbacks, re-emits server notifications as typed Qt signals, and issues the one-shot download.list on reaching Connected. - models/DownloadTableModel: QAbstractTableModel over TaskSummary. A progress batch is a row patch with a narrow dataChanged over the value columns only; beginResetModel() is reserved for the initial load and a reconnect resync. - widgets/ProgressDelegate: in-cell progress bar for the Status column. - mainwindow/MainWindow: the table, a status-bar connection dot, an offline banner instead of a modal, dialog-free pause/resume/stop actions, and QSettings column/geometry persistence. - gui/CMakeLists.txt links velox::proto (never velox::core, ADR 0009) and self-guards on the veloxproto target so main keeps configuring if it is ever absent again. - i18n from the first commit: every string via tr(), plus an Arabic .ts stub for the RTL check. - tests/: headless QTest for the model — proves the progress patch is a narrow dataChanged and never resets the model. Verified end-to-end against `mockd --tasks 300`: handshake, initial list, live progress batches applied to the model, and a clean Reconnecting -> Connected recovery when mockd is bounced mid-run. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd
113 lines
3.9 KiB
C++
113 lines
3.9 KiB
C++
// DownloadTableModel unit tests. Lane GUI.
|
|
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include <QSignalSpy>
|
|
#include <QtTest>
|
|
|
|
#include "models/DownloadTableModel.hpp"
|
|
|
|
using velox::gui::DownloadTableModel;
|
|
|
|
namespace {
|
|
|
|
QJsonObject summary(const QString &id, const QString &state, qint64 size, qint64 done) {
|
|
return QJsonObject{
|
|
{"taskId", id},
|
|
{"filename", id + ".iso"},
|
|
{"url", "https://example.test/" + id},
|
|
{"state", state},
|
|
{"sizeBytes", double(size)},
|
|
{"downloadedBytes", double(done)},
|
|
{"speedBps", 0.0},
|
|
{"segments", 1.0},
|
|
{"resumable", true},
|
|
{"createdAt", "2026-09-10T00:00:00Z"},
|
|
};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class TstDownloadTableModel : public QObject {
|
|
Q_OBJECT
|
|
|
|
private slots:
|
|
void resetPopulatesRows();
|
|
void progressPatchIsNarrowAndDoesNotReset();
|
|
void progressForUnknownTaskIsIgnored();
|
|
void addThenRemove();
|
|
};
|
|
|
|
void TstDownloadTableModel::resetPopulatesRows() {
|
|
DownloadTableModel model;
|
|
model.resetFromJson(
|
|
QJsonArray{summary("a", "downloading", 1000, 500), summary("b", "paused", 2000, 0)});
|
|
|
|
QCOMPARE(model.rowCount(), 2);
|
|
QCOMPARE(model.index(0, DownloadTableModel::ColName).data().toString(),
|
|
QStringLiteral("a.iso"));
|
|
QCOMPARE(model.index(0, DownloadTableModel::ColStatus).data().toString(),
|
|
QStringLiteral("50.0 %"));
|
|
QCOMPARE(model.index(0, 0).data(DownloadTableModel::ProgressRole).toDouble(), 0.5);
|
|
}
|
|
|
|
void TstDownloadTableModel::progressPatchIsNarrowAndDoesNotReset() {
|
|
DownloadTableModel model;
|
|
model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
|
|
|
|
QSignalSpy dataChangedSpy(&model, &QAbstractItemModel::dataChanged);
|
|
QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset);
|
|
QSignalSpy rowsInsertedSpy(&model, &QAbstractItemModel::rowsInserted);
|
|
|
|
model.applyProgress(QJsonArray{QJsonObject{
|
|
{"taskId", "a"}, {"downloadedBytes", 600.0}, {"speedBps", 50.0}, {"etaSeconds", 8.0}}});
|
|
|
|
QCOMPARE(resetSpy.count(), 0);
|
|
QCOMPARE(rowsInsertedSpy.count(), 0);
|
|
QCOMPARE(dataChangedSpy.count(), 1);
|
|
|
|
const auto args = dataChangedSpy.takeFirst();
|
|
const auto topLeft = args.at(0).toModelIndex();
|
|
const auto bottomRight = args.at(1).toModelIndex();
|
|
QCOMPARE(topLeft.row(), 0);
|
|
QCOMPARE(bottomRight.row(), 0);
|
|
QCOMPARE(topLeft.column(), int(DownloadTableModel::ColSize));
|
|
QCOMPARE(bottomRight.column(), int(DownloadTableModel::ColSpeed));
|
|
// The whole row must NOT be in the patch — Name/Queue stay untouched on a tick.
|
|
QVERIFY(topLeft.column() > int(DownloadTableModel::ColName));
|
|
|
|
QCOMPARE(model.index(0, DownloadTableModel::ColStatus).data().toString(),
|
|
QStringLiteral("60.0 %"));
|
|
}
|
|
|
|
void TstDownloadTableModel::progressForUnknownTaskIsIgnored() {
|
|
DownloadTableModel model;
|
|
model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
|
|
|
|
QSignalSpy dataChangedSpy(&model, &QAbstractItemModel::dataChanged);
|
|
model.applyProgress(QJsonArray{
|
|
QJsonObject{{"taskId", "ghost"}, {"downloadedBytes", 999.0}, {"speedBps", 1.0}}});
|
|
QCOMPARE(dataChangedSpy.count(), 0);
|
|
QCOMPARE(model.rowCount(), 1);
|
|
}
|
|
|
|
void TstDownloadTableModel::addThenRemove() {
|
|
DownloadTableModel model;
|
|
model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
|
|
|
|
QSignalSpy insertedSpy(&model, &QAbstractItemModel::rowsInserted);
|
|
model.applyTaskAdded(summary("c", "connecting", 4000, 0));
|
|
QCOMPARE(insertedSpy.count(), 1);
|
|
QCOMPARE(model.rowCount(), 2);
|
|
|
|
QSignalSpy removedSpy(&model, &QAbstractItemModel::rowsRemoved);
|
|
model.applyTaskRemoved(QStringLiteral("a"));
|
|
QCOMPARE(removedSpy.count(), 1);
|
|
QCOMPARE(model.rowCount(), 1);
|
|
QCOMPARE(model.index(0, 0).data(DownloadTableModel::TaskIdRole).toString(),
|
|
QStringLiteral("c"));
|
|
}
|
|
|
|
QTEST_GUILESS_MAIN(TstDownloadTableModel)
|
|
#include "tst_downloadtablemodel.moc"
|