diff --git a/cmake/tsan.supp b/cmake/tsan.supp index f5c31fa94..6efdec67f 100644 --- a/cmake/tsan.supp +++ b/cmake/tsan.supp @@ -89,11 +89,11 @@ # disconnect test is simply the first thing in the tree that exercises the # path hard enough to trip it. # -# ── Why these three frames, and why not more ────────────────────────────── +# ── Why these four frames, and why not more ─────────────────────────────── # # Each names a frame TSan resolves *by symbol* rather than by debug info, # which is what makes them survive the runner's stripped `libstdc++.so.6`. -# All three are confirmed resolvable there: the first two appear by name in +# All four are confirmed resolvable there: the first two appear by name in # the CI report this file was written against # (`std::__exception_ptr::exception_ptr::_M_release() # (libstdc++.so.6+0xc2f8c)` -- a resolved name with an unresolved file, @@ -109,6 +109,17 @@ # `std::runtime_error::~runtime_error` is likewise exported and likewise # appears resolved by name in CI's own report. # +# The fourth line is the same shape for the other standard exception base +# whose message lives in a COW string: `std::logic_error`. CI reported it from +# `morph_client_only_facade`, whose `LocalBackend` rejects with a +# `std::logic_error` that a pool thread releases last while the main thread's +# `onError` has just read its `what()` -- a write stack going straight from +# `~logic_error`, resolved by name, to `CompletionState::~CompletionState`. +# A race-free standalone control (one `exception_ptr` copied to a second +# thread, the main thread reading `what()` and dropping its copy, the other +# dropping the last one, nothing else ordering them) reproduces that report on +# Linux with libstdc++. +# # Deliberately *not* suppressed, though an earlier draft had them: # `__cxa_free_exception` (in no measured report against this tree) and # `std::string::_Rep::_M_dispose` (an inlined libstdc++ internal that would @@ -118,8 +129,10 @@ # involving that type. # # These are narrow on purpose. A genuine race elsewhere in morph does not -# destroy a `std::runtime_error` or touch `exception_ptr`'s refcount, and a -# planted control race was confirmed still reported with this file active. +# destroy a `std::runtime_error` or `std::logic_error`, or touch +# `exception_ptr`'s refcount, and a planted control race was confirmed still +# reported with this file active. race:std::__exception_ptr::exception_ptr::_M_release race:std::__exception_ptr::__dest_thunk race:std::runtime_error::~runtime_error +race:std::logic_error::~logic_error diff --git a/examples/client/main.cpp b/examples/client/main.cpp index 09c4c489b..1e039dd31 100644 --- a/examples/client/main.cpp +++ b/examples/client/main.cpp @@ -49,8 +49,10 @@ namespace { namespace examples = morph::examples; namespace ui = morph::ui; +#if MORPH_CLIENT_HAS_QT_QUICK /// How long `--grab` lets the window settle after the first mount. constexpr std::chrono::milliseconds kGrabSettle{1000}; +#endif /// `--grab`: where the window's image goes, and the window, once the Qt Quick frontend made it. struct Grab { @@ -104,6 +106,7 @@ bool saveGrab(Grab const& grab) { } return true; #else + static_cast(grab); std::cerr << "client: --grab draws the Qt Quick window, and this client is built without it\n"; return false; #endif diff --git a/include/morph/ui/agent.hpp b/include/morph/ui/agent.hpp index 959a60e5f..125f40800 100644 --- a/include/morph/ui/agent.hpp +++ b/include/morph/ui/agent.hpp @@ -443,7 +443,8 @@ class McpAdapter { return; } Json const* const given = member(params, "arguments"); - Json arguments = given == nullptr ? Json{Json::object_t{}} : *given; + // Parentheses: braces would pick the initializer-list constructor and make `[{}]`. + Json arguments = given == nullptr ? Json(Json::object_t{}) : *given; if (!std::holds_alternative(arguments.data)) { done(std::unexpected(std::string{"the arguments are not an object"})); return; diff --git a/include/morph/ui/interpreter.hpp b/include/morph/ui/interpreter.hpp index a4e013f66..b8bbe5e90 100644 --- a/include/morph/ui/interpreter.hpp +++ b/include/morph/ui/interpreter.hpp @@ -6534,7 +6534,8 @@ inline Node ScreenCore::lowerForm(DocNode const& node, LowerCtx const& ctx, Elem if (text.starts_with("s:")) { text.remove_prefix(2); } - return forms::detail::engine::Json{std::string{text}}; + // Parentheses: braces would pick the initializer-list constructor and make a one-element array. + return forms::detail::engine::Json(std::string{text}); } if (text.starts_with("i:")) { text.remove_prefix(2); @@ -6547,7 +6548,8 @@ inline Node ScreenCore::lowerForm(DocNode const& node, LowerCtx const& ctx, Elem ((type == "int" || type == "number") && json->is_number()) || (type == "exact" && (json->is_number() || json->is_string())); if (!fits) { - return std::unexpected("it is not " + std::string{type == "int" ? "an " : "a "} + std::string{type}); + bool const vowel = type == "int" || type == "exact"; + return std::unexpected("it is not " + std::string{vowel ? "an " : "a "} + std::string{type}); } return *std::move(json); } diff --git a/include/morph/ui/mount.hpp b/include/morph/ui/mount.hpp index 93ddeddf3..12c4b802b 100644 --- a/include/morph/ui/mount.hpp +++ b/include/morph/ui/mount.hpp @@ -924,8 +924,7 @@ class Mounter { widget.setOnSelect(inputEvent(spec.onSelect, reassert)); pages.onSelect = inputEvent(spec.onSelect, std::move(reassert)); auto show = [this, &widget, &pageScope, &pages, pageSpecs = spec.tabs, overlay = _overlay](std::size_t index) { - underOverlay(overlay, - [&] { _rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index); }); }); + _rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index, overlay); }); }; if (!spec.selected.isBound()) { show(spec.selected.constant()); @@ -939,13 +938,16 @@ class Mounter { // state (focus, scroll position, half-typed text) across tab switches. A new page is complete before anything // else changes: one that fails to mount leaves the previous page shown and selected, and the bar's highlight, // which the user's pick may already have moved, goes back to it. The next change of the index mounts again. + // + // Only the mount runs under the tabs' overlay, and that overlay is put back before a failure is reported: + // reporting it may destroy the Mounted, and with it this Mounter. void showPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, std::vector const& pageSpecs, - std::size_t index) { + std::size_t index, ContainerWidget* overlay) { bool const exists = index < pageSpecs.size(); bool const isNew = exists && pages.pageSlots.at(index) == nullptr; if (isNew) { try { - mountPage(widget, pageScope, pages, pageSpecs.at(index).node, index); + underOverlay(overlay, [&] { mountPage(widget, pageScope, pages, pageSpecs.at(index).node, index); }); } catch (...) { if (pages.shown.has_value()) { restoreSelection(widget, pages, *pages.shown); diff --git a/scripts/branch_partial_allowlist.json b/scripts/branch_partial_allowlist.json index fe3c9b09d..72d4b3ca7 100644 --- a/scripts/branch_partial_allowlist.json +++ b/scripts/branch_partial_allowlist.json @@ -85,13 +85,13 @@ }, { "file": "include/morph/util/rational.hpp", - "line": 1498, + "line": 1504, "source": "if (ctx.begin() == ctx.end() || *ctx.begin() == '}') {", "reason": "The ctx.begin() == ctx.end() true arm is unreachable. This is the textbook cppreference-style custom-formatter parse() idiom. Empirically verified on this toolchain (libc++, via a standalone probe compiled with clang++ -std=c++23 -stdlib=libc++): for both std::format(\"{}\", x) and std::format(\"{:}\", x), ctx.end() always points past the terminating '}', so ctx.begin() == ctx.end() is false and the terminator is always reachable via *ctx.begin() == '}' -- matching the observed 0/14 split exactly. std::format's top-level parser (and std::vformat's, which performs the same replacement-field validation before dispatching to a type's parse()) rejects an unterminated '{...' before ever calling into formatter::parse, so this function is never invoked with an already-exhausted range. The first disjunct is defensive boilerplate that this standard library's implementation (and the standard's own guarantee about validated replacement fields) makes structurally unreachable." }, { "file": "include/morph/util/rational.hpp", - "line": 1407, + "line": 1413, "source": "if (!detail::addOverflows(whole, step)) {", "reason": "The false arm (the overflow-would-occur case, declining to step) is unreachable, contrary to Task 2's initial classification of this line as testable -- verified both mathematically and empirically (a standalone probe sweeping denominators/precisions found 476 cases where the scale-up saturated to whole == INT64_MAX, and every one had a fractional remainder of exactly 0, never >= 0.5) before writing this entry. A Rational's magnitude can never exceed INT64_MAX: its value is numerator/denominator with denominator >= 1 and numerator in [-INT64_MAX, INT64_MAX] (canonicalise() clamps INT64_MIN away), so |value| <= |numerator| <= INT64_MAX always. `whole` is trunc(scaled), so whole == INT64_MAX forces scaled == INT64_MAX/1 exactly -- there is no room for scaled to be in (INT64_MAX, INT64_MAX + 1) the way an unbounded rational could land. With scaled == whole exactly, `fraction` (scaled minus whole) is always 0, so `roundAway` (set from comparing fraction against 1/2) is always false when whole == INT64_MAX, and stepping never happens on that side. On the other side, step == -1 would need whole == INT64_MIN to overflow, but whole's range is [-INT64_MAX, INT64_MAX] (INT64_MIN is never a valid Rational magnitude), so that direction cannot overflow either. The guard is defensive: reachable only if a future change let a Rational's magnitude exceed INT64_MAX, which the type's invariants currently forbid everywhere else in this file." }, @@ -121,28 +121,28 @@ }, { "file": "include/morph/ui/document.hpp", - "line": 1715, + "line": 1724, "source": "default:", "context": "download(cmd, fields, primary, path);", "reason": "The default arm is unreachable: the switch names every enumerator of CommandKind, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is DocumentParser::command's switch over the command kinds." }, { "file": "include/morph/ui/document.hpp", - "line": 2085, + "line": 2094, "source": "default:", "context": "case Shape::Keys:", "reason": "The default arm is unreachable: the switch names every enumerator of detail::Shape, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is DocumentParser::entry's switch over a key's shape." }, { "file": "include/morph/ui/document.hpp", - "line": 3320, + "line": 3329, "source": "default:", "context": "if (cmd.value) {", "reason": "The default arm is unreachable: the switch names every enumerator of CommandKind, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is DocumentValidator::checkCommand's switch." }, { "file": "include/morph/ui/document.hpp", - "line": 3655, + "line": 3664, "source": "default: {", "reason": "The default arm is unreachable: the switch names every enumerator of NameCategory, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is ScopeResolver::ref's switch over what a name declares." }, diff --git a/scripts/check_branch_coverage.py b/scripts/check_branch_coverage.py index cd8b8c736..b90223b7d 100644 --- a/scripts/check_branch_coverage.py +++ b/scripts/check_branch_coverage.py @@ -100,6 +100,9 @@ # new to the denominator rather than new to the code. Kept here as the # prior data point; the floors below are all from the 2026-09-07 run. FLOORS = { + # Measured locally over the CI coverage leg's configure (4 branch arms, all taken): only what a source holds + # before its first bundle branches. + "include/morph/client": (97.0, 100.00), "include/morph/core": (94.0, 97.37), "include/morph/detail": (97.0, 100.00), "include/morph/forms": (91.0, 94.78), diff --git a/scripts/test_check_install_export.sh b/scripts/test_check_install_export.sh index 1eef747b2..5313efa6d 100755 --- a/scripts/test_check_install_export.sh +++ b/scripts/test_check_install_export.sh @@ -171,22 +171,19 @@ expect_caught "morph's install rules not running at all" \ # # That path names whichever detail/ header the consumer TU reaches *first*, not # a fixed one, because the compile stops at the first missing include and never -# reports the rest. So it moves whenever a public header gains a detail/ include -# that sorts ahead of the previous first -- it read `morph/detail/fixed_string.hpp` -# until `morph/core/backend.hpp` started including `detail/instance_directory.hpp`, -# and that until `morph/core/completion.hpp` started including -# `detail/completion_awaiter.hpp`, each of which the consumer reaches earlier. A -# failure here saying "caught -# for the WRONG reason" and naming some other detail/ header is that, and the fix -# is to update this needle, not to touch the install rules. The needle stays -# specific rather than becoming a loose `detail/` match so that this case still -# proves the consumer failed on a *missing detail header* and not on something -# unrelated. +# reports the rest: today that is `reactive/detail/graph.hpp`, through +# `client/shell.hpp` -> `ui/view.hpp` -> `reactive/runtime.hpp`. It moves +# whenever a public header gains a detail/ include the consumer reaches earlier, +# and a failure here saying "caught for the WRONG reason" and naming some other +# detail/ header is that: update this needle, not the install rules. The needle +# stays specific rather than becoming a loose `detail/` match so that this case +# still proves the consumer failed on a *missing detail header* and not on +# something unrelated. expect_caught "the detail/ header set dropped from the install" \ "edit CMakeLists.txt -e '/^# The detail\/ headers that public headers include\./,/^set_target_properties(morph PROPERTIES INTERFACE_HEADER_SETS_TO_VERIFY HEADERS)$/d' -e '/FILE_SET morph_detail_headers DESTINATION/d'" \ fast \ "the consumer project did not compile against the install prefix" \ - "detail/completion_awaiter.hpp" + "detail/graph.hpp" # Bug 2: INTERFACE_HEADER_SETS_TO_VERIFY defaults to *every* interface header # set, including the detail/ one, which is deliberately not held to compiling diff --git a/src/qt_quick/qml/MorphPanel.qml b/src/qt_quick/qml/MorphPanel.qml index 4822a34d6..4728d34f1 100644 --- a/src/qt_quick/qml/MorphPanel.qml +++ b/src/qt_quick/qml/MorphPanel.qml @@ -70,7 +70,7 @@ MorphWidget { objectName: "divider" visible: root.headerRowShown Layout.fillWidth: true - height: 1 + implicitHeight: 1 color: root.scheme.border opacity: 0.6 } diff --git a/src/qt_quick/qml/MorphStack.qml b/src/qt_quick/qml/MorphStack.qml index 1440c7659..8d352d2aa 100644 --- a/src/qt_quick/qml/MorphStack.qml +++ b/src/qt_quick/qml/MorphStack.qml @@ -41,8 +41,10 @@ MorphWidget { // The shown children's lengths along the axis, as the layout sizes a child that does not stretch. readonly property var shownLengths: { const lengths = [] - for (const child of layout.children) { - if (!child.visible) + for (const object of layout.children) { + // `children` is a list of QtObject; the Layout attached type needs an Item. + const child = object as Item + if (!child || !child.visible) continue const preferred = root.axis === 0 ? child.Layout.preferredHeight : child.Layout.preferredWidth const implicit = root.axis === 0 ? child.implicitHeight : child.implicitWidth diff --git a/tests/client/test_sign_in.cpp b/tests/client/test_sign_in.cpp index 9c54af240..4240cc087 100644 --- a/tests/client/test_sign_in.cpp +++ b/tests/client/test_sign_in.cpp @@ -51,11 +51,12 @@ std::optional userAndJwt(ui::Value const& reply) { .token = jwt != nullptr && jwt->asString() != nullptr ? *jwt->asString() : std::string{}}; } -/// A screen that signs in through `Probe_Login` and attaches a file inline through `Probe_Attach`. +/// A screen that signs in through `Probe_Login` and attaches a file inline through `Probe_Attach`. The path goes in +/// with forward slashes: a Windows path's backslashes would be read as JSON escapes. std::string loginDocument(std::filesystem::path const& picked) { return R"({"vocab":"ui/1","id":"probe.login","models":{"probe":{"type":"Probe_Model"}},)" R"("state":{"picked":{"init":")" + - picked.string() + + picked.generic_string() + R"("}},)" R"("mutations":{"login":{"model":"probe","action":"Probe_Login","onSuccess":[{"signIn":{"from":"login"}}]},)" R"("attach":{"model":"probe","action":"Probe_Attach"}},)" diff --git a/tests/client/test_sources.cpp b/tests/client/test_sources.cpp index d61fae2b4..d6f92dbe5 100644 --- a/tests/client/test_sources.cpp +++ b/tests/client/test_sources.cpp @@ -78,8 +78,12 @@ TEST_CASE("LocalSource: opens the linked application's bundle, verified, through "[client][source]") { FakeAppContext context; LocalSource source{probe::app(), LocalSetup{}}; + CHECK(source.bundle() == nullptr); // nothing delivered yet + CHECK(source.catalogReply() == nullptr); Opened const opened = openAndWait(context, source); REQUIRE(opened.has_value()); + CHECK(source.bundle() != nullptr); + CHECK(source.catalogReply() != nullptr); CHECK(opened->applicationId == "probe"); CHECK(opened->documents.contains("probe.home")); CHECK(opened->documents.contains("probe.other")); diff --git a/tests/net/test_push_net.cpp b/tests/net/test_push_net.cpp index 0e2790b22..84cedef7c 100644 --- a/tests/net/test_push_net.cpp +++ b/tests/net/test_push_net.cpp @@ -312,6 +312,8 @@ TEST_CASE("net_connect_handler_fires_on_first_and_reconnect", "[push][channel]") std::scoped_lock const lock{mtx}; return seen; }; + // Each step waits on a real connect and handshake, which under Valgrind takes far longer than the default budget. + ::morph::testing::WaitBudget const budget{10s}; CountingExec reconnectExec; backend.setConnectHandler([&] { record("connect"); }); backend.setReconnectHandler([&] { record("reconnect"); }, &reconnectExec); @@ -319,13 +321,13 @@ TEST_CASE("net_connect_handler_fires_on_first_and_reconnect", "[push][channel]") { ::morph::net::SocketServer first{*server, port}; REQUIRE(first.listen()); - REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 1; })); + REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 1; }, budget)); } - REQUIRE(::morph::testing::waitUntil([&] { return !backend.waitForConnected(0ms); })); + REQUIRE(::morph::testing::waitUntil([&] { return !backend.waitForConnected(0ms); }, budget)); ::morph::net::SocketServer second{*server, port}; REQUIRE(second.listen()); - REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 3; })); + REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 3; }, budget)); // The connect handler runs before the reconnect handler is posted. CHECK(snapshot() == std::vector{"connect", "connect", "reconnect"}); } diff --git a/tests/qt_quick/test_qt_quick_overlays.cpp b/tests/qt_quick/test_qt_quick_overlays.cpp index 1c5075b66..e135da4d9 100644 --- a/tests/qt_quick/test_qt_quick_overlays.cpp +++ b/tests/qt_quick/test_qt_quick_overlays.cpp @@ -144,7 +144,7 @@ TEMPLATE_TEST_CASE("qt_quick overlays: tall content is capped at the window and scene.settle(); QQuickItem& overlayItem = Scene::itemOf(static_cast(*overlay)); - QObject* const popup = popupOf(overlayItem); + QObject const* const popup = popupOf(overlayItem); REQUIRE(popup != nullptr); // The window the Scene harness makes is 800x600; the overlay's realized height must not exceed it. CHECK(popup->property("height").toReal() <= 600); @@ -166,7 +166,7 @@ TEST_CASE("qt_quick dialog: short content is not stretched or clipped by the cap scene.settle(); QQuickItem& dialogItem = Scene::itemOf(static_cast(*dialog)); - QObject* const popup = popupOf(dialogItem); + QObject const* const popup = popupOf(dialogItem); REQUIRE(popup != nullptr); QQuickItem const& bodyItem = Scene::itemOf(*body); // The dialog's height is driven by its content's natural size, not artificially stretched to any cap. @@ -186,7 +186,7 @@ TEST_CASE("qt_quick drawer: narrow content is not stretched past its own natural scene.settle(); QQuickItem& drawerItem = Scene::itemOf(static_cast(*drawer)); - QObject* const popup = popupOf(drawerItem); + QObject const* const popup = popupOf(drawerItem); REQUIRE(popup != nullptr); // The drawer's own minimum width (MorphDrawer.qml:54, 30 units) governs here, not any new cap -- the cap must // not shrink it below what it already draws as today. @@ -204,7 +204,7 @@ TEST_CASE("qt_quick drawer: wide content is capped at the window's width", "[qt_ scene.settle(); QQuickItem& drawerItem = Scene::itemOf(static_cast(*drawer)); - QObject* const popup = popupOf(drawerItem); + QObject const* const popup = popupOf(drawerItem); REQUIRE(popup != nullptr); CHECK(popup->property("width").toReal() <= 800); } diff --git a/tests/qt_quick/test_qt_quick_table.cpp b/tests/qt_quick/test_qt_quick_table.cpp index 495878dae..aeb2ced3a 100644 --- a/tests/qt_quick/test_qt_quick_table.cpp +++ b/tests/qt_quick/test_qt_quick_table.cpp @@ -52,6 +52,14 @@ Rows makeTable(Scene& scene, std::vector const& keys, ui::SelectionMode return made; } +// What a content column adds to its widest cell: a unit of padding on each side. A unit is the theme font's average +// character width, so it differs between platforms and fonts. +qreal columnPadding(Scene& scene) { + auto* const theme = scene.engine().singletonInstance(QStringLiteral("MorphUi"), QStringLiteral("Theme")); + REQUIRE(theme != nullptr); + return 2 * theme->property("unitWidth").toReal(); +} + } // namespace TEST_CASE("qt_quick table: a row made in a table is a table row, and an int64 key above 2^53 comes back exactly", @@ -168,11 +176,11 @@ TEST_CASE("qt_quick table: growing one row's cell does not rescan every row, and } scene.settle(); - QQuickItem& tableItem = Scene::itemOf(*table); + QQuickItem const& tableItem = Scene::itemOf(*table); QQuickItem const& firstCell = Scene::itemOf(*cells.at(0)); qreal const widthBefore = firstCell.implicitWidth(); // Sanity: the column's width already fits its widest cell after the initial mount (the cache seeded correctly). - CHECK(tableItem.property("widths").toList().at(0).toReal() >= widthBefore + 2 * 8); + CHECK(tableItem.property("widths").toList().at(0).toReal() >= widthBefore + columnPadding(scene)); // `rowScans` is a test-only counter the fix adds to MorphTable.qml, incremented once per row visited inside the // full-rescan path. Its absence on unfixed code must fail loudly here, not silently read back 0 for both the @@ -190,7 +198,7 @@ TEST_CASE("qt_quick table: growing one row's cell does not rescan every row, and CHECK(delta == 0); // the grow path never rescans every row qreal const widthAfter = firstCell.implicitWidth(); CHECK(widthAfter > widthBefore); - CHECK(tableItem.property("widths").toList().at(0).toReal() >= widthAfter + 2 * 8); + CHECK(tableItem.property("widths").toList().at(0).toReal() >= widthAfter + columnPadding(scene)); } // Mutation: skip the invalidateColumn/reportCellWidth wiring from TableRowImpl::adopted entirely (a cell's width @@ -219,7 +227,7 @@ TEST_CASE("qt_quick table: a single-row grow's scan cost does not scale with row rows.push_back(std::move(row)); } scene.settle(); - QQuickItem& tableItem = Scene::itemOf(*table); + QQuickItem const& tableItem = Scene::itemOf(*table); int const baseline = tableItem.property("rowScans").toInt(); cells.at(0)->setText("a very very very very very long cell text, toggled"); scene.settle(); @@ -241,7 +249,7 @@ TEST_CASE("qt_quick table: an empty table resolves a content column to its heade auto table = scene.backend().createTable(nullptr); table->setColumns({{.label = "A"}, {.label = "B"}}); scene.settle(); - QQuickItem& tableItem = Scene::itemOf(*table); + QQuickItem const& tableItem = Scene::itemOf(*table); QVariantList const widths = tableItem.property("widths").toList(); REQUIRE(widths.size() == 2); CHECK(widths.at(0).toReal() > 0); @@ -261,14 +269,14 @@ TEST_CASE("qt_quick table: a cell's text shrinking rescans its column and keeps auto cell2 = scene.backend().createText(row2.get()); cell2->setText("short"); scene.settle(); - QQuickItem& tableItem = Scene::itemOf(*table); + QQuickItem const& tableItem = Scene::itemOf(*table); qreal const widthBefore = tableItem.property("widths").toList().at(0).toReal(); cell1->setText("short too"); scene.settle(); qreal const widthAfter = tableItem.property("widths").toList().at(0).toReal(); CHECK(widthAfter < widthBefore); - CHECK(widthAfter >= Scene::itemOf(*cell1).implicitWidth() + 2 * 8); + CHECK(widthAfter >= Scene::itemOf(*cell1).implicitWidth() + columnPadding(scene)); } TEST_CASE("qt_quick table: removing a row drops its cell's contribution to the column width", "[qt_quick]") { @@ -284,7 +292,7 @@ TEST_CASE("qt_quick table: removing a row drops its cell's contribution to the c auto cell2 = scene.backend().createText(row2.get()); cell2->setText("short"); scene.settle(); - QQuickItem& tableItem = Scene::itemOf(*table); + QQuickItem const& tableItem = Scene::itemOf(*table); qreal const widthBefore = tableItem.property("widths").toList().at(0).toReal(); row1.reset(); @@ -307,7 +315,7 @@ TEST_CASE("qt_quick table: a hidden cell does not count toward the column width, auto cell2 = scene.backend().createText(row2.get()); cell2->setText("short"); scene.settle(); - QQuickItem& tableItem = Scene::itemOf(*table); + QQuickItem const& tableItem = Scene::itemOf(*table); qreal const widthBefore = tableItem.property("widths").toList().at(0).toReal(); cell1->setVisible(false); @@ -390,7 +398,7 @@ TEST_CASE("qt_quick table: a virtualized table realizes only the window, reporti {.virtualize = {.threshold = 5, .overscan = 1}, .common = {.layout = {.height = ui::Sizing::fixed(10)}}})}; scene.settle(); - QQuickItem& tableItem = Scene::itemOf(mounted.root()); + QQuickItem const& tableItem = Scene::itemOf(mounted.root()); auto* const flick = tableItem.findChild(QStringLiteral("flick")); REQUIRE(flick != nullptr); @@ -429,7 +437,7 @@ TEST_CASE("qt_quick table: a virtualized table's column width never shrinks as a {.virtualize = {.threshold = 5, .overscan = 1}, .common = {.layout = {.height = ui::Sizing::fixed(10)}}})}; scene.settle(); - QQuickItem& tableItem = Scene::itemOf(mounted.root()); + QQuickItem const& tableItem = Scene::itemOf(mounted.root()); auto* const flick = tableItem.findChild(QStringLiteral("flick")); REQUIRE(flick != nullptr); qreal const widthWithWideRow = tableItem.property("widths").toList().at(0).toReal(); diff --git a/tests/qt_quick/test_qt_quick_widgets.cpp b/tests/qt_quick/test_qt_quick_widgets.cpp index 27c87c616..75027983d 100644 --- a/tests/qt_quick/test_qt_quick_widgets.cpp +++ b/tests/qt_quick/test_qt_quick_widgets.cpp @@ -158,7 +158,7 @@ TEST_CASE("qt_quick widgets: a text input draws its icon inside the field, and t auto input = scene.backend().createTextInput(nullptr, ui::TextInputMode::SingleLine); input->setIcon("search"); scene.settle(); - QQuickItem& control = Scene::controlOf(*input); + QQuickItem const& control = Scene::controlOf(*input); auto const* const icon = control.findChild(QStringLiteral("icon")); REQUIRE(icon != nullptr); CHECK(icon->isVisible()); @@ -171,7 +171,7 @@ TEST_CASE("qt_quick widgets: a text input with no icon reserves no leading space Scene scene; auto plain = scene.backend().createTextInput(nullptr, ui::TextInputMode::SingleLine); scene.settle(); - QQuickItem& control = Scene::controlOf(*plain); + QQuickItem const& control = Scene::controlOf(*plain); auto const* const icon = control.findChild(QStringLiteral("icon")); REQUIRE(icon != nullptr); CHECK_FALSE(icon->isVisible()); @@ -298,7 +298,7 @@ TEST_CASE("qt_quick widgets: a dropdown's indicator is themed, and flips with th Scene scene; auto select = scene.backend().createSelect(nullptr, ui::SelectStyle::Dropdown); scene.settle(); - QQuickItem& combo = Scene::controlOf(*select); + QQuickItem const& combo = Scene::controlOf(*select); auto* const indicator = combo.property("indicator").value(); REQUIRE(indicator != nullptr); CHECK(indicator->objectName() == QStringLiteral("indicator")); diff --git a/tests/test_push_agent.cpp b/tests/test_push_agent.cpp index 5f499224b..dfee9e57c 100644 --- a/tests/test_push_agent.cpp +++ b/tests/test_push_agent.cpp @@ -287,6 +287,11 @@ TEST_CASE("tools_call_runs_and_returns_result_text", "[push][agent]") { R"({"jsonrpc":"2.0","id":4,"method":"tools/call",)" R"("params":{"name":"Ag_List","arguments":{}}})")); CHECK(std::get(listed["result"]["content"][0]["text"].data) == R"([{"id":1,"text":"hello"}])"); + // No `arguments` member is the same call with none. + auto const bare = + json(ask(rig, *adapter, R"({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"Ag_List"}})")); + REQUIRE(bare.contains("result")); + CHECK(std::get(bare["result"]["content"][0]["text"].data) == R"([{"id":1,"text":"hello"}])"); } // Mutation: run the call without the session (the server refuses an unsigned execute) — or with it, through a path diff --git a/tests/test_ui_explorer.cpp b/tests/test_ui_explorer.cpp index 28d50de4b..8eab32801 100644 --- a/tests/test_ui_explorer.cpp +++ b/tests/test_ui_explorer.cpp @@ -117,7 +117,7 @@ struct Fixture { std::vector opened; std::unique_ptr page = harness.backend.createStack(nullptr, morph::ui::Axis::Vertical); - std::unique_ptr explorer() { + std::unique_ptr explorer(std::function const& adjust = {}) { ExplorerOptions options; options.catalog = twoModels(); options.facts = corpusCatalog(); @@ -129,6 +129,9 @@ struct Fixture { provided.insert_or_assign(screenId, std::move(document)); }; options.onOpen = [this](std::string const& screenId) { opened.push_back(screenId); }; + if (adjust) { + adjust(options); + } auto made = std::make_unique(harness.runtime, harness.backend, *page, std::move(options)); harness.settle(); return made; @@ -284,6 +287,29 @@ TEST_CASE("Explorer: the screens and every model the catalog names, a refused on CHECK(f.harness.backend.prop(f.byTestId("Button", "explorer_screen_samples"), "visible") == "false"); } +// Mutation: read a localized title's first text instead of its `fallback` (the screen is titled "Proben"). +TEST_CASE("Explorer: a screen is titled by its text, a localized title's fallback, or else its id", "[ui][explorer]") { + Fixture f; + auto const title = [](std::string_view json) { + auto parsed = morph::ui::parseJson(json); + REQUIRE(parsed.has_value()); + return *std::move(parsed); + }; + auto const explorer = f.explorer([&title](ExplorerOptions& options) { + options.catalog.entries = { + CatalogEntry{.id = "localized", .title = title(R"({"de":"Proben","fallback":"All samples"})")}, + CatalogEntry{.id = "noFallback", .title = title(R"({"de":"Proben","en":7})")}, + CatalogEntry{.id = "numbered", .title = title("7")}, + }; + }); + auto const label = [&f](std::string_view testId) { + return f.harness.backend.prop(f.byTestId("Button", testId), "label"); + }; + CHECK(label("explorer_screen_localized") == "All samples"); + CHECK(label("explorer_screen_noFallback") == "noFallback"); + CHECK(label("explorer_screen_numbered") == "numbered"); +} + // Mutation: skip showUses (the "Used by" card is missing). TEST_CASE("Explorer: an action shows its facts, fields, uses, a live Try it and its schema", "[ui][explorer]") { Fixture f; diff --git a/tests/test_ui_interpreter_detail.cpp b/tests/test_ui_interpreter_detail.cpp index 3578020fe..ecf49e7fa 100644 --- a/tests/test_ui_interpreter_detail.cpp +++ b/tests/test_ui_interpreter_detail.cpp @@ -379,3 +379,99 @@ TEST_CASE("interpreter: a view node's chord runs its commands", "[ui][interprete h.settle(); CHECK(h.text("hit") == "1"); } + +// Mutation: map a lower-case digit as an upper-case one (`chr - 'A'`), or drop `+` or `/` (each decodes wrongly). +TEST_CASE("interpreter: inline bytes are base64 text over the whole alphabet, or a list of bytes", + "[ui][interpreter][transfer]") { + using ui::detail::base64Decode; + using ui::detail::inlineBytes; + CHECK(base64Decode("TWFu") == std::string{"Man"}); + CHECK(base64Decode("bWFu") == std::string{"man"}); + CHECK(base64Decode("MTIz") == std::string{"123"}); + CHECK(base64Decode("+/8=") == std::string{"\xFB\xFF"}); + CHECK(base64Decode("TWE=") == std::string{"Ma"}); + CHECK(base64Decode("") == std::string{}); + CHECK_FALSE(base64Decode("TWFuT").has_value()); // one digit past a group carries no whole byte + CHECK_FALSE(base64Decode("TW-u").has_value()); + + CHECK(inlineBytes(Value{"YSxi"}) == std::string{"a,b"}); + CHECK(inlineBytes(Value{"not base64!"}).error() == "the text is not base64"); + CHECK(inlineBytes(Value{ValueList{Value{std::int64_t{97}}, Value{std::int64_t{0}}, Value{std::int64_t{255}}}}) == + std::string{"a\0\xFF", 3}); + CHECK(inlineBytes(Value{ValueList{Value{std::int64_t{256}}}}).error() == "a byte list holds integers 0 to 255"); + CHECK(inlineBytes(Value{ValueList{Value{std::int64_t{-1}}}}).error() == "a byte list holds integers 0 to 255"); + CHECK(inlineBytes(Value{ValueList{Value{"a"}}}).error() == "a byte list holds integers 0 to 255"); + CHECK(inlineBytes(Value{std::int64_t{1}}).error() == "inline bytes are base64 text or a list of bytes"); +} + +// Mutation: accept any JSON for a typed prop (a string payload passes as an int). +TEST_CASE("interpreter: a field component's change is its payload read as the value prop's type", + "[ui][interpreter][custom]") { + using ui::detail::changeWire; + auto const text = [](std::string const& payload, std::string_view type) { + auto const wire = changeWire(payload, type); + REQUIRE(wire.has_value()); + REQUIRE(wire->is_string()); + return wire->get(); + }; + CHECK(text("s:hello", "string") == "hello"); + CHECK(text("plain", "string") == "plain"); + CHECK(text("\"12.50\"", "exact") == "12.50"); + + CHECK(changeWire("i:42", "int")->is_number()); + CHECK(changeWire("4.5", "number")->is_number()); + CHECK(changeWire("7", "exact")->is_number()); + CHECK(changeWire("true", "bool")->is_boolean()); + CHECK(changeWire("null", "int")->is_null()); + CHECK(changeWire("[1,2]", "json")->is_array()); + + CHECK(changeWire("{not", "json").error() == "it is not JSON"); + CHECK(changeWire("\"x\"", "int").error() == "it is not an int"); + CHECK(changeWire("\"x\"", "number").error() == "it is not a number"); + CHECK(changeWire("1", "bool").error() == "it is not a bool"); + CHECK(changeWire("true", "exact").error() == "it is not an exact"); +} + +// Mutation: stop at a record without the key field instead of reading its members as rows (the map's rows are lost). +TEST_CASE("interpreter: held rows are found and patched in a list, under a member, and as a map's values", + "[ui][interpreter]") { + using ui::detail::keyedRowsOf; + using ui::detail::patchHeldRows; + using ui::detail::RowsByKey; + Value const byName = record({{"first", row(1, "a")}, {"second", row(2, "b")}, {"note", Value{"not a row"}}}); + Value const held = record({{"rows", Value{ValueList{row(3, "c")}}}, {"byName", byName}}); + + std::vector const found = keyedRowsOf(held, {"rows", "byName", "absent"}, "id"); + REQUIRE(found.size() == 3); + CHECK(*found.at(0) == row(3, "c")); + CHECK(keyedRowsOf(Value{"flat"}, {"rows"}, "id").empty()); + + Value const newer = row(2, "B"); + Value const unrelated = row(9, "z"); + RowsByKey const rows{ + {ui::detail::canonicalJson(Value{std::int64_t{2}}), &newer}, + {ui::detail::canonicalJson(Value{std::int64_t{9}}), &unrelated}, + }; + std::optional const patched = patchHeldRows(held, {"rows", "byName", "absent"}, "id", rows); + REQUIRE(patched.has_value()); + CHECK(*patched->field("byName")->field("second") == row(2, "B")); + CHECK(*patched->field("byName")->field("first") == row(1, "a")); + CHECK(*patched->field("rows") == Value{ValueList{row(3, "c")}}); + + RowsByKey const none{{ui::detail::canonicalJson(Value{std::int64_t{9}}), &unrelated}}; + CHECK_FALSE(patchHeldRows(held, {"rows", "byName", "absent"}, "id", none).has_value()); + CHECK_FALSE(patchHeldRows(Value{"flat"}, {""}, "id", rows).has_value()); +} + +// Mutation: accept a record with a second member (`{"fixed": 3, "stretch": 1}` reads as fixed and hides the slip). +TEST_CASE("interpreter: a sizing is \"content\", or one fixed or stretch member holding an int", "[ui][interpreter]") { + using ui::detail::isSizing; + CHECK(isSizing(Value{"content"})); + CHECK_FALSE(isSizing(Value{"wide"})); + CHECK(isSizing(record({{"fixed", Value{std::int64_t{3}}}}))); + CHECK(isSizing(record({{"stretch", Value{std::int64_t{2}}}}))); + CHECK_FALSE(isSizing(record({{"fixed", Value{"3"}}}))); + CHECK_FALSE(isSizing(record({{"fill", Value{std::int64_t{1}}}}))); + CHECK_FALSE(isSizing(record({{"fixed", Value{std::int64_t{3}}}, {"stretch", Value{std::int64_t{1}}}}))); + CHECK_FALSE(isSizing(Value{std::int64_t{3}})); +}