CodeRigor

FREE LESSON

Qt models, QML and model tests

Write a QAbstractListModel that keeps every view in sync: the begin/end change protocol, the move-destination rule, precise dataChanged roles, invalid-index handling, QAbstractItemModelTester and QSignalSpy tests, and how the model reaches QML.

Every list in a Qt Quick application — the scene browser, a job queue, search results — is a ListView (or TableView, Repeater, GridView) drawing a model. The model is C++; the view and its delegates are QML. The two never share data structures: the view asks the model questions (rowCount(), data(index, role)) and the model announces changes through signals. Almost every model bug — the sidebar that shows a stale row, the selection that jumps to the neighbour, the crash right after a delete — is an announcement that doesn't match the answers that follow it.

Interviews for Qt roles probe exactly this: "write a list model", "what's the beginMoveRows destination when moving down?", "how do you test a model?", "how do you expose it to QML?". This lesson builds on qt-object-lifetime (QObject ownership, connections and their context objects) and teaches the model contract, its tests, and the QML side.

1. Model, view, delegate, role

Who talks to whom
The view asks the model for values by role and creates one delegate per visible row. The model never touches the view; it announces changes with signals. roleNames() is the dictionary that turns QML property names into role numbers.
Who talks to whom
The view asks the model for values by role and creates one delegate per visible row. The model never touches the view; it announces changes with signals. roleNames() is the dictionary that turns QML property names into role numbers.
  • A QModelIndex names one cell: row, column, parent (for trees) and the model it belongs to. It is a snapshot, valid until the model next changes. QModelIndex() (invalid) is the invisible root; the rows of a list are the root's children.
  • A role is an int saying which aspect of a cell you want: Qt::DisplayRole (the text), Qt::DecorationRole (an icon)… and your own from Qt::UserRole + 1 up. QML sees roles by name, through roleNames().
  • A QPersistentModelIndex is registered with the model, which updates it on every announced insert, remove and move — the view's selection and current item are persistent indexes.

For a flat list you subclass QAbstractListModel and implement three virtuals. For trivial data Qt also offers QStringListModel, QStandardItemModel and, since Qt 6.10, QRangeModel (wraps a C++ range without subclassing); you write your own when rows come from your data structures, change incrementally, or need custom roles for QML.

2. The read side: rowCount, data, roleNames

C++
#include <QAbstractListModel>
#include <QList>
#include <print>

struct Scene { QString name; int splats; };

class Scenes : public QAbstractListModel {
public:
    enum Role { NameRole = Qt::UserRole + 1, SplatCountRole };

    int rowCount(const QModelIndex& parent = QModelIndex()) const override {
        return parent.isValid() ? 0 : int(items_.size());    // list rows have no children
    }
    QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override {
        if (!checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid))
            return {};                                        // invalid, foreign or stale
        const Scene& s = items_.at(index.row());
        switch (role) {
            case Qt::DisplayRole:
            case NameRole: return s.name;
            case SplatCountRole: return s.splats;
            default: return {};
        }
    }
    QHash<int, QByteArray> roleNames() const override {
        auto names = QAbstractListModel::roleNames();         // keep "display", "decoration", …
        names.insert(NameRole, "name");
        names.insert(SplatCountRole, "splatCount");
        return names;
    }

    QList<Scene> items_{{"garden", 1200}, {"bicycle", 640}};
};

int main() {
    Scenes m;
    for (int r = 0; r < m.rowCount(); ++r)
        std::println("{}: {} splats", m.data(m.index(r)).toString().toStdString(),
                     m.data(m.index(r), Scenes::SplatCountRole).toInt());
    std::println("root has {} rows, row 0 has {} children", m.rowCount(), m.rowCount(m.index(0)));
    std::println("data(invalid) valid? {}", m.data(QModelIndex()).isValid());
    std::println("role {} is called '{}'", int(Scenes::SplatCountRole),
                 m.roleNames().value(Scenes::SplatCountRole).toStdString());
}

