From 1873e4a96e712547dede39a111d73e44f8f26c53 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 20:59:18 +0200 Subject: [PATCH 1/4] kanban(gui): the board was 3px wide and its right-hand columns unreachable At the shipped 1280x860 window the board area measured 3 pixels and the Activity panel 1237. Three things compounded: The Activity panel is a ColumnLayout inside a RowLayout, and a layout nested in a layout fills width by default -- `Layout.preferredWidth: 280` does not stop it. Its implicit width is the widest unwrapped activity summary, so it took the whole row. It is now `fillWidth: false` with a 200..320 band, which puts it at 280 and leaves the board 960. `boardRoot` is a bare Item, implicit width 0, with `fillWidth: true` and no floor, so there was nothing to stop that. It now carries a `Layout.minimumWidth` of one column plus the gaps either side -- below that the first column is cut in half and no window size rescues it. The columns were laid out in a bare Row inside an Item with `clip: true` and no scroll container anywhere in the file, so a strip wider than the viewport was not merely cut off but unreachable: a four-column board already needs 984 against the 960 it gets. The strip now lives in a Flickable with an always-on horizontal ScrollBar, sized from the column count rather than from the Row's implicit width, since a Row is a positioner and stretching it moves nothing inside it. The two numbers the strip is built from -- 240 wide, 8 apart -- are now named once on `boardRoot` and read by the delegate, the Row's spacing, the floor and the content width. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- examples/kanban/gui/qml/BoardView.qml | 405 +++++++++++++++----------- 1 file changed, 237 insertions(+), 168 deletions(-) diff --git a/examples/kanban/gui/qml/BoardView.qml b/examples/kanban/gui/qml/BoardView.qml index 5ce6f4612..f0434deb6 100644 --- a/examples/kanban/gui/qml/BoardView.qml +++ b/examples/kanban/gui/qml/BoardView.qml @@ -64,6 +64,7 @@ import MorphForms Item { id: page + objectName: "boardView" property var boardBridge: null property var projectAdminBridge: null @@ -362,199 +363,258 @@ Item { // ── Board area: one section per swimlane (or one flat row) ── Item { id: boardRoot + objectName: "boardArea" Layout.fillWidth: true Layout.fillHeight: true clip: true - ColumnLayout { + // A column delegate is a fixed 240 wide, so a board area + // narrower than one column plus the gaps around it shows half a + // column and no amount of scrolling makes it usable. This floor + // is what the Activity panel beside it has to yield to. + Layout.minimumWidth: boardRoot.columnWidth + 2 * boardRoot.columnSpacing + + /// The column delegate's fixed width, and the gap between two of + /// them. Named here because the strip's total extent -- the width + /// the board must be able to scroll across -- is computed from + /// them, and the delegate below reads the same two numbers. + readonly property int columnWidth: 240 + readonly property int columnSpacing: 8 + + /// The width every column laid end to end needs. Computed from + /// the column count rather than read off the Row's implicit + /// width: a Row is a positioner, so a layout that stretches it + /// moves nothing inside it, and its implicit width is only right + /// once every delegate has been built. + readonly property int stripExtent: page.columns.length > 0 + ? page.columns.length * boardRoot.columnWidth + + (page.columns.length - 1) * boardRoot.columnSpacing + : 0 + + // Without this the columns past the right edge are drawn beyond + // boardRoot's clip and there is nothing to reach them with: a + // four-column board already overflows the ~960 this area gets at + // the default window size. + Flickable { + id: boardFlickable + objectName: "boardFlickable" anchors.fill: parent - spacing: 8 - - Repeater { - model: page.laneModel - - delegate: ColumnLayout { - id: laneSection - required property var modelData - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 4 - - Label { - visible: laneSection.modelData.showHeader - font.bold: true - text: laneSection.modelData.name - } + // Keeps the strip clear of the bar rather than under it. + anchors.bottomMargin: boardScrollBar.visible ? boardScrollBar.height : 0 + clip: true + contentWidth: Math.max(boardRoot.stripExtent, width) + contentHeight: height + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + + // A bar that is actually on screen, not just a flick gesture: + // a desktop pointer has no surface to flick, so an off-screen + // column would otherwise still be unreachable. + ScrollBar.horizontal: ScrollBar { + id: boardScrollBar + objectName: "boardScrollBar" + policy: boardFlickable.contentWidth > boardFlickable.width + ? ScrollBar.AlwaysOn + : ScrollBar.AsNeeded + } - Row { + ColumnLayout { + // Sized from the flickable rather than anchored to it: a + // Flickable's children are parented to its contentItem, whose + // geometry is not the content size, and this layout has to be + // as wide as the strip it holds rather than as wide as the + // viewport that strip is seen through. + width: boardFlickable.contentWidth + height: boardFlickable.height + spacing: 8 + + Repeater { + model: page.laneModel + + delegate: ColumnLayout { + id: laneSection + required property var modelData Layout.fillWidth: true Layout.fillHeight: true - spacing: 8 - - Repeater { - model: page.columns - - delegate: Rectangle { - id: columnDelegate - required property var modelData - width: 240 - height: laneSection.height - (laneSection.modelData.showHeader ? 24 : 0) - border.width: 2 - border.color: dropArea.containsDrag - ? (columnDelegate.atWipLimit ? "#d33" : "steelblue") - : "transparent" - color: palette.base - - readonly property var columnTasks: - page.tasksFor(columnDelegate.modelData.id, laneSection.modelData.id) - readonly property bool atWipLimit: - columnDelegate.modelData.wipLimit > 0 - && columnDelegate.columnTasks.length >= columnDelegate.modelData.wipLimit - - ColumnLayout { - anchors.fill: parent - anchors.margins: 4 - spacing: 4 - - Label { - Layout.fillWidth: true - font.bold: true - elide: Text.ElideRight - text: columnDelegate.modelData.name + " (" - + (columnDelegate.modelData.wipLimit === 0 - ? String(columnDelegate.columnTasks.length) - : columnDelegate.columnTasks.length + "/" - + columnDelegate.modelData.wipLimit) - + ")" - } + spacing: 4 - DynamicForm { - id: createTaskForm - objectName: "createTaskForm_" - + columnDelegate.modelData.id - Layout.fillWidth: true - actionType: "CreateTask" - schema: page.schemas["CreateTask"] || ({}) - controller: page.boardBridge - - // The two hidden context - // fields (see this file's - // header comment). Seeded on - // creation and re-seeded after - // every successful submit, - // since resetFields() clears - // hidden fields too. - function bindContext() { - setFieldValue("columnId", - String(columnDelegate.modelData.id)) - setFieldValue("swimlaneId", - String(laneSection.modelData.id)) - } + Label { + visible: laneSection.modelData.showHeader + font.bold: true + text: laneSection.modelData.name + } - function rebind() { - resetFields() - bindContext() + Row { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: boardRoot.columnSpacing + + Repeater { + model: page.columns + + delegate: Rectangle { + id: columnDelegate + objectName: "boardColumn_" + columnDelegate.modelData.id + required property var modelData + width: boardRoot.columnWidth + height: laneSection.height - (laneSection.modelData.showHeader ? 24 : 0) + border.width: 2 + border.color: dropArea.containsDrag + ? (columnDelegate.atWipLimit ? "#d33" : "steelblue") + : "transparent" + color: palette.base + + readonly property var columnTasks: + page.tasksFor(columnDelegate.modelData.id, laneSection.modelData.id) + readonly property bool atWipLimit: + columnDelegate.modelData.wipLimit > 0 + && columnDelegate.columnTasks.length >= columnDelegate.modelData.wipLimit + + ColumnLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 4 + + Label { + Layout.fillWidth: true + font.bold: true + elide: Text.ElideRight + text: columnDelegate.modelData.name + " (" + + (columnDelegate.modelData.wipLimit === 0 + ? String(columnDelegate.columnTasks.length) + : columnDelegate.columnTasks.length + "/" + + columnDelegate.modelData.wipLimit) + + ")" } - Component.onCompleted: { - createTaskForm.bindContext() - page.registerTaskForm(createTaskForm) + DynamicForm { + id: createTaskForm + objectName: "createTaskForm_" + + columnDelegate.modelData.id + Layout.fillWidth: true + actionType: "CreateTask" + schema: page.schemas["CreateTask"] || ({}) + controller: page.boardBridge + + // The two hidden context + // fields (see this file's + // header comment). Seeded on + // creation and re-seeded after + // every successful submit, + // since resetFields() clears + // hidden fields too. + function bindContext() { + setFieldValue("columnId", + String(columnDelegate.modelData.id)) + setFieldValue("swimlaneId", + String(laneSection.modelData.id)) + } + + function rebind() { + resetFields() + bindContext() + } + + Component.onCompleted: { + createTaskForm.bindContext() + page.registerTaskForm(createTaskForm) + } + Component.onDestruction: page.unregisterTaskForm(createTaskForm) } - Component.onDestruction: page.unregisterTaskForm(createTaskForm) - } - ListView { - id: taskList - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: columnDelegate.columnTasks - - delegate: Rectangle { - id: card - required property var modelData - width: taskList.width - height: 48 - radius: 4 - color: palette.alternateBase - border.width: 1 - border.color: palette.mid - - // Reparented to the - // board's root Item for - // the duration of the - // drag so it visually - // floats above the - // columns (§6.2 step 1). - Drag.active: dragHandler.active - Drag.dragType: Drag.Internal - Drag.hotSpot.x: width / 2 - Drag.hotSpot.y: height / 2 - - DragHandler { - id: dragHandler - target: card - - onActiveChanged: { - if (active) { - const inBoard = card.mapToItem( - boardRoot, 0, 0) - card.parent = boardRoot - card.x = inBoard.x - card.y = inBoard.y - } else { - card.Drag.drop() - card.parent = taskList.contentItem + ListView { + id: taskList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: columnDelegate.columnTasks + + delegate: Rectangle { + id: card + required property var modelData + width: taskList.width + height: 48 + radius: 4 + color: palette.alternateBase + border.width: 1 + border.color: palette.mid + + // Reparented to the + // board's root Item for + // the duration of the + // drag so it visually + // floats above the + // columns (§6.2 step 1). + Drag.active: dragHandler.active + Drag.dragType: Drag.Internal + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + + DragHandler { + id: dragHandler + target: card + + onActiveChanged: { + if (active) { + const inBoard = card.mapToItem( + boardRoot, 0, 0) + card.parent = boardRoot + card.x = inBoard.x + card.y = inBoard.y + } else { + card.Drag.drop() + card.parent = taskList.contentItem + } } } - } - TapHandler { - onTapped: { - if (!dragHandler.active) { - page.openTaskPopup(card.modelData) + TapHandler { + onTapped: { + if (!dragHandler.active) { + page.openTaskPopup(card.modelData) + } } } - } - Label { - anchors.fill: parent - anchors.margins: 6 - elide: Text.ElideRight - text: card.modelData.title + Label { + anchors.fill: parent + anchors.margins: 6 + elide: Text.ElideRight + text: card.modelData.title + } } } } - } - DropArea { - id: dropArea - anchors.fill: parent - - onDropped: (drop) => { - // §6.2 step 3: destination - // column/swimlane come from - // this DropArea; destination - // position is the nearest - // index within the - // destination list to the - // drop's own y. - const destTasks = columnDelegate.columnTasks - const dropY = drop.y - let position = destTasks.length - for (let i = 0; i < destTasks.length; ++i) { - if (dropY < (i + 0.5) * 48) { - position = i - break + DropArea { + id: dropArea + anchors.fill: parent + + onDropped: (drop) => { + // §6.2 step 3: destination + // column/swimlane come from + // this DropArea; destination + // position is the nearest + // index within the + // destination list to the + // drop's own y. + const destTasks = columnDelegate.columnTasks + const dropY = drop.y + let position = destTasks.length + for (let i = 0; i < destTasks.length; ++i) { + if (dropY < (i + 0.5) * 48) { + position = i + break + } + } + if (page.boardBridge && drop.source + && drop.source.modelData) { + page.boardBridge.moveTask( + String(drop.source.modelData.id), + String(columnDelegate.modelData.id), + String(laneSection.modelData.id), + position) } - } - if (page.boardBridge && drop.source - && drop.source.modelData) { - page.boardBridge.moveTask( - String(drop.source.modelData.id), - String(columnDelegate.modelData.id), - String(laneSection.modelData.id), - position) } } } @@ -569,7 +629,16 @@ Item { // ── Activity panel: GetActivity's stream, refreshed on the same // poll tick as the board (design spec §7) ────────────────── ColumnLayout { + objectName: "activityPanel" + // A ColumnLayout nested in a RowLayout fills width by default, + // and this one's implicit width is the widest unwrapped + // activity summary -- so left to itself it takes the whole row + // and the board beside it is squeezed to nothing. It is a + // sidebar: it gets a fixed band, the board gets the rest. + Layout.fillWidth: false + Layout.minimumWidth: 200 Layout.preferredWidth: 280 + Layout.maximumWidth: 320 Layout.fillHeight: true spacing: 4 From d7c3a3c07431faebb2b0e4b4f839e4e8f752870d Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 20:59:31 +0200 Subject: [PATCH 2/4] tests(kanban): measure the board's width and its columns' reachability A layout that "looks right" is the weakest evidence there is, and an offscreen run cannot look at anything -- so this measures instead. It loads Main.qml with both bridges attached, so the widths under test are the ones the real shell produces (the 1280x860 window, its margins, the StackView, BoardView's own margins), and reaches the board by evaluating `stack.push(boardPage)` in Main's own context -- verbatim the statement its `onProjectOpened` handler runs, so no input event is synthesized. Five columns are created through the bridge's typed entry point, which is 1232px of strip against a 960px board area. Asserted: the Activity panel stays within a sidebar's width; the board area holds at least one whole column; the strip is genuinely wider than the area (otherwise the reachability check would prove nothing); a horizontal scrollbar is on screen; and the last column, which starts outside the viewport, is wholly inside it once scrolled to the end. The floor is then checked again at a 500px window, the only size at which it is what decides the split rather than the sidebar's own bound. Each of the three fixes was removed in turn against this test: dropping the Flickable leaves it unfound; dropping the floor puts the board at 180 of the 256 it needs at 500px; dropping the sidebar's bound puts Activity at 648. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- examples/kanban/tests/test_board_layout.cpp | 328 ++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 examples/kanban/tests/test_board_layout.cpp diff --git a/examples/kanban/tests/test_board_layout.cpp b/examples/kanban/tests/test_board_layout.cpp new file mode 100644 index 000000000..b46f815c3 --- /dev/null +++ b/examples/kanban/tests/test_board_layout.cpp @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// BoardView's column strip, measured rather than eyeballed. +// +// The board is the one screen in this rung whose content has a hard minimum +// width: every column delegate is a fixed 240px Rectangle, so a board with N +// columns needs N*240 + (N-1)*8 pixels laid end to end, and no amount of +// window resizing makes that number smaller. Everything else on this rung's +// screens is elastic -- labels elide, list delegates take their view's width, +// forms wrap -- so nothing else can become unreachable by being too narrow. +// +// Three properties are asserted here, at the *shipped* window size rather than +// at whatever size an item loaded as a bare root happens to get: +// +// 1. The Activity panel beside the board stays a sidebar, rather than +// expanding to whatever its widest activity summary wants. +// 2. A board wider than the area it is drawn in is *reachable*: the column +// strip scrolls horizontally, and the last column can be brought fully +// into view. +// 3. The board area never collapses below one usable column. Asserted at the +// shipped width and again at a width where the sidebar and the board +// cannot both have what they ask for, which is the only size at which +// that floor decides anything. +// +// Reachability, not mere existence, is the point. A column that is laid out +// past a clipping edge with no scroll container in front of it is drawn +// nowhere and can be scrolled to by nothing -- the board is not "partly +// visible" in that state, its right-hand columns are simply gone. +// +// Why Main.qml and not BoardView on its own. The widths under test are the +// ones the real shell produces: the 1280x860 window, its 8px margins, the +// StackView inside them, and BoardView's own 8px margins inside that. Loading +// BoardView as a bare root object would size it from its own implicit width +// and measure a screen the user never sees. So this loads Main.qml with both +// bridges attached and then runs `stack.push(boardPage)` -- which is verbatim +// the statement Main.qml's own `onProjectOpened` handler runs -- through +// QQmlExpression in Main's own context. No synthesized input events +// (examples/TESTING.md presenter rule 6): the expression is the handler's +// body, not a click on the thing that would invoke it. +// +// Runs under QT_QPA_PLATFORM=offscreen, against the QGuiApplication +// testkit_main.cpp owns when this rung's test binary is built. Compiled away +// entirely without MORPH_LADDER_QML_URI, i.e. in a configure with no +// MORPH_BUILD_FORMS_QML, exactly like the two QML suites beside it. + +#ifdef MORPH_LADDER_QML_URI + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "board_qml_bridge.hpp" +#include "project_admin_qml_bridge.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// The column delegate's fixed width, as BoardView.qml declares it. +constexpr qreal kColumnWidth = 240; + +/// The gap between two column delegates, as BoardView.qml declares it. +constexpr qreal kColumnSpacing = 8; + +/// Enough columns that the strip cannot fit the board area at any sane window +/// size: five of them need 1232px, against the ~960px the 1280px-wide shipped +/// window leaves once its own margins and the Activity sidebar are taken out. +constexpr int kColumnCount = 5; + +/// A window too narrow to give the board a whole column once the Activity +/// sidebar has taken its 280: 500 - 32 (the two nested 8px margin rings) - 8 +/// (the row's spacing) - 280 leaves 180, well under one column. Something has +/// to give at this size, and the floor is what decides which -- the board +/// keeps a usable column and the sidebar is the one that runs off the edge. +constexpr int kNarrowWindowWidth = 500; + +/// @brief A rig whose one bridge already carries a valid session for +/// @p principal — the same recipe test_gui_forms_render.cpp uses. +/// @param principal The identity to install. +/// @return The rig, owning the bridge and executor the adapters take. +[[nodiscard]] std::unique_ptr makeAuthedRig(std::string principal) { + auto rig = std::make_unique(Mode::Local, 1); + morph::session::Context ctx; + ctx.principal = std::move(principal); + rig->bridge(0).setDefaultSession(ctx); + return rig; +} + +/// @brief Seeds one project (the rig's principal is its Manager) straight +/// through `ProjectAdminModel`'s own handler. +/// @param rig The rig whose bridge/executor to dispatch the seed through. +/// @return The new project's id, as its plain number. +[[nodiscard]] qlonglong seedProject(BackendRig& rig) { + morph::bridge::BridgeHandler creator{rig.bridge(0), rig.executor()}; + const auto id = morph::ladder::testkit::awaitQt(creator.execute(kanban::CreateProject{.name = "Sprint Board"})).id; + return id.hasValue() ? static_cast(*id) : -1; +} + +/// @brief Depth-first search of the *visual* item tree under @p root for an +/// item with @p name as its `objectName`. +/// +/// `QObject::findChild` is not enough for anything a `Repeater` created — a +/// delegate's visual parent is inside the tree while its `QObject` parent is +/// not — and BoardView's column delegates are exactly that, two Repeaters +/// deep. +/// @param root The item to search under (searched itself first). +/// @param name The `objectName` to find. +/// @return The item, or `nullptr`. +[[nodiscard]] QQuickItem* findItem(QQuickItem* root, const QString& name) { + if (root == nullptr) { + return nullptr; + } + if (root->objectName() == name) { + return root; + } + const QList kids = root->childItems(); + for (QQuickItem* kid : kids) { + if (QQuickItem* hit = findItem(kid, name); hit != nullptr) { + return hit; + } + } + return nullptr; +} + +/// @brief Whether @p item lies wholly inside @p viewport's own bounds. +/// +/// The test of "reachable": an item scrolled into view has both edges within +/// the viewport it is clipped by. A half-pixel slack absorbs the layout's own +/// rounding, which can leave a scrolled-to-end position a fraction short. +/// @param item The item to place. +/// @param viewport The clipping item to place it against. +/// @return `true` when no part of @p item falls outside @p viewport. +[[nodiscard]] bool fullyInside(QQuickItem* item, QQuickItem* viewport) { + if (item == nullptr || viewport == nullptr) { + return false; + } + const QPointF topLeft = item->mapToItem(viewport, QPointF{0, 0}); + return topLeft.x() >= -0.5 && topLeft.x() + item->width() <= viewport->width() + 0.5; +} + +/// @brief Loads @p typeName from this rung's QML module with @p properties set +/// as initial properties, asserting a root object was produced and no +/// warning this rung is responsible for was emitted. +/// +/// @par The one tolerated warning +/// `DynamicForm.qml` declares `onOptionsReceived` in a `Connections` block +/// whose `target` is the controller, unconditionally, and a controller that +/// serves no `morph::forms::Choice` field has no such signal. Every conforming +/// controller in the ladder therefore warns once per form the moment a real +/// controller is attached. Tolerated by exact text rather than by dropping the +/// assertion, so any *other* warning still fails — see +/// test_gui_forms_render.cpp's own note on the same filter. +/// @param engine The engine to load into (kept alive by the caller). +/// @param typeName Unqualified QML type name within `MORPH_LADDER_QML_URI`. +/// @param properties Initial properties for the root object. +/// @return The root object. +[[nodiscard]] QObject* loadRoot(QQmlApplicationEngine& engine, const char* typeName, const QVariantMap& properties) { + QStringList unexpected; + QObject::connect(&engine, &QQmlApplicationEngine::warnings, [&unexpected](const QList& warnings) { + for (const QQmlError& warning : warnings) { + const QString text = warning.toString(); + if (text.contains(QStringLiteral("onOptionsReceived")) && + text.contains(QStringLiteral("MorphForms/qml/DynamicForm.qml"))) { + continue; + } + unexpected.append(text); + } + }); + engine.setInitialProperties(properties); + engine.loadFromModule(MORPH_LADDER_QML_URI, typeName); + INFO(unexpected.join(QStringLiteral("\n")).toStdString()); + CHECK(unexpected.isEmpty()); + REQUIRE_FALSE(engine.rootObjects().isEmpty()); + return engine.rootObjects().front(); +} + +} // namespace + +TEST_CASE("BoardView keeps a usable board area and keeps every column reachable", "[kanban][gui][qml-layout]") { + DbFixture fixture; + auto rig = makeAuthedRig("alice"); + const qlonglong projectId = seedProject(*rig); + REQUIRE(projectId > 0); + + kanban::gui::ProjectAdminBridge adminBridge{rig->bridge(0), rig->executor()}; + kanban::gui::BoardBridge bridge{rig->bridge(0), rig->executor()}; + + bool boardChanged = false; + QObject::connect(&bridge, &kanban::gui::BoardBridge::boardChanged, [&boardChanged] { boardChanged = true; }); + auto settle = [&boardChanged] { + const bool arrived = pumpUntil([&boardChanged] { return boardChanged; }); + boardChanged = false; + return arrived; + }; + + bridge.openBoard(QString::number(projectId)); + REQUIRE(settle()); + + // A board too wide for any viewport this window can offer. Built through + // the bridge's own typed entry point, so the widths measured below are + // over columns the model really has. + for (int i = 0; i < kColumnCount; ++i) { + bridge.createColumn(QStringLiteral("Column %1").arg(i + 1), 0); + REQUIRE(settle()); + } + bridge.stopPolling(); // Deterministic: nothing may re-enter while widths are read. + + const QVariantList columns = bridge.board().value(QStringLiteral("columns")).toList(); + REQUIRE(columns.size() == kColumnCount); + const QString lastColumnId = columns.back().toMap().value(QStringLiteral("id")).toString(); + + QQmlApplicationEngine engine; + QObject* root = loadRoot(engine, "Main", + {{QStringLiteral("projectAdminBridge"), QVariant::fromValue(&adminBridge)}, + {QStringLiteral("boardBridge"), QVariant::fromValue(&bridge)}}); + auto* window = qobject_cast(root); + REQUIRE(window != nullptr); + + // The shipped size, read off the window rather than restated here: a + // change to Main.qml's `width` is a change to what this test measures. + const qreal windowWidth = window->width(); + REQUIRE(windowWidth > 0); + + // Exactly what Main.qml's own onProjectOpened handler does once a project + // is chosen. `stack` and `boardPage` are ids in Main.qml's root context, + // which is the context this expression is evaluated in. + QQmlExpression pushBoard{qmlContext(root), root, QStringLiteral("stack.push(boardPage)")}; + pushBoard.evaluate(); + REQUIRE_FALSE(pushBoard.hasError()); + + QQuickItem* boardView = nullptr; + REQUIRE(pumpUntil([&] { + boardView = qobject_cast(window->findChild(QStringLiteral("boardView"))); + return boardView != nullptr && boardView->width() > 0; + })); + + QQuickItem* boardArea = findItem(boardView, QStringLiteral("boardArea")); + QQuickItem* activityPanel = findItem(boardView, QStringLiteral("activityPanel")); + REQUIRE(boardArea != nullptr); + REQUIRE(activityPanel != nullptr); + + // Let the layout reach its final geometry: the polish pass that sizes the + // row's two children runs on the window's own frame, not inside push(). + REQUIRE(pumpUntil([&] { return boardArea->width() > 0 && activityPanel->width() > 0; })); + + INFO("window " << windowWidth << ", board area " << boardArea->width() << ", activity " << activityPanel->width()); + + // 1. The sidebar stays a sidebar. A ColumnLayout inside a RowLayout fills + // width by default, so without an explicit bound Activity competes with + // the board for space rather than taking its declared 280. + CHECK(activityPanel->width() <= 320); + + // 2. The board area never collapses below one whole column plus the gaps + // around it. Below that the first column is cut in half and there is no + // width at which the board is usable. + CHECK(boardArea->width() >= kColumnWidth + 2 * kColumnSpacing); + + // 3. The strip really is wider than the area it is drawn in -- otherwise + // the reachability assertion below would pass on a board that never + // needed scrolling, and prove nothing. + const qreal stripExtent = kColumnCount * kColumnWidth + (kColumnCount - 1) * kColumnSpacing; + CHECK(stripExtent > boardArea->width()); + + QQuickItem* flick = findItem(boardArea, QStringLiteral("boardFlickable")); + REQUIRE(flick != nullptr); + CHECK(flick->property("contentWidth").toReal() >= stripExtent); + + // 4. The horizontal scrollbar is on screen, so the strip can be dragged + // and not merely flicked at. + QQuickItem* scrollBar = findItem(boardView, QStringLiteral("boardScrollBar")); + REQUIRE(scrollBar != nullptr); + CHECK(scrollBar->isVisible()); + + // 5. The last column starts out of view -- the condition scrolling has to + // rescue -- and scrolling to the end brings it wholly into view. + QQuickItem* lastColumn = findItem(boardView, QStringLiteral("boardColumn_") + lastColumnId); + REQUIRE(lastColumn != nullptr); + // The `Row` holding the strip positions its children on the window's + // polish pass, not when the delegate is built, so every column reads x=0 + // until that has run once. Waiting for the last one to reach its place is + // what makes the two assertions below measure a laid-out strip. + const qreal lastColumnX = (kColumnCount - 1) * (kColumnWidth + kColumnSpacing); + REQUIRE(pumpUntil([&] { return lastColumn->x() >= lastColumnX - 0.5; })); + CHECK_FALSE(fullyInside(lastColumn, flick)); + + const qreal contentWidth = flick->property("contentWidth").toReal(); + flick->setProperty("contentX", QVariant{contentWidth - flick->width()}); + REQUIRE(pumpUntil([&] { return fullyInside(lastColumn, flick); })); + CHECK(fullyInside(lastColumn, flick)); + + // 6. And the floor is load-bearing on its own, not merely implied by the + // sidebar's bound. At the shipped width there is room for both, so + // narrow the window until there is not -- the size at which the floor + // is the only thing deciding that the board is not what gives way. + const qreal boardAreaWhenWide = boardArea->width(); + window->setWidth(kNarrowWindowWidth); + REQUIRE(pumpUntil([&] { return boardView->width() > 0 && boardView->width() <= kNarrowWindowWidth; })); + // The row re-runs on the next polish pass, not inside setWidth, so wait for + // the board to have moved off its wide-window size rather than reading last + // frame's numbers back. + REQUIRE(pumpUntil([&] { return boardArea->width() < boardAreaWhenWide; })); + INFO("narrow window " << window->width() << ", board area " << boardArea->width() << ", activity " + << activityPanel->width()); + CHECK(boardArea->width() >= kColumnWidth + 2 * kColumnSpacing); +} + +#endif // MORPH_LADDER_QML_URI From 8b06c9007bcadc69d9b99d2e3124447b5e761969 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 22:44:26 +0200 Subject: [PATCH 3/4] tests(kanban): three clang-tidy findings in the new board-layout test The layout test landed without the clang-tidy gate having been run over it, so three findings on changed lines reached CI: readability-identifier-length `id` is shorter than the three-character floor misc-no-recursion `findItem` calls itself (precedence) `a + 2 * b` wants the multiplication bracketed The first and third are renames and a pair of brackets. The second is not: the function walks a QQuickItem tree of unbounded depth, and an iterative version would hand-roll the same stack with none of the clarity, so it carries a suppression that says why rather than a rewrite that pretends the shape is different. No assertion, bound or measured value changes. The test still fails against each of the three mutations it was written to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- examples/kanban/tests/test_board_layout.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/kanban/tests/test_board_layout.cpp b/examples/kanban/tests/test_board_layout.cpp index b46f815c3..baaf9c68f 100644 --- a/examples/kanban/tests/test_board_layout.cpp +++ b/examples/kanban/tests/test_board_layout.cpp @@ -113,8 +113,9 @@ constexpr int kNarrowWindowWidth = 500; /// @return The new project's id, as its plain number. [[nodiscard]] qlonglong seedProject(BackendRig& rig) { morph::bridge::BridgeHandler creator{rig.bridge(0), rig.executor()}; - const auto id = morph::ladder::testkit::awaitQt(creator.execute(kanban::CreateProject{.name = "Sprint Board"})).id; - return id.hasValue() ? static_cast(*id) : -1; + const auto projectId = + morph::ladder::testkit::awaitQt(creator.execute(kanban::CreateProject{.name = "Sprint Board"})).id; + return projectId.hasValue() ? static_cast(*projectId) : -1; } /// @brief Depth-first search of the *visual* item tree under @p root for an @@ -127,6 +128,9 @@ constexpr int kNarrowWindowWidth = 500; /// @param root The item to search under (searched itself first). /// @param name The `objectName` to find. /// @return The item, or `nullptr`. +// Recursion matches the shape of the thing being searched: a QQuickItem tree of +// unbounded depth, where an iterative walk would hand-roll the same stack. +// NOLINTNEXTLINE(misc-no-recursion) [[nodiscard]] QQuickItem* findItem(QQuickItem* root, const QString& name) { if (root == nullptr) { return nullptr; @@ -274,7 +278,7 @@ TEST_CASE("BoardView keeps a usable board area and keeps every column reachable" // 2. The board area never collapses below one whole column plus the gaps // around it. Below that the first column is cut in half and there is no // width at which the board is usable. - CHECK(boardArea->width() >= kColumnWidth + 2 * kColumnSpacing); + CHECK(boardArea->width() >= kColumnWidth + (2 * kColumnSpacing)); // 3. The strip really is wider than the area it is drawn in -- otherwise // the reachability assertion below would pass on a board that never From 57a5c3fc4ffb8bfe69b6eeffa4ead5edb6f438e7 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 23 Sep 2026 23:16:44 +0200 Subject: [PATCH 4/4] tests(kanban): bracket the remaining mixed arithmetic the layout test builds its bounds from Three further `readability-math-missing-parentheses` findings on changed lines, in the two expressions that compute the strip extent and the minimum usable board width. The earlier pass fixed one occurrence and did not look for the rest, which is why this is a second commit rather than an amendment. (kColumnCount * kColumnWidth) + ((kColumnCount - 1) * kColumnSpacing) kColumnWidth + (2 * kColumnSpacing) Brackets only -- the operator precedence C++ already applies is what is now written down, so every bound the test asserts keeps its value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- examples/kanban/tests/test_board_layout.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/kanban/tests/test_board_layout.cpp b/examples/kanban/tests/test_board_layout.cpp index baaf9c68f..4004ea65c 100644 --- a/examples/kanban/tests/test_board_layout.cpp +++ b/examples/kanban/tests/test_board_layout.cpp @@ -283,7 +283,7 @@ TEST_CASE("BoardView keeps a usable board area and keeps every column reachable" // 3. The strip really is wider than the area it is drawn in -- otherwise // the reachability assertion below would pass on a board that never // needed scrolling, and prove nothing. - const qreal stripExtent = kColumnCount * kColumnWidth + (kColumnCount - 1) * kColumnSpacing; + const qreal stripExtent = (kColumnCount * kColumnWidth) + ((kColumnCount - 1) * kColumnSpacing); CHECK(stripExtent > boardArea->width()); QQuickItem* flick = findItem(boardArea, QStringLiteral("boardFlickable")); @@ -326,7 +326,7 @@ TEST_CASE("BoardView keeps a usable board area and keeps every column reachable" REQUIRE(pumpUntil([&] { return boardArea->width() < boardAreaWhenWide; })); INFO("narrow window " << window->width() << ", board area " << boardArea->width() << ", activity " << activityPanel->width()); - CHECK(boardArea->width() >= kColumnWidth + 2 * kColumnSpacing); + CHECK(boardArea->width() >= kColumnWidth + (2 * kColumnSpacing)); } #endif // MORPH_LADDER_QML_URI