cpp26-reflection · part 39

228 Qt model edge cases, and the ones my models failed

· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

In short

  • The last post ended with a list of what was not tested. The answer to that list is a catalogue of 228 cases, each with a test and a verdict.
  • 170 work and 22 are refused by design. 27 were defects with a fix and a test now. 9 are still pinned by a test that passes while the gap exists.
  • The run found real defects in my own models. An empty editor can write an empty value back, and a model keeps stale rows when its container dies.
  • All of it is merged: eight pull requests, #19 to #26. The nine pinned cases are the ones I have not fixed.

How the cases were collected and checked

A case comes from the Qt documentation, the model tester, a view or proxy, or my own reading of the code. The row says which. A row names its test and a verdict. A script, run by ctest, fails on a blank cell, an unknown verdict, a missing test file or a duplicate id.

The tests do not trust my reading of Qt. A view or proxy is shown Qt’s own model under the same change. The two must agree on current index, selection, scroll position and signal counts. The references are QStringListModel and QStandardItemModel. A difference is the model’s fault and nothing else. Cases that must crash run in a forked child that has to die of SIGABRT.

category rows works refused fixed pinned
model protocol 67 50 12 5 0
re-entrancy and lifetime 23 9 0 13 1
proxies and selection 32 32 0 0 0
views and QML 27 24 1 1 1
data and types 32 23 2 5 2
threads 19 8 3 3 5
matrix and selection tracker 28 24 4 0 0

The last row belongs to the matrix and the selection tracker I ported from Leonardo Rossi’s spec, with the same kind of tests. Proxies and selection found no defect: every case behaves like Qt’s own models.

Defects a view can see

The protocol tests found five gaps in the models that a view can see. Three of them are about the data a row reads.

  • EditRole. The model said a cell was editable but answered nothing for Qt::EditRole. A default editor opens empty. Committing it without typing writes an empty value back to the entry.
  • A foreign index. The data and edit functions never checked the index. An index of another model, or column 1 of a table given to a list model, was read as the row with the same number.
  • A removal that reads empty. Qt wants the data of a removed row to be there while rowsAboutToBeRemoved is announced. A batch removal erased the entries first, so those rows read as empty. The spec I ported from had the same property, and I first took that for a deliberate choice. Its author has since said it was a mistake. The two bindings of that spec now announce a batch removal before they erase (see the follow-up).
  • Zero columns. A table with no columns emitted dataChanged with two invalid indexes.
  • QML. Nothing to bind a row count to: rowCount() has no notify signal, so a binding stayed stale while ListView.count followed.

Four are fixed now. The model answers the edit role. It refuses an index that is not its own. It keeps a snapshot of the rows of a batch removal until the runs are announced, and it announces nothing for zero columns. The snapshot has a price: a batch removal on a map or a hash copies each removed entry once. The QML case stays pinned: Qt gives a model no notify signal for rowCount(), so there is nothing to bind to.

Re-entrancy and lifetime

A receiver that changes the container while it is being announced is the case Qt’s documentation does not cover. I called every mutator from the about and done signal of every change, on a list and a map, each case in a forked child. Nothing refused a nested change. 15 of 100 list cases left the model out of step and 10 ended the process.

When the receiver was connected before the model, 19 of 180 cases crashed. A model created inside an about receiver crashed in 7 of 18, because it ended a change it had never begun. A model whose container was destroyed kept its stale rows and was never reset.

The fix is two rules. A mutator called while its own container announces returns false and asserts in a debug build. The model holds the container by QPointer and resets to empty when it is destroyed. After it, 959 of 1000 nested cases abort in debug and the rest cannot occur; in release none fail. One case stays pinned: a receiver that cuts the model’s connection after it began a change. The model cannot know the end will not come.

Keys, sizes and copies

  • NaN. A NaN key in a QMap<double> answered to every key. In a QHash<double> it could never be found again. Now it is refused.
  • std::less. Qt 6’s QMap orders by std::less<Key>, but my index searched with the less-than operator. Where the two disagree a ghost row appeared. Now both use std::less.
  • Rows are an int. A stand-in size of 2^31 gave a negative row count. One checked conversion now refuses a change that would pass INT_MAX.
  • A throwing copy. insertMany announced the change before copying the values. A copy that threw left a change that was announced and never confirmed. Now the values are copied first, while staging, and a throwing copy changes nothing.
  • Threads. A receiver on another thread reads the new state at an about signal. With assertions off the one-thread rule is not enforced. Both stay pinned until a change-record bridge exists.

What Qt itself does

Three behaviours are not the model’s, and each reproduces with QStringListModel. A single-selection view moves its current index to the neighbour of a removed current row and selects it. ListView keeps its currentIndex number across a reset. A QItemSelectionModel that outlives a deleted model crashed in this Qt 6.10 image, flakily. A web search found no bug report for it.

What this does not show

  • Open: 9 cases are pinned. One is the QML row count, two are in data and types, five are thread cases and one is the cut connection.
  • Mutation: the first 31 mutants of the matrix code were killed. I did not rerun the full set of 102 after the re-entrancy guard, so the container’s older mutants are unchecked.
  • Not tested: a real mouse drag and drop, QtQuick Controls views, locale-aware ordering beyond the C locale, a data race. Clang and MSVC are not tried for the new code.
  • Provenance: AI agents wrote most of the tests, one category each, under my direction. I reviewed their reports and the pinned defects, not every line.

Where to read it

The catalogue is in docs/qt-edge-cases. The tests are in tests/qt/edge beside it. The pull requests are #19 (tests), #20 (re-entrancy), #21 (keys), #22 (matrix and selection), #23, #24 (mutation and CRAP) and #26 (protocol fixes), all merged.