The rules that matter:

  • rowCount(validParent) is 0. That's what makes a list a list; without it a tree view (and QAbstractItemModelTester) would think every row has children.
  • data() never trusts its index. Views, proxies and QML hand you invalid indexes, indexes of another model (a proxy's), and stale ones taken before a removal. checkIndex(index, IndexIsValid | ParentIsInvalid) checks the model, the row/column range and the parent (and logs a warning — handy in a debug Q_ASSERT); a hand-written check must cover the same cases. Unknown roles return QVariant().
  • roleNames() starts from the base class. Building a fresh QHash silently deletes display & co. from QML.
  • Return the natural type: int for counts, QString for text. For an enum-like status, either register the enum with Q_ENUM (needs moc) or — as this skill's task does — return a stable string like "ready" that QML compares directly.

No Q_OBJECT here: a model that only overrides virtuals and uses the signals QAbstractItemModel already declares needs no moc. You need Q_OBJECT when you add your own signals, slots, Q_PROPERTYs or QML registration (§7).

3. Changing rows: begin, mutate, end

Views keep state derived from the model: delegates for the visible rows, a selection, a scroll position, persistent indexes. To keep that state right, every structural change is bracketed:

change announce with range
insert rows beginInsertRows(parent, first, last) … endInsertRows() the new rows' indexes, inclusive
remove rows beginRemoveRows(parent, first, last) … endRemoveRows() the doomed rows, inclusive
move rows beginMoveRows(src, first, last, dst, destinationChild) … endMoveRows() see §4
replace everything beginResetModel() … endResetModel() —
values of existing rows dataChanged(topLeft, bottomRight, roles) after the change

The begin call comes before the mutation — it emits rowsAboutToBeInserted/Removed/Moved, and connected views and proxies read the model's old state at that moment. The end call emits rowsInserted/… after it.

One insert, as the view sees it
The model's list changes strictly between the two signals. A view handling rowsAboutToBeInserted still sees 2 rows; handling rowsInserted, 5.
One insert, as the view sees it
The model's list changes strictly between the two signals. A view handling rowsAboutToBeInserted still sees 2 rows; handling rowsInserted, 5.

The ranges are inclusive: inserting n rows at first is (first, first + n − 1).

beginInsertRows(QModelIndex(), 2, 4)
Appending three scenes to a two-row list: the announced range is the indexes the new rows will have, first and last included.
beginInsertRows(QModelIndex(), 2, 4)
Appending three scenes to a two-row list: the announced range is the indexes the new rows will have, first and last included.

Here is the protocol observed from outside, the way a test (or a view) sees it:

C++
#include <QAbstractListModel>
#include <QStringList>
#include <print>

class Names : public QAbstractListModel {
public:
    int rowCount(const QModelIndex& p = {}) const override { return p.isValid() ? 0 : int(rows_.size()); }
    QVariant data(const QModelIndex& i, int role) const override {
        return checkIndex(i, CheckIndexOption::IndexIsValid) && role == Qt::DisplayRole ? QVariant(rows_.at(i.row()))
                                                                                       : QVariant();
    }
    void append(const QStringList& more) {
        if (more.isEmpty()) return;                      // nothing to announce
        const int first = int(rows_.size());
        beginInsertRows(QModelIndex(), first, first + int(more.size()) - 1);
        rows_ += more;
        endInsertRows();
    }
    bool remove(int row, int count) {
        if (row < 0 || count < 1 || row >= rowCount() || count > rowCount() - row) return false;
        beginRemoveRows(QModelIndex(), row, row + count - 1);
        rows_.remove(row, count);
        endRemoveRows();
        return true;
    }
private:
    QStringList rows_{"a", "b"};
};

int main() {
    Names m;
    auto show = [&](const char* what) {
        return [&m, what](const QModelIndex&, int first, int last) {
            std::println("{:<22} {}..{}  rowCount={}", what, first, last, m.rowCount());
        };
    };
    QObject::connect(&m, &QAbstractItemModel::rowsAboutToBeInserted, show("rowsAboutToBeInserted"));
    QObject::connect(&m, &QAbstractItemModel::rowsInserted, show("rowsInserted"));
    QObject::connect(&m, &QAbstractItemModel::rowsAboutToBeRemoved, show("rowsAboutToBeRemoved"));
    QObject::connect(&m, &QAbstractItemModel::rowsRemoved, show("rowsRemoved"));
    m.append({"c", "d", "e"});
    m.remove(1, 2);
    std::println("remove(1, INT_MAX) -> {}", m.remove(1, 2147483647));
}

Two habits worth copying. Validate first, announce second: a rejected call must emit nothing. And write range checks that can't overflow: count > rowCount() - row, never row + count > rowCount().

4. Moves and the "move down" rule

beginMoveRows(sourceParent, first, last, destinationParent, destinationChild) moves rows [first, last] so that they end up before row destinationChild — where destinationChild counts rows of the list as it is before the move. Moving up, that equals the final index. Moving down, it doesn't: the moved row leaves a gap above the destination, so the destination is the final index + 1.

move(1, 3): "b" should end at index 3
start / 3
Moving down, the destination is counted in the old list: "before old row 4". Passing the final index 3 would announce "before d" — one row short.
move(1, 3): "b" should end at index 3
Moving down, the destination is counted in the old list: "before old row 4". Passing the final index 3 would announce "before d" — one row short.

Two consequences you will hit in practice:

  • Destinations first and last + 1 mean "stay where you are". beginMoveRows returns false for them (and for destinations inside the moved range); if it returns false you must not call endMoveRows. So the buggy dest = to makes "move down by one" silently do nothing.
  • For longer downward moves the buggy destination doesn't fail — it makes Qt place persistent indexes one row above the data. The selection lands on the neighbour; only a persistent-index test catches it.
C++
#include <QAbstractListModel>
#include <QPersistentModelIndex>
#include <QStringList>
#include <print>

class Names : public QAbstractListModel {
public:
    int rowCount(const QModelIndex& p = {}) const override { return p.isValid() ? 0 : int(rows_.size()); }
    QVariant data(const QModelIndex& i, int role) const override {
        return checkIndex(i, CheckIndexOption::IndexIsValid) && role == Qt::DisplayRole ? QVariant(rows_.at(i.row()))
                                                                                       : QVariant();
    }
    // Move one row so that it ends at index `to` (like QList::move).
    bool move(int from, int to, bool buggy = false) {
        if (from < 0 || to < 0 || from >= rowCount() || to >= rowCount()) return false;
        if (from == to) return true;
        const int dest = (to > from && !buggy) ? to + 1 : to;
        if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), dest)) return false;
        rows_.move(from, to);
        endMoveRows();
        return true;
    }
    QString all() const { return rows_.join(""); }
