gui: category tree, menus, toolbar, splitter — build-order step 3

- CategoryPanel: the left tree — All Downloads / Unfinished / Finished,
  then Categories and Queues populated from category.list / queue.list,
  with per-node task counts. Selecting a node emits a TaskSelection.
- DownloadFilterProxy: QSortFilterProxyModel keyed off that selection.
  Client-side for M1 (the whole list fits); asTaskFilter() exposes the
  equivalent TaskFilter for a server-side download.list once paging lands.
- MainWindow: menu bar (Tasks / Downloads / View / Help) sharing QAction
  objects with the toolbar; QSplitter [panel | table]; Delete with a
  confirm; Resume/Pause All; View menu toggles the panel. Actions
  disabled while offline.
- Counts are computed off a throttled 400 ms timer, not the 4 Hz progress
  path. Fixed a debounce-vs-throttle bug found in the first screenshot:
  restarting the timer on every progress tick meant it never fired and
  the status bar sat at "0 of 0 downloads".
- First-run column widths that fit the content.
- tst_downloadfilterproxy: nodes filter to their own rows, the
  Finished/Unfinished split is correct, and the filter is dynamic (a row
  that finishes leaves the Unfinished node with no re-list). Verified
  against mockd --tasks 400 (screenshot: tree counts 75/84/89/83/69 sum
  to 400, status bar "400 of 400, 21 active").

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd
This commit is contained in:
2026-09-10 15:41:29 +04:00
co-authored by Claude Sonnet 5
parent 170bcfdb3e
commit 51fa1201bd
11 changed files with 655 additions and 44 deletions
+10
View File
@@ -22,6 +22,16 @@ target_link_libraries(tst_downloadtablemodel PRIVATE Qt6::Core Qt6::Test)
add_test(NAME gui_downloadtablemodel COMMAND tst_downloadtablemodel)
set_tests_properties(gui_downloadtablemodel PROPERTIES LABELS "gui")
# tst_downloadfilterproxy — the category-tree filter.
# Red when: a node stops filtering to its own rows, the Finished/Unfinished split
# misclassifies a state, or the filter stops being dynamic.
add_executable(tst_downloadfilterproxy tst_downloadfilterproxy.cpp)
target_compile_features(tst_downloadfilterproxy PRIVATE cxx_std_23)
target_compile_options(tst_downloadfilterproxy PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_downloadfilterproxy PRIVATE velox-gui-lib Qt6::Test)
add_test(NAME gui_downloadfilterproxy COMMAND tst_downloadfilterproxy)
set_tests_properties(gui_downloadfilterproxy PROPERTIES LABELS "gui")
# tst_rtl — the real RTL check the GUI DoD asks for (the .ts stub alone verifies nothing).
# Red when: the main window stops propagating Qt::RightToLeft to its children, or the
# offline-banner layout is pinned so it does not mirror under RTL.
+93
View File
@@ -0,0 +1,93 @@
// DownloadFilterProxy unit tests. Lane GUI.
//
// Red when: a category-tree node stops filtering the table to its own rows, the
// Finished/Unfinished split misclassifies a state, or the filter stops being dynamic (a
// row that changes state mid-view lingers in the wrong node).
#include <QJsonArray>
#include <QJsonObject>
#include <QtTest>
#include "models/DownloadFilterProxy.hpp"
#include "models/DownloadTableModel.hpp"
using velox::gui::DownloadFilterProxy;
using velox::gui::DownloadTableModel;
using velox::gui::TaskSelection;
namespace {
QJsonObject task(const QString &id, const QString &state, const QString &category,
const QString &queue) {
return QJsonObject{
{"taskId", id},
{"filename", id + ".bin"},
{"url", "https://example.test/" + id},
{"state", state},
{"sizeBytes", 1000.0},
{"downloadedBytes", state == QLatin1String("complete") ? 1000.0 : 100.0},
{"categoryId", category},
{"queueId", queue},
{"createdAt", "2026-09-10T00:00:00Z"},
};
}
QJsonArray sample() {
return QJsonArray{
task("a", "downloading", "music", "main"), task("b", "complete", "music", ""),
task("c", "paused", "video", "main"), task("d", "complete", "video", ""),
task("e", "queued", "programs", "sync"),
};
}
} // namespace
class TstDownloadFilterProxy : public QObject {
Q_OBJECT
private slots:
void nodesFilterToTheirRows();
void filterIsDynamic();
};
void TstDownloadFilterProxy::nodesFilterToTheirRows() {
DownloadTableModel model;
model.resetFromJson(sample());
DownloadFilterProxy proxy;
proxy.setSourceModel(&model);
proxy.setSelection({TaskSelection::All, {}});
QCOMPARE(proxy.rowCount(), 5);
proxy.setSelection({TaskSelection::Finished, {}});
QCOMPARE(proxy.rowCount(), 2); // b, d
proxy.setSelection({TaskSelection::Unfinished, {}});
QCOMPARE(proxy.rowCount(), 3); // a, c, e
proxy.setSelection({TaskSelection::Category, QStringLiteral("music")});
QCOMPARE(proxy.rowCount(), 2); // a, b
proxy.setSelection({TaskSelection::Queue, QStringLiteral("main")});
QCOMPARE(proxy.rowCount(), 2); // a, c
proxy.setSelection({TaskSelection::Category, QStringLiteral("nope")});
QCOMPARE(proxy.rowCount(), 0);
}
void TstDownloadFilterProxy::filterIsDynamic() {
DownloadTableModel model;
model.resetFromJson(sample());
DownloadFilterProxy proxy;
proxy.setSourceModel(&model);
proxy.setSelection({TaskSelection::Unfinished, {}});
QCOMPARE(proxy.rowCount(), 3);
// "a" finishes — it must leave the Unfinished node without a re-list.
model.applyTaskState(
QJsonObject{{"taskId", "a"}, {"summary", task("a", "complete", "music", "main")}});
QCOMPARE(proxy.rowCount(), 2);
}
QTEST_GUILESS_MAIN(TstDownloadFilterProxy)
#include "tst_downloadfilterproxy.moc"