13#include <QAbstractItemModelTester>
15#include <QRandomGenerator>
17#include <QSortFilterProxyModel>
18#include <QStandardItemModel>
30 constexpr int IdRole = Qt::UserRole + 1;
32 constexpr auto TagMimeType =
"x-leechcraft/tag";
34 struct TagsStub final : ITagsManager
36 tag_id GetID (
const QString& tag)
override {
return tag; }
37 QString GetTag (tag_id
id)
const override {
return id; }
38 QStringList GetAllTags ()
const override {
return {}; }
39 QStringList Split (
const QString&
string)
const override {
return string.split (
';', Qt::SkipEmptyParts); }
40 QList<tag_id> SplitToIDs (
const QString&
string)
override {
return Split (
string); }
41 QString
Join (
const QStringList& tags)
const override {
return tags.join (
"; "); }
42 QString JoinIDs (
const QStringList& ids)
const override {
return Join (ids); }
43 QAbstractItemModel* GetModel ()
override {
return nullptr; }
44 QObject* GetQObject ()
override {
return nullptr; }
47 struct Source : QStandardItemModel
49 using QStandardItemModel::persistentIndexList;
51 mutable QList<int> LastDraggedIds_;
52 std::optional<QByteArray> LastDropTag_;
54 void Append (
int id,
const QString& text,
const QStringList& tags)
56 const auto first =
new QStandardItem { text };
57 first->setData (
id, IdRole);
58 first->setData (tags, TagsRole);
59 appendRow ({ first,
new QStandardItem { QString::number (
id * 10) } });
62 QMimeData* mimeData (
const QModelIndexList& indexes)
const override
64 LastDraggedIds_.clear ();
65 for (
const auto& index : indexes)
66 LastDraggedIds_ << index.data (IdRole).toInt ();
67 std::ranges::sort (LastDraggedIds_);
68 return QStandardItemModel::mimeData (indexes);
71 bool dropMimeData (
const QMimeData *data, Qt::DropAction,
int,
int,
const QModelIndex&)
override
73 LastDropTag_ = data->hasFormat (TagMimeType) ?
74 std::optional { data->data (TagMimeType) } :
80 struct Filter : QSortFilterProxyModel
82 using QSortFilterProxyModel::persistentIndexList;
90 QAbstractItemModel& Upstream_;
91 FlatToFoldersProxyModel Proxy_ { &Tags_ };
92 QAbstractItemModelTester Tester_ { &Proxy_, QAbstractItemModelTester::FailureReportingMode::QtTest };
94 explicit Fixture (
bool viaFilter)
95 : Upstream_ { viaFilter ? static_cast<QAbstractItemModel&> (Filter_) : Source_ }
97 Filter_.setSourceModel (&Source_);
102 Source_.Append (1,
"a", {
"t1" });
103 Source_.Append (2,
"b", {
"t1",
"t2" });
104 Source_.Append (3,
"c", {});
109 Proxy_.SetSourceModel (&Upstream_);
112 int UpstreamPersistentCount ()
const
114 return (&Upstream_ == &Filter_ ? Filter_.persistentIndexList () : Source_.persistentIndexList ()).size ();
120 QSignalSpy Inserted_;
126 explicit Spies (QAbstractItemModel& model)
127 : Inserted_ { &model, &QAbstractItemModel::rowsInserted }
128 , Removed_ { &model, &QAbstractItemModel::rowsRemoved }
129 , Reset_ { &model, &QAbstractItemModel::modelReset }
130 , Layout_ { &model, &QAbstractItemModel::layoutChanged }
131 , Data_ { &model, &QAbstractItemModel::dataChanged }
135 int Structural ()
const
137 return Inserted_.size () + Removed_.size () + Reset_.size () + Layout_.size ();
141 QStringList TagsOf (
const QModelIndex& index)
143 auto tags = index.data (TagsRole).toStringList ();
149 using Picture = QMap<QString, QSet<int>>;
151 Picture Expected (
const QAbstractItemModel& upstream)
154 for (
int row = 0; row < upstream.rowCount (); ++row)
156 const auto& index = upstream.index (row, 0);
157 for (
const auto& tag : TagsOf (index))
158 result [tag] << index.data (IdRole).toInt ();
163 QString Describe (
const Picture& picture)
166 for (
const auto& [tag, ids] : picture.asKeyValueRange ())
168 auto sorted = ids.values ();
169 std::ranges::sort (sorted);
170 QStringList idStrings;
171 for (
const auto id : sorted)
172 idStrings << QString::number (
id);
173 folders << (tag.isEmpty () ? QStringLiteral (
"<untagged>") : tag) +
" {" + idStrings.join (
' ') +
'}';
175 return folders.join (
"; ");
181 for (
int f = 0;
f < proxy.rowCount (); ++
f)
183 const auto& folder = proxy.index (f, 0);
185 for (
int l = 0; l < proxy.rowCount (folder); ++l)
186 ids << QString::number (proxy.index (l, 0, folder).data (IdRole).toInt ());
187 result << folder.data (TagsRole).toString () +
": " + ids.join (
' ');
194 QCOMPARE (proxy.columnCount (), upstream.columnCount ());
198 for (
int f = 0;
f < proxy.rowCount (); ++
f)
200 const auto& folderIdx = proxy.index (f, 0);
201 QVERIFY (proxy.IsFolder (folderIdx));
202 QCOMPARE (proxy.parent (folderIdx), QModelIndex {});
203 if (upstream.columnCount () > 1)
204 QCOMPARE (proxy.rowCount (folderIdx.siblingAtColumn (1)), 0);
206 const auto& tag = folderIdx.data (TagsRole).toString ();
207 QVERIFY2 (!actual.contains (tag), qPrintable (
"duplicate folder for tag " + tag));
208 QVERIFY2 (proxy.rowCount (folderIdx) > 0, qPrintable (
"empty folder for tag " + tag));
210 auto& ids = actual [tag];
211 for (
int l = 0; l < proxy.rowCount (folderIdx); ++l)
214 const auto& leafIdx = proxy.index (l, 0, folderIdx);
215 QVERIFY (!proxy.IsFolder (leafIdx));
216 QCOMPARE (proxy.rowCount (leafIdx), 0);
217 QCOMPARE (proxy.parent (leafIdx), folderIdx);
219 const auto& source = proxy.MapToSource (leafIdx);
220 QVERIFY (source.isValid ());
221 QCOMPARE (source.model (), &upstream);
222 QVERIFY (TagsOf (source).contains (tag));
223 QVERIFY (proxy.MapFromSource (source).contains (leafIdx));
224 for (
int col = 0; col < upstream.columnCount (); ++col)
225 QCOMPARE (proxy.index (l, col, folderIdx).data (), source.siblingAtColumn (col).data ());
227 ids << leafIdx.data (IdRole).toInt ();
231 const auto& expected = Expected (upstream);
232 QVERIFY2 (actual == expected,
233 qPrintable (
"tree is " + Describe (actual) +
", source says " + Describe (expected)));
235 int expectedLeaves = 0;
236 for (
int row = 0; row < upstream.rowCount (); ++row)
237 expectedLeaves += TagsOf (upstream.index (row, 0)).size ();
238 QCOMPARE (leaves, expectedLeaves);
241 QModelIndex Parent (
const QList<QVariant>& signalArgs)
243 return signalArgs.at (0).value<QModelIndex> ();
247 void FlatToFoldersProxyModelTest::initTestCase ()
250 qunsetenv (
"LC_MODEL_SANITIZER_MODE");
253 void FlatToFoldersProxyModelTest::initTestCase_data ()
255 QTest::addColumn<bool> (
"viaFilter");
256 QTest::newRow (
"direct source") <<
false;
257 QTest::newRow (
"through a QSortFilterProxyModel") <<
true;
260 void FlatToFoldersProxyModelTest::testInitialTree ()
262 QFETCH_GLOBAL (
bool, viaFilter);
263 Fixture
f { viaFilter };
266 CheckTree (
f.Proxy_,
f.Upstream_);
268 QCOMPARE (Dump (
f.Proxy_), (QStringList {
"t1: 1 2",
"t2: 2",
": 3" }));
269 QCOMPARE (
f.Proxy_.headerData (0, Qt::Horizontal, Qt::DisplayRole),
f.Upstream_.headerData (0, Qt::Horizontal, Qt::DisplayRole));
270 QCOMPARE (
f.Proxy_.flags ({}), Qt::ItemIsDropEnabled);
273 void FlatToFoldersProxyModelTest::testInsertCreatesFoldersOnDemand ()
275 QFETCH_GLOBAL (
bool, viaFilter);
276 Fixture
f { viaFilter };
279 Spies spies {
f.Proxy_ };
281 f.Source_.Append (4,
"d", {
"t3" });
282 QCOMPARE (spies.Inserted_.size (), 2);
283 QCOMPARE (Parent (spies.Inserted_.at (0)), QModelIndex {});
284 QVERIFY (Parent (spies.Inserted_.at (1)).isValid ());
286 f.Source_.Append (5,
"e", {
"t1" });
287 QCOMPARE (spies.Inserted_.size (), 3);
288 QCOMPARE (spies.Structural (), 3);
289 CheckTree (
f.Proxy_,
f.Upstream_);
292 void FlatToFoldersProxyModelTest::testRetagKeepsTheSurvivingLeaf ()
294 QFETCH_GLOBAL (
bool, viaFilter);
295 Fixture
f { viaFilter };
298 Spies spies {
f.Proxy_ };
300 f.Source_.item (1)->setData (QStringList {
"t2",
"t3" }, TagsRole);
302 QCOMPARE (spies.Data_.size (), 1);
303 const auto& args = spies.Data_.first ();
304 const auto& topLeft = args.at (0).value<QModelIndex> ();
305 QCOMPARE (topLeft.data (IdRole).toInt (), 2);
306 QCOMPARE (topLeft.parent ().data (TagsRole).toString (), QString {
"t2" });
307 QCOMPARE (args.at (1).value<QModelIndex> ().column (),
f.Upstream_.columnCount () - 1);
308 QCOMPARE (args.at (2).value<QList<int>> (), (QList<int> { TagsRole }));
310 QCOMPARE (spies.Removed_.size (), 1);
311 QCOMPARE (spies.Inserted_.size (), 2);
312 CheckTree (
f.Proxy_,
f.Upstream_);
315 void FlatToFoldersProxyModelTest::testRemovingTheLastLeafRemovesTheFolder ()
317 QFETCH_GLOBAL (
bool, viaFilter);
318 Fixture
f { viaFilter };
321 Spies spies {
f.Proxy_ };
323 f.Source_.removeRow (2);
325 QCOMPARE (spies.Removed_.size (), 2);
326 QVERIFY (Parent (spies.Removed_.at (0)).isValid ());
327 QCOMPARE (Parent (spies.Removed_.at (1)), QModelIndex {});
328 QCOMPARE (
f.Proxy_.rowCount (), 2);
329 CheckTree (
f.Proxy_,
f.Upstream_);
332 void FlatToFoldersProxyModelTest::testRemovalFollowsOwnBookkeeping ()
334 QFETCH_GLOBAL (
bool, viaFilter);
335 Fixture
f { viaFilter };
339 f.Source_.blockSignals (
true);
340 f.Source_.item (2)->setData (QStringList {
"zz" }, TagsRole);
341 f.Source_.blockSignals (
false);
342 f.Source_.removeRow (2);
344 QCOMPARE (
f.Proxy_.rowCount (), 2);
345 CheckTree (
f.Proxy_,
f.Upstream_);
348 void FlatToFoldersProxyModelTest::testSourceSortLeavesTheTreeAlone ()
350 QFETCH_GLOBAL (
bool, viaFilter);
351 Fixture
f { viaFilter };
354 const auto& before = Dump (
f.Proxy_);
355 Spies spies {
f.Proxy_ };
357 f.Source_.sort (0, Qt::DescendingOrder);
359 QCOMPARE (spies.Layout_.size (), 0);
360 QCOMPARE (spies.Structural (), 0);
361 QCOMPARE (Dump (
f.Proxy_), before);
362 CheckTree (
f.Proxy_,
f.Upstream_);
365 void FlatToFoldersProxyModelTest::testSourceResetIsOneReset ()
367 QFETCH_GLOBAL (
bool, viaFilter);
368 Fixture
f { viaFilter };
371 Spies spies {
f.Proxy_ };
374 QCOMPARE (spies.Reset_.size (), 1);
375 QCOMPARE (spies.Structural (), 1);
376 QCOMPARE (
f.Proxy_.rowCount (), 0);
378 f.Source_.Append (4,
"d", {
"t1",
"t2" });
379 QCOMPARE (spies.Inserted_.size (), 4);
380 CheckTree (
f.Proxy_,
f.Upstream_);
383 void FlatToFoldersProxyModelTest::testAttachAndDetach ()
385 QFETCH_GLOBAL (
bool, viaFilter);
386 Fixture
f { viaFilter };
388 Spies spies {
f.Proxy_ };
391 QCOMPARE (spies.Reset_.size (), 1);
392 QCOMPARE (spies.Structural (), 1);
393 QCOMPARE (
f.Proxy_.rowCount (), 3);
394 CheckTree (
f.Proxy_,
f.Upstream_);
396 f.Proxy_.SetSourceModel (
nullptr);
397 QCOMPARE (spies.Reset_.size (), 2);
398 QCOMPARE (spies.Structural (), 2);
399 QCOMPARE (
f.Proxy_.rowCount (), 0);
400 QCOMPARE (
f.Proxy_.columnCount (), 0);
401 QCOMPARE (
f.Proxy_.GetSourceModel (),
nullptr);
403 f.Source_.Append (4,
"d", {
"t9" });
404 QCOMPARE (spies.Structural (), 2);
407 void FlatToFoldersProxyModelTest::testDragAndDropMapThroughFolders ()
409 QFETCH_GLOBAL (
bool, viaFilter);
410 Fixture
f { viaFilter };
413 const auto& t1 =
f.Proxy_.index (0, 0);
415 std::unique_ptr<QMimeData> folderDrag {
f.Proxy_.mimeData ({ t1 }) };
416 QVERIFY (folderDrag);
417 QCOMPARE (
f.Source_.LastDraggedIds_, (QList<int> { 1, 2 }));
419 std::unique_ptr<QMimeData> leafDrag {
f.Proxy_.mimeData ({
f.Proxy_.index (1, 0, t1) }) };
421 QCOMPARE (
f.Source_.LastDraggedIds_, (QList<int> { 2 }));
424 payload.setText (
"payload");
425 QVERIFY (
f.Proxy_.dropMimeData (&payload, Qt::CopyAction, -1, -1, t1));
426 QVERIFY (
f.Source_.LastDropTag_.has_value ());
427 QCOMPARE (*
f.Source_.LastDropTag_, QByteArray {
"t1" });
429 QVERIFY (
f.Proxy_.dropMimeData (&payload, Qt::CopyAction, -1, -1, {}));
430 QVERIFY (!
f.Source_.LastDropTag_.has_value ());
433 void FlatToFoldersProxyModelTest::testReleasesUpstreamIndexes ()
435 QFETCH_GLOBAL (
bool, viaFilter);
436 Fixture
f { viaFilter };
438 const auto baseline =
f.UpstreamPersistentCount ();
441 other.Append (7,
"x", {
"t1" });
443 FlatToFoldersProxyModel proxy { &
f.Tags_ };
444 QAbstractItemModelTester tester { &proxy, QAbstractItemModelTester::FailureReportingMode::QtTest };
446 proxy.SetSourceModel (&
f.Upstream_);
447 QCOMPARE (
f.UpstreamPersistentCount (), baseline + 3);
449 proxy.SetSourceModel (&other);
450 QCOMPARE (
f.UpstreamPersistentCount (), baseline);
452 proxy.SetSourceModel (&
f.Upstream_);
453 QCOMPARE (
f.UpstreamPersistentCount (), baseline + 3);
455 QCOMPARE (
f.UpstreamPersistentCount (), baseline);
458 void FlatToFoldersProxyModelTest::testRandomWalk ()
460 QFETCH_GLOBAL (
bool, viaFilter);
461 Fixture
f { viaFilter };
464 constexpr quint32 seed = 20261017;
465 QRandomGenerator gen { seed };
466 const QStringList pool {
"t1",
"t2",
"t3",
"t4" };
467 const auto randomTags = [&]
470 for (
const auto& tag : pool)
471 if (!gen.bounded (3))
475 const auto randomText = [&] {
return QString { QChar {
'a' +
static_cast<int> (gen.bounded (6)) } }; };
478 for (
int step = 0; step < 400; ++step)
480 const auto rows =
f.Source_.rowCount ();
481 switch (rows ? gen.bounded (6) : 0)
484 f.Source_.Append (nextId++, randomText (), randomTags ());
487 f.Source_.removeRow (gen.bounded (rows));
490 f.Source_.item (gen.bounded (rows))->setData (randomTags (), TagsRole);
493 f.Source_.item (gen.bounded (rows))->setText (randomText ());
497 f.Source_.sort (0, gen.bounded (2) ? Qt::AscendingOrder : Qt::DescendingOrder);
499 f.Filter_.setFilterFixedString (gen.bounded (2) ? QString {} : randomText ());
502 if (!gen.bounded (20))
507 CheckTree (
f.Proxy_,
f.Upstream_);
508 if (QTest::currentTestFailed ())
510 qWarning () <<
"random walk failed at step" << step <<
"with seed" << seed <<
"upstream:" << Describe (Expected (
f.Upstream_));
void Append(Res &result, T &&val) noexcept
constexpr detail::ExprTree< detail::ExprType::LeafStaticPlaceholder, detail::MemberPtrs< Ptr > > f
Container< T > Filter(const Container< T > &c, F f)
constexpr auto Join(auto &&) noexcept