private:
    QStringList rows_{"a", "b", "c", "d", "e"};
};

int main() {
    for (bool buggy : {false, true}) {
        Names m;
        QPersistentModelIndex selected(m.index(1));            // the view's selection: "b"
        bool down1 = m.move(0, 1, buggy);                       // a ↔ b
        std::println("{}: move(0,1) {} -> {}", buggy ? "dest = to    " : "dest = to + 1", down1, m.all().toStdString());
        m.move(1, 3, buggy);                                    // whatever is at 1 goes to 3
        std::println("{}  list {}  selection row {} shows '{}'", "             ", m.all().toStdString(),
                     selected.row(), m.data(selected, Qt::DisplayRole).toString().toStdString());
    }
}

5. dataChanged, roles, and when to reset

When values of existing rows change, emit dataChanged(topLeft, bottomRight, roles) after the change. The role list says which bindings to refresh:

  • The exact roles that changed — the goal. A QML delegate re-evaluates only the bindings that depend on those roles. A scene's splat count ticking up during loading shouldn't re-layout its name.
  • Empty list — "any role may have changed". Correct, but every binding of the row re-runs; fine for rare updates, wasteful for progress counters.
  • Too few roles — a real bug: the delegate's errorMessage text keeps its old value until scrolling recreates the delegate.
  • Nothing changed — emit nothing.

Compute the list by comparing old and new values field by field; a changed name is both Qt::DisplayRole and NameRole if both map to it. Coalesce: one dataChanged over a contiguous range beats one per row.

beginResetModel()/endResetModel() tells views to throw everything away: delegates, selection, current item, scroll position; every persistent index becomes invalid. It is right for "open a different project" and wrong for "one scene finished loading". Forgetting the pair entirely — items_ = newList; — is the classic silent bug: the view keeps showing old rows until something forces it to re-query. (layoutAboutToBeChanged/layoutChanged exist for re-sorting the same rows; proxies like QSortFilterProxyModel use them.)

6. Testing models

Three tools, all usable without a display.

QAbstractItemModelTester (QtTest) connects to every signal of a model and checks the contract: rowCount/data agree with what was announced, invalid indexes give invalid data, list rows have no children, ranges are consistent. In Fatal mode the first violation aborts the process; Warning logs; QtTest reports through QVERIFY inside a QtTest test function. Attach one to every model in your test suite — it catches mistakes that unit tests written by the same author miss.

QSignalSpy records each emission's arguments as a QList<QVariant>, so a test can assert the exact range, destination and roles.

QPersistentModelIndex in a test is the strongest move/remove check: Qt updates it from your announcements, so it exposes any mismatch with your data.

C++
#include <QAbstractListModel>
#include <QSignalSpy>
#include <QStringList>
#include <QtTest/QAbstractItemModelTester>
#include <print>

class Names : public QAbstractListModel {
public:
    enum { UpperRole = Qt::UserRole + 1 };
    int rowCount(const QModelIndex& p = {}) const override { return p.isValid() ? 0 : int(rows_.size()); }
    QVariant data(const QModelIndex& i, int role) const override {
        if (!checkIndex(i, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) return {};
        if (role == Qt::DisplayRole) return rows_.at(i.row());
        if (role == UpperRole) return rows_.at(i.row()).toUpper();
        return {};
    }
    void rename(int row, const QString& name) {
        if (row < 0 || row >= rowCount() || rows_[row] == name) return;
        rows_[row] = name;
        emit dataChanged(index(row), index(row), {Qt::DisplayRole, UpperRole});
    }
    void append(const QString& name) {
        beginInsertRows(QModelIndex(), rowCount(), rowCount());
        rows_ << name;
        endInsertRows();
    }
private:
    QStringList rows_{"garden"};
};

int main() {
    Names m;
    QAbstractItemModelTester tester(&m, QAbstractItemModelTester::FailureReportingMode::Fatal);
    QSignalSpy inserted(&m, &QAbstractItemModel::rowsInserted);
    QSignalSpy changed(&m, &QAbstractItemModel::dataChanged);

    m.append("bicycle");
    m.rename(1, "truck");
    m.rename(1, "truck");                                    // no change: no signal

    std::println("rowsInserted ×{}: first={} last={}", inserted.count(), inserted.at(0).at(1).toInt(),
                 inserted.at(0).at(2).toInt());
    auto roles = changed.at(0).at(2).value<QList<int>>();
    std::println("dataChanged ×{}: row {} roles {} {}", changed.count(),
                 changed.at(0).at(0).value<QModelIndex>().row(), roles.at(0), roles.at(1));
    std::println("the tester saw nothing wrong");
}

For application tests you'll use QtTest proper — a QObject subclass whose private slots are test functions, run by QTEST_MAIN (it needs moc, so it lives in the project, not the sandbox):

C++
class TestSceneListModel : public QObject {
    Q_OBJECT
private slots:
    void initTestCase() { qputenv("QT_QPA_PLATFORM", "offscreen"); }   // or set it in CTest
    void setStatus_data() {                                             // data-driven: a table…
        QTest::addColumn<int>("status");
        QTest::addColumn<QString>("message");
        QTest::addColumn<int>("emitted");
        QTest::newRow("ready")  << int(SceneStatus::Ready) << QString() << 1;
        QTest::newRow("error")  << int(SceneStatus::Error) << "bad header" << 1;
        QTest::newRow("same")   << int(SceneStatus::Loading) << QString() << 0;
    }
    void setStatus() {                                                  // …and a test per row
        QFETCH(int, status);
        QFETCH(QString, message);
        QFETCH(int, emitted);
        SceneListModel m;
        QAbstractItemModelTester tester(&m, QAbstractItemModelTester::FailureReportingMode::QtTest);
        m.append({SceneInfo{"garden"}});
        QSignalSpy spy(&m, &QAbstractItemModel::dataChanged);
        m.setStatus(0, SceneStatus(status), message);
        QCOMPARE(spy.count(), emitted);
    }
    void loadsAsynchronously() {
        SceneLoader loader;
        QSignalSpy done(&loader, &SceneLoader::finished);
        loader.load(QFINDTESTDATA("fixtures/garden.ply"));
        QVERIFY(done.wait(5000));            // returns as soon as the signal arrives
        QTRY_COMPARE(loader.model()->rowCount(), 1);   // polls with event processing, 5 s cap
    }
};
QTEST_MAIN(TestSceneListModel)
#include "tst_scenelistmodel.moc"

Asynchronous results arrive through the event loop, so tests must run it while waiting. QSignalSpy::wait(ms) and QTRY_COMPARE/QTRY_VERIFY do that and return as soon as the condition holds; QTest::qWait(ms) processes events for a fixed time and is for "let things settle", not for "wait for the result". The same mechanism in miniature — a timer stands in for a loader:

C++
#include <QCoreApplication>
#include <QSignalSpy>
#include <QStringListModel>
#include <QTimer>
#include <print>

int main() {
    int argc = 0;
    QCoreApplication app(argc, nullptr);
    QStringListModel model;
    QTimer::singleShot(0, &model, [&] { model.setStringList({"garden", "bicycle"}); });  // "async load"

    QSignalSpy reset(&model, &QAbstractItemModel::modelReset);
    std::println("before the event loop: rows={} resets={}", model.rowCount(), reset.count());
    bool arrived = reset.wait(5000);       // runs the event loop until modelReset (or 5 s)
    std::println("after wait: arrived={} rows={} resets={}", arrived, model.rowCount(), reset.count());
}

GUI-level tests (a QQuickView loading Main.qml, Qt Quick Test's TestCase items run by qmltestrunner) need a platform plugin; on a CI machine without a display run them with QT_QPA_PLATFORM=offscreen, set per test in CMake: set_tests_properties(tst_viewer PROPERTIES ENVIRONMENT QT_QPA_PLATFORM=offscreen).

7. Exposing the model to QML

QML needs moc-generated metadata, so this part lives in the project. A Qt 6 CMake setup declares a QML module; types marked QML_ELEMENT in its sources are registered automatically:

CMake
find_package(Qt6 6.8 REQUIRED COMPONENTS Quick)
qt_standard_project_setup(REQUIRES 6.8)          # AUTOMOC, sensible defaults

qt_add_executable(viewer main.cpp)
qt_add_qml_module(viewer
    URI Scene
    QML_FILES Main.qml SceneDelegate.qml
    SOURCES scenelistmodel.h scenelistmodel.cpp)
target_link_libraries(viewer PRIVATE Qt6::Quick splat::core)
C++
class SceneListModel : public QAbstractListModel {
    Q_OBJECT
    QML_ELEMENT                                        // usable as `SceneListModel {}` after `import Scene`
    Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
    explicit SceneListModel(QObject* parent = nullptr) : QAbstractListModel(parent) {
        connect(this, &QAbstractItemModel::rowsInserted, this, &SceneListModel::countChanged);
        connect(this, &QAbstractItemModel::rowsRemoved, this, &SceneListModel::countChanged);
        connect(this, &QAbstractItemModel::modelReset, this, &SceneListModel::countChanged);
    }
    int count() const { return rowCount(); }
    Q_INVOKABLE bool remove(int row, int count = 1);  // callable from QML
signals:
    void countChanged();
    // rowCount / data / roleNames / … as in §2–§5
};
  • Q_PROPERTY(… NOTIFY sig): QML bindings re-evaluate only when the NOTIFY signal fires. A property without one is read once — and qmllint warns. Emit it for every change that alters the value, and only then.
  • QML_ELEMENT (+ qt_add_qml_module) replaces the older qmlRegisterType<SceneListModel>("Scene", 1, 0, "SceneListModel") call in main(). For one shared instance use QML_SINGLETON, or pass it with QQmlApplicationEngine::setInitialProperties; setContextProperty still works but is invisible to tooling and discouraged.
  • Q_INVOKABLE methods and slots are callable from QML; keep them thin and forward to the C++ API you test.

The view side, with required properties in the delegate:

QML
import QtQuick
import QtQuick.Controls
import Scene

ListView {
    id: list
    model: SceneListModel { id: scenes }
    delegate: ItemDelegate {
        required property int index
        required property string name
        required property string status
        required property string errorMessage
        width: ListView.view.width
        text: status === "error" ? name + " — " + errorMessage : name
        highlighted: ListView.isCurrentItem
        onClicked: list.currentIndex = index
    }
    footer: Label { text: scenes.count + " scenes" }
}

A delegate that declares required properties gets them initialised from the roles with the same names (plus index, model, modelData from the view), and stops receiving the other roles as implicit context properties — so the role names in roleNames() are now a typed interface between C++ and QML. When the model emits dataChanged with StatusRole, the view updates status and the text binding re-runs.

Bindings and binding loops

text: status === "error" ? … is a binding: QML records which properties it read and re-evaluates it when any of them notifies. Two consequences:

  • Assigning to a bound property from JavaScript (width = 200 in a handler) removes the binding silently. Use Qt.binding(() => …) to install a new one, or restructure with states.
  • A cycle — width: label.width + 16 on the parent while the label has width: parent.width - 16 — makes QML log Binding loop detected for property "width" and settle on an evaluation-order-dependent value. Size one from the other, never both ways; implicit sizes (implicitWidth) are the usual one-way channel upwards.

Loading and error states

Anything that loads — a scene file, a thumbnail, a QML component — has three states, and the UI needs one for each:

Every load has three states
The model's status role, Image.status and Loader.status all follow this shape. Each state needs a UI: a busy indicator, the content, an error with a retry.
Every load has three states
The model's status role, Image.status and Loader.status all follow this shape. Each state needs a UI: a busy indicator, the content, an error with a retry.
QML
Image {
    asynchronous: true
    source: "image://thumbs/" + name             // served by a QQuickAsyncImageProvider
    BusyIndicator { anchors.centerIn: parent; running: parent.status === Image.Loading }
    Label { visible: parent.status === Image.Error; text: qsTr("no preview") }
}
Loader {
    id: details
    asynchronous: true                            // compile/instantiate off the critical path
    source: "SceneDetails.qml"
    onStatusChanged: if (status === Loader.Error) console.warn("details failed to load")
}

An async image provider keeps decoding off the GUI thread: QQuickAsyncImageProvider::requestImageResponse(id, requestedSize) returns a QQuickImageResponse* that does its work elsewhere (a thread pool) and emits finished(); on failure its errorString() is non-empty and the Image goes to Image.Error instead of showing a blank tile. Register it with engine.addImageProvider("thumbs", new ThumbnailProvider) (the engine takes ownership). Threading rules for the work itself are the subject of qt-background-work.

8. Shipping it

A Qt Quick app needs the Qt libraries, the platform plugin, and every QML module it imports at run time. Qt 6's CMake API generates the deployment step:

CMake
qt_generate_deploy_qml_app_script(
    TARGET viewer
    OUTPUT_SCRIPT deploy_script)
install(TARGETS viewer)
install(SCRIPT ${deploy_script})                 # runs at `cmake --install`

It scans the app's QML imports and installs what they need next to the binary. Outside CMake the same job is done by windeployqt (Windows), macdeployqt (macOS) and, for Linux AppImages, linuxdeploy with its Qt plugin. Test the result on a clean machine or container: "module QtQuick.Controls is not installed" on someone else's machine means the deployment, not the code, is broken.

Summary

  • A model answers (rowCount, data, roleNames) and announces (begin…/end…, dataChanged, reset). Views trust the announcements.
  • rowCount(validParent) == 0; data() rejects invalid, foreign, stale and out-of-range indexes; roleNames() extends the base class's names.
  • begin → mutate → end, with inclusive [first, last] ranges; validate before announcing; no overflow in range checks.
  • Moving down: destinationChild = to + 1. beginMoveRows returns false for no-op moves.
  • dataChanged lists exactly the changed roles; reset is the sledgehammer.
  • Test every model with QAbstractItemModelTester, QSignalSpy and persistent indexes; wait for signals, not for time; run GUI tests with QT_QPA_PLATFORM=offscreen.
  • QML: QML_ELEMENT + qt_add_qml_module, Q_PROPERTY with NOTIFY, required properties in delegates, no binding cycles, explicit loading/ready/error states; deploy with qt_generate_deploy_qml_app_script.

In the project

Ungraded practice in projects/scene-viewer/app/ (stage 2); the evidence is your own build and ctest --preset app output.

  1. Compare app/scene_list_model.cpp with your graded SceneListModel: which signals does each operation emit, with which roles?
  2. Add remove and move (the sidebar could reorder scenes) with exactly the right begin/end calls, and extend app/tests/tst_scene_list_model.cpp — it already runs QAbstractItemModelTester in QtTest mode.
  3. In app/qml/Main.qml, the delegate uses required properties and shows loading / error / ready states. Add a retry button for the error state that calls a new Q_INVOKABLE on the controller.
  4. Run the app's tests headless (the app test preset sets QT_QPA_PLATFORM=offscreen) and under ASan. Take a screenshot with SPLAT_SCREENSHOT=out.png on tst_qml_smoke.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

A guided index of KDAB's model/view episodes (proxy models, delegates, testing). Use it to pick the episodes that match what you're building.

Qt 6.10's QRangeModel wraps a C++ range as a model. Worth knowing in interviews — and when you still need your own QAbstractListModel.

The QML side: ListView, delegates and models, which the lesson shows only as fragments because the sandbox has no display.