/* --uitest: drive the window with synthesized input. * * The GUI is checked the way a person uses it -- a mouse click on a segment, * a finger tap on another, a narrowed window that has to collapse the * sidebar -- instead of only loading the QML and hoping. Qt's test event * synthesis does the pointing, so this runs on any platform, with or without * a touchscreen, and can be scripted (see app/README.md). */ #include "UiTest.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "cst_agent.h" #include "cst_attach.h" #include "core/ToolProvider.h" #include "ui/BackKey.h" #include "ui/SessionImages.h" #include "ui/ThemeIcons.h" namespace app { namespace { int g_fail; /* The run's own log file, opened once the workspace is known (see the top of * the run below): every line stdout gets is written here as well. On a phone * stdout reaches nobody, and an app started from adb cannot be handed * CST_APP_SHOT or any other environment -- so `--uitest --workspace DIR` plus * `adb pull DIR/uitest.log` is how a device run is read back. Empty when the * file could not be made, in which case the run only prints. */ QFile g_runLog; /* Qt's own messages go into the same log as the checks: this is where QML * puts "TypeError: Cannot read property ... of undefined" and every other * reason a step did nothing, and a device run has nowhere else to show it * (logcat does not carry this build's Qt messages -- a plain qInfo from the * app is not in the buffer). Messages before the log is opened still print. */ void logQtMessage(QtMsgType type, const QMessageLogContext &, const QString &msg) { const char *kind = type == QtDebugMsg ? "debug" : type == QtInfoMsg ? "info" : type == QtWarningMsg ? "warn" : type == QtCriticalMsg ? "crit" : "fatal"; const QByteArray line = QByteArray(kind) + " " + msg.toUtf8() + "\n"; if (g_runLog.isOpen()) { g_runLog.write(line); g_runLog.flush(); } fwrite(line.constData(), 1, static_cast(line.size()), stderr); if (type == QtFatalMsg) abort(); } void check(bool ok, const QString &what) { std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what)); std::fflush(stdout); if (g_runLog.isOpen()) { g_runLog.write(ok ? "ok " : "FAIL"); g_runLog.write(what.toUtf8()); g_runLog.write("\n"); g_runLog.flush(); } if (!ok) g_fail++; } /* A line in the run's log that is not a check: what the window looked like at * a step, for the cases where "it did nothing" is all the checks can say. */ void note(const QString &what) { std::printf("note %s\n", qPrintable(what)); std::fflush(stdout); if (g_runLog.isOpen()) { g_runLog.write("note "); g_runLog.write(what.toUtf8()); g_runLog.write("\n"); g_runLog.flush(); } } /* Every item with this objectName in the visual tree. A view that has just * been replayed can still hold the delegates it is about to drop, so a name * can match more than one item for a moment. */ void collectNamed(QQuickItem *root, const QString &name, QList *out) { if (!root) return; if (root->objectName() == name) out->append(root); const QList kids = root->childItems(); for (QQuickItem *c : kids) collectNamed(c, name, out); } int countNamed(QQuickItem *root, const QString &prefix) { if (!root) return 0; int n = root->objectName().startsWith(prefix) ? 1 : 0; const QList kids = root->childItems(); for (QQuickItem *c : kids) n += countNamed(c, prefix); return n; } /* An item by objectName. The QObject tree is tried first (it holds the named * controls); a view's delegates can be owned by the model instead, so the * visual tree -- what a finger can actually reach -- is walked as well. */ bool onScreen(QQuickItem *it); /* defined below: isVisible() is not enough */ QQuickItem *item(QQuickWindow *win, const QString &name) { QList hits; collectNamed(win->contentItem(), name, &hits); /* What a finger can reach is the one the test means. Two things say * "visible" without being reachable: a delegate on its way out, and a * second copy of a control that lives in a closed drawer (the sidebar is * drawn from the window and, when narrow, from a drawer off to the left). * Which copy comes first in the tree is not the same on every platform, so * the one whose centre is really on screen wins. */ for (QQuickItem *it : hits) if (onScreen(it)) return it; for (QQuickItem *it : hits) if (it->isVisible()) return it; if (QQuickItem *it = win->findChild(name)) return it; return hits.isEmpty() ? nullptr : hits.first(); } /* The centre of an item, in window coordinates. */ QPoint centre(QQuickItem *it) { const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2)); return p.toPoint(); } /* Is this item's centre really reachable? isVisible() answers whether hiding * was asked for, not whether a clipping ancestor (a scrolling view) has moved * it out of sight -- and an event goes to whatever is at that point instead. */ bool onScreen(QQuickItem *it) { if (!it || !it->isVisible() || it->width() <= 0 || it->height() <= 0) return false; QQuickWindow *win = it->window(); if (!win) return false; const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2)); if (!QRectF(0, 0, win->width(), win->height()).contains(p)) return false; for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) { if (!a->clip()) continue; if (!a->mapRectToScene(QRectF(0, 0, a->width(), a->height())).contains(p)) return false; } return true; } /* An event lands on whatever is at that point, and isVisible() does not know * about a scrolling ancestor's clipping (see onScreen): an item the view has * scrolled past still says it is visible, and the click goes to the row that * happens to be at that pixel -- which is a different row on a window whose * font makes the rows taller (another platform, another Qt). So a click * scrolls its target in first, which is what a person does. The nearest * ancestor that both clips and scrolls (a ListView's own item) is the one to * move; anchored to its middle, so a row at either edge still gets its whole * centre inside the viewport. */ void scrollIntoView(QQuickItem *it, bool middle = false) { if (!it || (!middle && onScreen(it))) return; for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) { const QVariant contentY = a->property("contentY"); if (!a->clip() || !contentY.isValid() || !a->property("contentHeight").isValid()) continue; const double y = it->mapToItem(a, QPointF(0, 0)).y() + contentY.toDouble(); const double lowest = a->property("contentHeight").toDouble() - a->height(); note(QStringLiteral("%1 is out of the viewport (contentY %2, at %3 of %4): " "scrolling to it") .arg(it->objectName()) .arg(contentY.toDouble()) .arg(y) .arg(a->property("contentHeight").toDouble())); a->setProperty("contentY", qBound(0.0, y - a->height() / 2, lowest)); QTest::qWait(150); return; } } /* One rendered frame: items are laid out, and a view's delegates are created, * in the render loop's polish step. A platform whose window is never exposed * -- offscreen on Windows, which is how this is run on a machine that has no * Windows -- only runs that step when something asks for a picture, so * without this the window can be a whole pass behind: a row still 0 wide, a * card not created yet. A person looking at the window would see it settled, * which is what the checks below are about. */ void frame(QQuickWindow *win) { if (win) win->grabWindow(); QTest::qWait(50); } void clickItem(QQuickWindow *win, QQuickItem *it); /* defined below */ void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it); void clickMouse(QQuickWindow *win, const QString &name) { QQuickItem *it = item(win, name); if (!it || !it->isVisible()) { check(false, QStringLiteral("mouse click %1: not there").arg(name)); return; } clickItem(win, it); } /* The same click, on an item the caller already has: a name can match more * than one item (a view keeps a delegate from the session before), and the * one the test means is not always the first in the tree. */ void clickItem(QQuickWindow *win, QQuickItem *it) { QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it)); } void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) { QQuickItem *it = item(win, name); if (!it || !it->isVisible()) { check(false, QStringLiteral("touch tap %1: not there").arg(name)); return; } tapItem(win, dev, it); } void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it) { const QPoint p = centre(it); QTest::touchEvent(win, dev).press(0, p, win); QTest::touchEvent(win, dev).release(0, p, win); } void resizeTo(QQuickWindow *win, int w, int h) { win->resize(w, h); QTest::qWait(250); /* let the layouts settle */ } /* A stored session with a tool call in it, written through the store's own * API, so the file is exactly what the engine reads back. */ QString writeFixtureSession(const QString &ws, const QString &id) { const QByteArray w = ws.toUtf8(); char path[4096] = ""; if (cst_agent_store_path_for_id(w.constData(), id.toUtf8().constData(), path, sizeof(path)) != 0) return QString(); cst_session_t *s = cst_session_open(path); if (!s) return QString(); cst_message_t m; memset(&m, 0, sizeof(m)); m.role = CST_ROLE_USER; m.content = strdup("what is here?"); /* The prompt carries a picture, stored exactly the way the engine stores * one (the file next to the session, the message naming it). */ { QImage pic(320, 200, QImage::Format_RGB32); pic.fill(Qt::darkCyan); QPainter painter(&pic); painter.fillRect(QRect(0, 0, 160, 100), Qt::darkYellow); painter.end(); QByteArray png; QBuffer buf(&png); buf.open(QIODevice::WriteOnly); pic.save(&buf, "PNG"); char aerr[256] = ""; m.attach = cst_attach_import_bytes(path, "shot.png", png.constData(), static_cast(png.size()), aerr, sizeof(aerr)); m.nattach = m.attach ? 1 : 0; } cst_session_append(s, &m); cst_attachments_free(m.attach, m.nattach); memset(&m, 0, sizeof(m)); m.role = CST_ROLE_ASSISTANT; m.content = strdup("let me look. "); m.tool_calls_json = strdup( "[{\"id\":\"call_1\",\"type\":\"function\",\"function\":" "{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":" "\\\"README.md\\\"}\"}}]"); cst_session_append(s, &m); memset(&m, 0, sizeof(m)); m.role = CST_ROLE_TOOL; m.content = strdup("hello\n"); snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1"); snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file"); cst_session_append(s, &m); /* A delivered file (deliver_file): the copy the agent hands to the person, * marked for_user -- the row a file card is drawn from. */ memset(&m, 0, sizeof(m)); m.role = CST_ROLE_ASSISTANT; m.tool_calls_json = strdup( "[{\"id\":\"call_2\",\"type\":\"function\",\"function\":" "{\"name\":\"deliver_file\",\"arguments\":\"{\\\"path\\\":" "\\\"report.md\\\"}\"}}]"); cst_session_append(s, &m); memset(&m, 0, sizeof(m)); { const QString src = ws + QStringLiteral("/report.md"); QFile rf(src); if (rf.open(QIODevice::WriteOnly)) rf.write("# report\n"); rf.close(); char aerr[256] = ""; m.attach = cst_attach_import_artifact(path, src.toUtf8().constData(), NULL, aerr, sizeof(aerr)); m.nattach = m.attach ? 1 : 0; } m.role = CST_ROLE_TOOL; m.content = strdup("delivered report.md\n"); snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_2"); snprintf(m.tool_name, sizeof(m.tool_name), "%s", "deliver_file"); cst_session_append(s, &m); cst_attachments_free(m.attach, m.nattach); memset(&m, 0, sizeof(m)); m.role = CST_ROLE_ASSISTANT; m.reasoning = strdup("checked the file"); m.content = strdup("a repo."); cst_session_append(s, &m); cst_session_close(s); return id; } /* A picture of an interesting state on request: the only way to look at the * layout on a machine without a screen (CST_APP_SHOT=shot.png makes the run * write shot.png at the end and shot-settings.png while the settings dialog * is open). */ void maybeShot(QQuickWindow *win, const QString &suffix) { if (!qEnvironmentVariableIsSet("CST_APP_SHOT")) return; const QString base = qEnvironmentVariable("CST_APP_SHOT"); const QString path = suffix.isEmpty() ? base : base.endsWith(QLatin1String(".png")) ? base.left(base.size() - 4) + suffix + QStringLiteral(".png") : base + suffix + QStringLiteral(".png"); std::printf("%s %s\n", win->grabWindow().save(path) ? "note wrote" : "note cannot write", qPrintable(path)); } } // namespace int runUiTest(AgentCore &core, const QString &size) { int width = 1000, height = 720; const int x = size.indexOf(QLatin1Char('x')); if (x > 0) { const int w = size.left(x).toInt(), h = size.mid(x + 1).toInt(); if (w > 0 && h > 0) { width = w; height = h; } } /* A workspace is never empty on a second run: the engine opens the last * session of the store (cst_agent_open()), so the window is created with a * transcript already in the engine. Put one there before the QML loads, * which is exactly what a restart looks like. */ /* A file card hands its file to the system: in a test run nothing may open * for real, and the platform layer itself honours this (see ToolProvider.h). */ qputenv("CST_APP_NO_OPEN", "1"); /* From here on the run leaves a log in the workspace next to the files it * makes, so a device run can be read back without logcat (see g_runLog). */ g_runLog.setFileName(core.workspace() + QStringLiteral("/uitest.log")); g_runLog.open(QIODevice::WriteOnly | QIODevice::Truncate); qInstallMessageHandler(logQtMessage); const QString resumed = writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on")); check(!resumed.isEmpty() && core.switchSession(resumed), QStringLiteral("the engine has a session open before the window exists")); QQmlApplicationEngine engine; installThemeIcons(&engine); /* the window's icons, same as main() */ /* image://cstattach/: the session's stored images, pointed at the session * the engine has open (and re-pointed on every switch). */ SessionImages *images = installSessionImages(&engine); images->setDir(core.attachmentDir()); QObject::connect(&core, &AgentCore::sessionChanged, images, [images, &core] { images->setDir(core.attachmentDir()); }); engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core); engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), width); engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height); engine.load(QUrl(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"))); if (engine.rootObjects().isEmpty()) { std::printf("FAIL the window did not load\n"); return 1; } QQuickWindow *win = qobject_cast(engine.rootObjects().value(0)); if (!win) { std::printf("FAIL the root object is not a window\n"); return 1; } /* The same handler main() installs: every Back key check below goes through * the code the phone runs, not through a Shortcut that only the test has. */ installBackKey(win, engine.rootObjects().value(0)); QTest::qWait(250); /* --- the window opens on the session the engine already had ----------- */ { QQuickItem *tr = item(win, QStringLiteral("transcript")); const int rows = tr ? tr->property("count").toInt() : -1; check(rows == 6, QStringLiteral("the open session is on screen at startup (%1 rows)") .arg(rows)); } QQuickItem *docked = item(win, QStringLiteral("dockedSidebar")); QQuickItem *navButton = item(win, QStringLiteral("navButton")); QQuickItem *navPanel = item(win, QStringLiteral("navPanel")); QQuickItem *composer = item(win, QStringLiteral("composerInput")); QPointingDevice *touch = QTest::createTouchDevice(); /* --- a desktop window: the sidebar is docked, the drawer is gone --- */ check(docked && docked->isVisible(), QStringLiteral("wide window: sidebar docked")); check(navButton && !navButton->isVisible(), QStringLiteral("wide window: no drawer button")); check(core.mode() == QLatin1String("coding"), QStringLiteral("engine starts in coding mode")); /* --- mouse: pick the other mode --- */ clickMouse(win, QStringLiteral("modeGeneral")); check(core.mode() == QLatin1String("general"), QStringLiteral("mouse click on \"general\" reaches the engine")); check(item(win, QStringLiteral("modeGeneral"))->property("current").toBool(), QStringLiteral("mouse click marks \"general\" as current")); /* --- touch: tap the first mode back --- */ tapTouch(win, touch, QStringLiteral("modeCoding")); check(core.mode() == QLatin1String("coding"), QStringLiteral("touch tap on \"coding\" reaches the engine")); /* --- mouse: the plan switch is a two-way control --- */ clickMouse(win, QStringLiteral("planSwitch")); check(core.planMode(), QStringLiteral("plan switch turns plan mode on")); clickMouse(win, QStringLiteral("planSwitch")); check(!core.planMode(), QStringLiteral("plan switch turns it off again")); /* Task controls must reach the same runtime as the model and HTTP API. */ clickMouse(win, QStringLiteral("taskPanelButton")); QTest::qWait(150); QObject *taskPanel = win->findChild(QStringLiteral("taskControlDialog")); check(taskPanel && taskPanel->property("visible").toBool(), QStringLiteral("Tasks opens the runtime control panel")); clickMouse(win, QStringLiteral("taskReadOnly")); clickMouse(win, QStringLiteral("taskMainOnly")); QJsonObject taskState = QJsonDocument::fromJson(core.taskState().toUtf8()).object(); check(taskState.value(QStringLiteral("read_only")).toBool() && !taskState.value(QStringLiteral("delegation")).toBool(), QStringLiteral("task panel enforces read-only and main-agent-only controls")); core.taskControl(false, true, false); if (taskPanel) QMetaObject::invokeMethod(taskPanel, "close"); QTest::qWait(150); /* --- keyboard: typing in the composer arms Send --- */ QQuickItem *send = item(win, QStringLiteral("sendButton")); if (composer && send) { composer->forceActiveFocus(); /* QTest::keyClicks() is QWidget-only; a window takes one key at a time. */ for (const QChar &c : QStringLiteral("hello")) QTest::keyClick(win, c.toLatin1()); check(send->property("enabled").toBool(), QStringLiteral("typing arms the send button")); QTest::keyClick(win, Qt::Key_A, Qt::ControlModifier); QTest::keyClick(win, Qt::Key_Backspace); check(!send->property("enabled").toBool(), QStringLiteral("an empty composer disarms it")); } else { check(false, QStringLiteral("composer and send button are reachable")); } /* --- sessions: the sidebar is the store's face ------------------------ */ QQuickItem *list = item(win, QStringLiteral("transcript")); const QString fixture = writeFixtureSession(core.workspace(), QStringLiteral("20260101-120000-fix")); check(!fixture.isEmpty() && list != nullptr, QStringLiteral("a session fixture with a tool call is in the store")); QMetaObject::invokeMethod(win, "reloadSessions"); QTest::qWait(150); /* A row is not clickable until the layout pass that gives it its width has * run (see frame()): a click on a row that is still 0 wide goes to whatever * is behind it. */ frame(win); int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:")); if (qEnvironmentVariableIsSet("CST_APP_DUMP")) std::printf("note: %d session rows in the visual tree\n", rowsSeen); const int listed = win->property("sessionsView").toList().size(); check(item(win, QStringLiteral("sessionRow:") + fixture) != nullptr, QStringLiteral("the stored session shows up in the sidebar (%1 rows, %2 listed)") .arg(rowsSeen) .arg(listed)); check(core.sessions().size() >= 2, QStringLiteral("the store lists it next to the open one")); /* the fixture is: user, agent + call, result, agent + call, delivered * file, thought, agent */ clickMouse(win, QStringLiteral("sessionRow:") + fixture); check(core.sessionId() == fixture, QStringLiteral("a click on a row switches the engine")); check(list && list->property("count").toInt() == 6, QStringLiteral("the reopened transcript is replayed into the view")); { bool toolOk = false; const QVariantList rows = core.history(); for (const QVariant &v : rows) { const QVariantMap r = v.toMap(); if (r.value(QStringLiteral("kind")).toString() == QLatin1String("tool") && r.value(QStringLiteral("name")).toString() == QLatin1String("read_file")) toolOk = r.value(QStringLiteral("complete")).toBool() && r.value(QStringLiteral("output")).toString() == QLatin1String("hello\n"); } check(toolOk, QStringLiteral("a tool result is paired with its call")); } /* --- a delivered file: a card with open and share --------------------- */ { /* The view is re-laid out off the click; give it the frame it needs. */ QTest::qWait(150); const int cards = countNamed(win->contentItem(), QStringLiteral("fileCard")); check(cards == 1, QStringLiteral("a delivered file is a card in the tool row (%1)") .arg(cards)); check(item(win, QStringLiteral("fileOpen0")) != nullptr && item(win, QStringLiteral("fileShare0")) != nullptr, QStringLiteral("the card carries open and share")); win->setProperty("statusText", QString()); /* A click goes to whatever is at that point, and the transcript replays * from its bottom: on a phone-sized window the card can be above the * visible part, where isVisible() still says yes (it does not know about * the view's clipping). scrollIntoView puts it in the middle of the view -- * and stop the view following its tail (ChatView.stickBottom) first, or * the scroll is undone by the next content change. */ if (list) { /* Stop the view following its tail (ChatView.stickBottom) first: the * scroll to the card below would be undone by the next content * change. */ if (QQuickItem *chat = item(win, QStringLiteral("chatView"))) chat->setProperty("stickBottom", false); } QStringList rels; QString cardSays; QList strips; collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &strips); /* Which card is the session's own. The tree still holds the delegate of * the session before -- a view keeps a row or two around -- and its card * names a file of *that* session, so the first "fileOpen0" in the tree is * not necessarily the one the check means. A rel this session can open is * one that is really in its attachment directory. */ QQuickItem *openButton = nullptr; QQuickItem *shareButton = nullptr; const QString attachDir = core.attachmentDir(); for (QQuickItem *s : strips) { const QVariantList files = s->property("files").toList(); for (int i = 0; i < files.size(); i++) { const QVariant &f = files.at(i); rels << f.toMap().value(QStringLiteral("rel")).toString(); if (openButton) { continue; } const QString rel = f.toMap().value(QStringLiteral("rel")).toString(); if (f.toMap().value(QStringLiteral("kind")).toString() == QLatin1String("img") || rel.isEmpty() || !QFileInfo::exists(attachDir + QLatin1Char('/') + rel)) continue; QList hits; collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits); if (!hits.isEmpty()) { openButton = hits.first(); hits.clear(); collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits); shareButton = hits.isEmpty() ? nullptr : hits.first(); } } if (cardSays.isEmpty()) cardSays = s->property("errorText").toString(); } if (!openButton) openButton = item(win, QStringLiteral("fileOpen0")); if (!shareButton) shareButton = item(win, QStringLiteral("fileShare0")); /* What the card itself hands the engine ("rel" is the name the store keeps * the file under), and whether it is really on screen: a card outside the * viewport takes the events meant for it, and the card's own line is where * the engine puts its refusal. */ if (openButton) { scrollIntoView(openButton, true); frame(win); clickItem(win, openButton); } else { check(false, QStringLiteral("the delivered file has a card to open")); } const bool byMouse = !win->property("statusText").toString().isEmpty(); if (!byMouse && openButton) tapItem(win, touch, openButton); const QString opened = win->property("statusText").toString(); check(opened.startsWith(QLatin1String("opened ")), QStringLiteral("Open hands the file to the system (%1%2; cards hold '%3', " "%4, card says '%5')") .arg(opened, byMouse ? QString() : QStringLiteral(" after a tap"), rels.join(QLatin1String(", ")), onScreen(openButton) ? QStringLiteral("on screen") : QStringLiteral("out of the viewport"), cardSays)); win->setProperty("statusText", QString()); if (shareButton) { scrollIntoView(shareButton, true); frame(win); clickItem(win, shareButton); } const bool shareByMouse = !win->property("statusText").toString().isEmpty(); if (!shareByMouse && shareButton) tapItem(win, touch, shareButton); const QString shared = win->property("statusText").toString(); check(shared.contains(QLatin1String("report.md")), QStringLiteral("Share names the file (%1%2; card says '%3')") .arg(shared, shareByMouse ? QString() : QStringLiteral(" after a tap"), cardSays)); const QString refused = core.openArtifact(QStringLiteral("art-not-one-of-ours.md")); check(refused.contains(QLatin1String("no such file")), QStringLiteral("a name this session does not own is refused (%1)") .arg(refused)); /* And the card says it: a tap that cannot work must not look dead. */ QQuickItem *strip = nullptr; { QList hits; collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits); for (QQuickItem *it : hits) if (it->isVisible()) strip = it; } bool called = false; if (strip) called = QMetaObject::invokeMethod( strip, "openFile", Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md"))); const QVariant err = strip ? strip->property("errorText") : QVariant(); check(called && err.toString().contains(QLatin1String("no such file")), QStringLiteral("the card shows why it could not open it (%1)") .arg(err.toString())); QTest::qWait(150); QQuickItem *errText = item(win, QStringLiteral("fileError")); check(errText && errText->isVisible() && errText->property("text").toString().contains( QLatin1String("no such file")), QStringLiteral("and the line is where the card is")); } /* The approval switch is not decoration: the engine asks for a tool only * when its own confirm_* flags say so (needs_confirm() in agent_loop.c), * and every front end that has a consent dialog has to set them -- the * window used to leave all three at zero, which is why "ask" never asked. */ { const int was = core.approval(); core.setApproval(CST_APPROVAL_ASK); const cst_agent_config_t *live = cst_agent_config(core.agent()); check(live && live->approval == CST_APPROVAL_ASK && live->confirm_shell && live->confirm_write && live->confirm_net, QStringLiteral("'ask' reaches the engine as the three confirm flags")); core.setApproval(CST_APPROVAL_YOLO); live = cst_agent_config(core.agent()); check(live && live->approval == CST_APPROVAL_YOLO && !live->confirm_shell && !live->confirm_write && !live->confirm_net, QStringLiteral("and 'yolo' takes them all away")); core.setApproval(was); /* leave the machine as it was found */ } /* --- md_to_pdf: the front end's own document writer ------------------- */ { const cst_tool_def_t *def = cst_tool_find("md_to_pdf"); check(def != nullptr && def->owner != nullptr && strncmp(def->owner, "app:qt", 6) == 0, QStringLiteral("md_to_pdf is registered by the front end")); if (def && def->run_ex) { /* Run it the way the agent's turn would: through the registry, with a * call and the engine's context. */ const QByteArray ws = core.workspace().toUtf8(); cst_tool_ctx_t ctx; memset(&ctx, 0, sizeof(ctx)); ctx.workspace_root = ws.constData(); ctx.agent = core.agent(); ctl_tool_call_t call; memset(&call, 0, sizeof(call)); snprintf(call.id, sizeof(call.id), "pdf1"); snprintf(call.name, sizeof(call.name), "md_to_pdf"); static const char *kMd = "# 设备信息报告\n\n这是一段**中文**正文,用来确认字体和分页。\n\n" "- 项目一\n- 项目二\n\n```\ncode block\n```\n"; static const char *kOut = "selftest.pdf"; snprintf(call.args[0].key, sizeof(call.args[0].key), "markdown"); call.args[0].value = const_cast(kMd); call.args[0].value_len = strlen(kMd); snprintf(call.args[1].key, sizeof(call.args[1].key), "out"); call.args[1].value = const_cast(kOut); call.args[1].value_len = strlen(kOut); call.nargs = 2; cst_tool_result_t res; memset(&res, 0, sizeof(res)); const int rc = cst_tool_run(&ctx, &call, &res); check(res.exit_code == 0, QStringLiteral("md_to_pdf writes the document (%1)") .arg(QString::fromUtf8(res.out ? res.out : ""))); check(rc == 0 && res.nattach == 1, QStringLiteral("and hands it over as a file for the user")); QFile f(core.workspace() + QStringLiteral("/selftest.pdf")); const bool opened = f.open(QIODevice::ReadOnly); const QByteArray head = opened ? f.read(5) : QByteArray(); check(head == QByteArrayLiteral("%PDF-"), QStringLiteral("the bytes are a PDF (%1)") .arg(QString::fromLatin1(head))); cst_tool_result_free(&res); } } /* Paper is white whatever the window's palette says: markdown carries no * colour, so a dark window turned every plain paragraph #eeeeee -- a PDF * that looked blank. Draw it on a white image and look for ink. */ { const QPalette was = QGuiApplication::palette(); QPalette darkPalette = was; darkPalette.setColor(QPalette::Text, QColor(0xee, 0xee, 0xee)); darkPalette.setColor(QPalette::WindowText, QColor(0xee, 0xee, 0xee)); QGuiApplication::setPalette(darkPalette); std::unique_ptr page(app::paperDocument( QStringLiteral("text that names no colour of its own" ""))); QImage paper(320, 60, QImage::Format_RGB32); paper.fill(0xffffffffu); QPainter ink(&paper); page->drawContents(&ink); ink.end(); int darkest = 255; for (int y = 0; y < paper.height(); y++) { const QRgb *row = reinterpret_cast(paper.constScanLine(y)); for (int x = 0; x < paper.width(); x++) darkest = qMin(darkest, qGray(row[x])); } check(darkest < 64, QStringLiteral("a dark window still lays out black ink on paper " "(darkest pixel %1)") .arg(darkest)); QGuiApplication::setPalette(was); } /* A tool that takes minutes -- a build, a wide search -- must not read as a * window that has hung: while a card runs, its chip counts. */ { QList cards; collectNamed(win->contentItem(), QStringLiteral("toolCard"), &cards); check(!cards.isEmpty(), QStringLiteral("the fixture session has a tool card to watch")); if (!cards.isEmpty()) { QQuickItem *card = cards.first(); card->setProperty("state", "running"); /* four minutes eighteen in, the point where "running" alone stopped * being an answer */ card->setProperty( "startedAt", static_cast(QDateTime::currentMSecsSinceEpoch()) - 258000.0); card->setProperty("runningFor", 258); QList chips; collectNamed(card, QStringLiteral("toolChip"), &chips); const QString counting = chips.isEmpty() ? QString() : chips.first()->property("text").toString(); check(counting.contains(QLatin1String("4:18")), QStringLiteral("a running card says how long it has been going (%1)") .arg(counting)); card->setProperty("state", "done"); const QString settled = chips.isEmpty() ? QString() : chips.first()->property("text").toString(); check(!settled.contains(QLatin1String("4:18")), QStringLiteral("and a finished card stops counting (%1)") .arg(settled)); } } /* new sessions: the engine moves, the view empties, the store grows */ const int before = core.sessions().size(); clickMouse(win, QStringLiteral("newSessionButton")); const QString first = core.sessionId(); check(first != fixture, QStringLiteral("New starts another session")); clickMouse(win, QStringLiteral("newSessionButton")); const QString second = core.sessionId(); check(second != first, QStringLiteral("New starts another one each time")); check(core.sessions().size() == before + 2, QStringLiteral("both new sessions are in the store")); check(list && list->property("count").toInt() == 0, QStringLiteral("a fresh session starts with an empty view")); /* the x drops a stored session and leaves the open one alone */ QTest::qWait(150); clickMouse(win, QStringLiteral("sessionDelete:") + first); check(core.sessions().size() == before + 1, QStringLiteral("the x drops a stored session")); check(core.sessionId() == second, QStringLiteral("deleting another session leaves the open one alone")); /* and back to the fixture, which still has its six rows */ QTest::qWait(150); /* The sidebar holds two more sessions by now, so the row may sit below the * fold: brought into view first, or the click goes to the row that happens * to be there (see scrollIntoView). */ if (QQuickItem *row = item(win, QStringLiteral("sessionRow:") + fixture)) { scrollIntoView(row); frame(win); } clickMouse(win, QStringLiteral("sessionRow:") + fixture); check(core.sessionId() == fixture && list && list->property("count").toInt() == 6, QStringLiteral("switching back replays it again")); { bool promptImage = false; int promptImageRow = -1; const QVariantList rows = core.history(); for (int i = 0; i < rows.size(); i++) { const QVariant &v = rows.at(i); const QVariantMap r = v.toMap(); if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") && r.value(QStringLiteral("images")).toString().contains( QLatin1String("|"))) { promptImage = true; if (promptImageRow < 0) promptImageRow = i; } } check(promptImage, QStringLiteral("a stored prompt image comes back with its row")); /* The view follows the tail (ChatView.stickBottom) and the picture is in * the first prompt: on a window that fits fewer rows than this one it is * above the visible part, where a delegate is not created at all. Put it * on screen, which is what a person does before looking at it. */ if (list && promptImageRow >= 0) QMetaObject::invokeMethod(list, "positionViewAtIndex", Q_ARG(int, promptImageRow), Q_ARG(int, 0)); /* The rows are in the model by now; the cards -- and the thumbnails in * them -- are built on the view's next layout pass. A frame forces it, * and that frame is also the picture a person looks at. */ maybeShot(win, QStringLiteral("-images")); frame(win); if (qEnvironmentVariableIsSet("CST_APP_DUMP")) { QVariant v; QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v), Q_ARG(QVariant, 0)); std::printf("note: replayed row 0: %s\n", qPrintable(v.toString())); std::printf("note: %d strips and %d thumbs in the tree\n", countNamed(win->contentItem(), QStringLiteral("imageStrip")), countNamed(win->contentItem(), QStringLiteral("imageThumb"))); } check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >= 1, QStringLiteral("the replayed transcript shows it as a thumbnail (%1)") .arg(countNamed(win->contentItem(), QStringLiteral("imageThumb")))); } /* --- provider, model, key: the settings dialog ------------------------ */ check(core.providers().size() > 0 && !core.providers().first().toMap().value(QStringLiteral("name")) .toString() .isEmpty(), QStringLiteral("the provider presets are readable")); check(core.useProvider(QStringLiteral("no-such-provider"), QString(), QString()) .contains(QLatin1String("no such provider")), QStringLiteral("switching to an unknown provider is refused")); check(core.usage().contains(QStringLiteral("requests")), QStringLiteral("the token counters are readable")); clickMouse(win, QStringLiteral("settingsButton")); QQuickItem *panel = item(win, QStringLiteral("settingsPanel")); check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog")); /* Where the pieces actually are, whether or not that worked: a click goes to * whatever is on top at that point, and a style lays a header out its own * way -- the phone runs a different style from a desktop (Universal against * Basic), which is where the header's content ended up 35 px below the * header itself. */ { QQuickItem *bar = item(win, QStringLiteral("appBar")); const QMargins safe = win->property("safeAreaMargins").value(); note(QStringLiteral("style %1, window %2x%3, header y %4 h %5, " "gear centre y %6, safe area %7/%8/%9/%10") .arg(QQuickStyle::name()) .arg(win->width()) .arg(win->height()) .arg(bar ? bar->y() : -1) .arg(bar ? bar->height() : -1) .arg(item(win, QStringLiteral("settingsButton")) ? centre(item(win, QStringLiteral("settingsButton"))).y() : -1) .arg(safe.left()) .arg(safe.top()) .arg(safe.right()) .arg(safe.bottom())); } /* item() answers with an item that exists even when nothing is visible (the * QObject-tree fallback), so "the dialog is not open" is the invisible case * too. */ if (!panel || !panel->isVisible()) { /* Two ways to be wrong -- a click that never lands, and a handler that * does nothing -- and from the outside they look the same (this failed * only on the phone, with no QML error at all). Say which, and go on with * the dialog either way, so the checks below still say something. */ QQuickItem *gear = item(win, QStringLiteral("settingsButton")); check(gear != nullptr && gear->isVisible(), QStringLiteral("... the gear is %1, centre %2,%3 of %4x%5") .arg(gear && gear->isVisible() ? QStringLiteral("reachable") : QStringLiteral("not there")) .arg(gear ? centre(gear).x() : -1) .arg(gear ? centre(gear).y() : -1) .arg(win->width()) .arg(win->height())); QMetaObject::invokeMethod(win, "openSettings"); QTest::qWait(120); panel = item(win, QStringLiteral("settingsPanel")); check(panel && panel->isVisible(), QStringLiteral("... and openSettings() opens it when called directly")); } check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0, QStringLiteral("the dialog lists the providers")); maybeShot(win, QStringLiteral("-settings")); QQuickItem *mfield = item(win, QStringLiteral("settingsModel")); if (mfield && panel) { mfield->setProperty("text", QStringLiteral("cst-test-model")); clickMouse(win, QStringLiteral("settingsApply")); check(core.model() == QLatin1String("cst-test-model"), QStringLiteral("the model picked in the dialog reaches the engine")); QQuickItem *note = item(win, QStringLiteral("settingsNote")); check(note && note->property("text").toString().contains( QLatin1String("cst-test-model")), QStringLiteral("the dialog says what it is using now")); clickMouse(win, QStringLiteral("settingsClose")); check(!panel->isVisible(), QStringLiteral("Close puts the dialog away")); } else { check(false, QStringLiteral("the dialog has a model field")); } /* --- advanced: a custom provider, added and removed ------------------- */ /* The run has a scratch config dir (main.cpp points XDG_CONFIG_HOME at it), * so the engine's user model file is this run's own. */ check(core.genParams(QStringLiteral("deepseek")).value(QStringLiteral("temperature")) .toDouble() == 0.0, QStringLiteral("no generation parameters are saved to begin with")); check(core.setGenParams(QStringLiteral("deepseek"), 0.2, 0.9, 4096).isEmpty(), QStringLiteral("generation parameters are accepted")); { const QVariantMap g = core.genParams(QStringLiteral("deepseek")); check(g.value(QStringLiteral("temperature")).toDouble() > 0.19 && g.value(QStringLiteral("max_tokens")).toInt() == 4096, QStringLiteral("they are remembered per provider (%1 / %2 / %3)") .arg(g.value(QStringLiteral("temperature")).toDouble()) .arg(g.value(QStringLiteral("top_p")).toDouble()) .arg(g.value(QStringLiteral("max_tokens")).toInt())); } check(core.addProvider(QStringLiteral("cst-scratch"), QStringLiteral("https://api.example.com/v1"), QStringLiteral("CST_SCRATCH_KEY"), QStringLiteral("scratch-1"), QStringLiteral("scratch-1, scratch-2")) .isEmpty(), QStringLiteral("a provider the presets do not know is added")); { bool found = false, custom = false; QString base, models; const QVariantList list = core.providers(); for (const QVariant &v : list) { const QVariantMap p = v.toMap(); if (p.value(QStringLiteral("name")).toString() != QLatin1String("cst-scratch")) continue; found = true; custom = p.value(QStringLiteral("custom")).toBool(); base = p.value(QStringLiteral("base")).toString(); models = core.modelChoices(QStringLiteral("cst-scratch")).join(QLatin1Char(',')); } check(found && custom && base.contains(QLatin1String("api.example.com")), QStringLiteral("it shows up in the provider list as a custom one")); check(models.contains(QLatin1String("scratch-1")) && models.contains(QLatin1String("scratch-2")), QStringLiteral("the models it lists are the picker's (%1)").arg(models)); } check(!core.addProvider(QStringLiteral("bad name"), QStringLiteral("https://x/v1"), QString(), QString(), QString()) .isEmpty(), QStringLiteral("a name that is not a provider name is refused")); check(core.removeProvider(QStringLiteral("cst-scratch")) == 1, QStringLiteral("the custom provider can be removed again")); { bool gone = true; const QVariantList list = core.providers(); for (const QVariant &v : list) if (v.toMap().value(QStringLiteral("name")).toString() == QLatin1String("cst-scratch")) gone = false; check(gone, QStringLiteral("and it is gone from the list")); } check(core.removeProvider(QStringLiteral("deepseek")) == 0, QStringLiteral("a preset cannot be removed (there is no user entry to drop)")); check(core.setGenParams(QStringLiteral("deepseek"), 0, 0, 0).isEmpty() && core.genParams(QStringLiteral("deepseek")) .value(QStringLiteral("temperature")) .toDouble() == 0.0, QStringLiteral("parameters can be cleared again")); /* The dialog drives the same two engine calls: open it, fold the advanced * section out and put a provider in through the form. */ clickMouse(win, QStringLiteral("settingsButton")); QTest::qWait(150); /* the dialog's layout settles before it is clicked into */ if (panel && mfield) { clickMouse(win, QStringLiteral("settingsAdvanced")); QTest::qWait(150); /* the fold-out re-lays the body out */ QQuickItem *nfield = item(win, QStringLiteral("advName")); QQuickItem *bfield = item(win, QStringLiteral("advBase")); check(nfield && nfield->isVisible(), QStringLiteral("Advanced options opens in the dialog")); maybeShot(win, QStringLiteral("-advanced")); if (nfield && bfield) { nfield->setProperty("text", QStringLiteral("cst-form")); bfield->setProperty("text", QStringLiteral("https://form.example.com/v1")); /* The form sits at the end of the dialog's scrolling body: scroll it * into view so the click lands on the button, not on the frame. */ QQuickItem *scroller = item(win, QStringLiteral("settingsBody")); if (scroller) { scroller->setProperty("contentY", scroller->property("contentHeight").toReal() - scroller->height()); QTest::qWait(120); /* the flickable settles */ } clickMouse(win, QStringLiteral("settingsSaveProvider")); bool found = false; const QVariantList list = core.providers(); for (const QVariant &v : list) if (v.toMap().value(QStringLiteral("name")).toString() == QLatin1String("cst-form")) found = true; check(found, QStringLiteral("the form in the dialog adds a provider")); QQuickItem *rm = item(win, QStringLiteral("settingsRemoveProvider")); check(rm && rm->property("enabled").toBool(), QStringLiteral("its entry can be removed from the form")); clickMouse(win, QStringLiteral("settingsRemoveProvider")); check(core.removeProvider(QStringLiteral("cst-form")) == 0, QStringLiteral("the form's provider is gone again")); } else { check(false, QStringLiteral("the advanced form has name and base URL fields")); } clickMouse(win, QStringLiteral("settingsClose")); } else { check(false, QStringLiteral("the settings dialog can be reopened")); } /* --- attachments: a file goes next to the session -------------------- */ const QString att = core.workspace() + QStringLiteral("/note.txt"); { QFile f(att); f.open(QIODevice::WriteOnly); f.write("hello from a fixture\n"); } check(core.attachFile(att).isEmpty(), QStringLiteral("a file on disk can be attached")); check(core.pendingAttachments().size() == 1, QStringLiteral("the engine reports one attachment for the next prompt")); check(core.pendingAttachments().first().contains(QLatin1String("note.txt")), QStringLiteral("the attachment names the file it came from")); check(!core.attachFile(QStringLiteral("/no/such/file-here")).isEmpty(), QStringLiteral("a missing file is refused with a reason")); QTest::qWait(120); check(countNamed(win->contentItem(), QStringLiteral("attachChip")) == 1, QStringLiteral("the composer lists it")); maybeShot(win, QStringLiteral("-attach")); clickMouse(win, QStringLiteral("clearAttachments")); check(core.pendingAttachments().isEmpty(), QStringLiteral("clear all drops them")); /* the paste path: an image on the clipboard becomes an attachment */ QGuiApplication *gui = qobject_cast(QCoreApplication::instance()); #if !defined(Q_OS_ANDROID) if (gui && gui->clipboard()) { QImage img(8, 8, QImage::Format_RGB32); img.fill(Qt::red); gui->clipboard()->setImage(img); check(core.clipboardHasImage(), QStringLiteral("the clipboard image is seen")); check(core.attachClipboard().isEmpty() && core.pendingAttachments().size() == 1, QStringLiteral("an image on the clipboard is attached")); core.clearPending(); } else { check(false, QStringLiteral("there is a clipboard to paste from")); } #else /* Android's clipboard carries text and URIs, not images: setting one there * does nothing, and a phone that "fails" this is not a phone that is * broken. The paste path itself is the same code the desktop runs. */ (void)gui; note(QStringLiteral("clipboard image checks skipped (Android)")); #endif /* --- tool calls: two calls of one tool are two cards ------------------ */ const int rows0 = list ? list->property("count").toInt() : -1; auto rowAt = [&](int i) { QVariant v; QVariantMap r; if (!QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v), Q_ARG(QVariant, i))) return r; return QJsonDocument::fromJson(v.toString().toUtf8()).object().toVariantMap(); }; auto event4 = [&](const char *fn, const QVariant &a, const QVariant &b, const QVariant &c, const QVariant &d) { return QMetaObject::invokeMethod(win, fn, Q_ARG(QVariant, a), Q_ARG(QVariant, b), Q_ARG(QVariant, c), Q_ARG(QVariant, d)); }; check(event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-1"), QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"vendor\"}")) && list->property("count").toInt() == rows0 + 1, QStringLiteral("a tool call opens a card")); event4("engineToolEvent", QStringLiteral("run"), QStringLiteral("call-1"), QStringLiteral("list_dir"), QString()); /* the same tool a second time, while the first call is still open: pairing by name is what left the first card on "running" forever */ event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-2"), QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"src\"}")); event4("engineToolResult", QStringLiteral("call-1"), QStringLiteral("list_dir"), QVariant(0), QStringLiteral("exit=0 bytes=532")); check(list->property("count").toInt() == rows0 + 2, QStringLiteral("two calls of one tool are two cards (%1 -> %2)") .arg(rows0) .arg(list->property("count").toInt())); { const QVariantMap first = rowAt(rows0), second = rowAt(rows0 + 1); check(first.value(QStringLiteral("state")).toString() == QLatin1String("done") && first.value(QStringLiteral("output")).toString() == QLatin1String("exit=0 bytes=532"), QStringLiteral("the result lands on the card of its own call " "(state '%1', output '%2', keys [%3])") .arg(first.value(QStringLiteral("state")).toString(), first.value(QStringLiteral("output")).toString(), first.keys().join(QLatin1Char(',')))); check(second.value(QStringLiteral("state")).toString() == QLatin1String("pending") && second.value(QStringLiteral("detail")).toString().contains( QLatin1String("\"src\"")) && second.value(QStringLiteral("output")).toString().isEmpty(), QStringLiteral("the other card is untouched (state '%1')") .arg(second.value(QStringLiteral("state")).toString())); } /* and a turn that ends with the call still open settles it */ check(QMetaObject::invokeMethod(win, "engineTurnFinished", Q_ARG(QVariant, 0), Q_ARG(QVariant, QString())), QStringLiteral("the turn end is reachable too")); { const QVariantMap second = rowAt(rows0 + 1); check(second.value(QStringLiteral("complete")).toBool() && second.value(QStringLiteral("state")).toString() == QLatin1String("unfinished"), QStringLiteral("an open call is settled when the turn ends")); } /* --- the answer is rendered while it streams -------------------------- */ if (QMetaObject::invokeMethod(win, "engineAssistant", Q_ARG(QVariant, QStringLiteral("**bold** text")))) { const int last = list->property("count").toInt() - 1; QMetaObject::invokeMethod(win, "renderStreaming"); const QVariantMap row = rowAt(last); check(row.value(QStringLiteral("kind")).toString() == QLatin1String("message") && !row.value(QStringLiteral("complete")).toBool(), QStringLiteral("a delta opens a streaming answer")); check(row.value(QStringLiteral("html")).toString().contains(QLatin1Char('<')) && row.value(QStringLiteral("text")).toString().contains(QLatin1String("bold")), QStringLiteral("markdown is rendered while the answer streams")); } else { check(false, QStringLiteral("assistant deltas are reachable")); } /* --- the image provider: only the session's own files ----------------- */ { const QString dir = core.attachmentDir(); /* one directory per session since v0.9.5: //attachments. * The engine builds the path with the separator of the platform it runs * on, so what arrives here is "…/sessions\\attachments" on Windows: * asked with forward slashes, which is what the check means. */ const QString shown = QDir::fromNativeSeparators(dir); check(!shown.isEmpty() && shown.endsWith(QLatin1String("/attachments")) && shown.contains(QLatin1String("/sessions/")), QStringLiteral("the agent names its image directory (%1)").arg(dir)); QImage pic(32, 16, QImage::Format_RGB32); pic.fill(Qt::darkGreen); QByteArray png; QBuffer buf(&png); buf.open(QIODevice::WriteOnly); pic.save(&buf, "PNG"); char aerr[256] = ""; cst_attachment_t *stored = cst_attach_import_bytes( cst_agent_session_path(core.agent()), "probe.png", png.constData(), static_cast(png.size()), aerr, sizeof(aerr)); check(stored != nullptr, QStringLiteral("a picture can be stored next to the session")); if (stored) { QSize got; const QImage shrunk = images->requestImage(QString::fromUtf8(stored->rel), &got, QSize(8, 8)); check(!shrunk.isNull() && shrunk.width() == 8 && shrunk.height() == 4, QStringLiteral("the provider serves it, shrunk to what was asked " "(%1x%2)") .arg(shrunk.width()) .arg(shrunk.height())); check(images->requestImage(QStringLiteral("../escape.png"), &got, QSize(64, 64)) .isNull(), QStringLiteral("a name outside the attachment directory is refused")); check(images->requestImage(QStringLiteral("no-such-file.png"), &got, QSize(64, 64)) .isNull(), QStringLiteral("a file that is not there is refused")); cst_attach_free(stored); } } /* --- a tool that made a picture: its card shows it -------------------- */ { const int before = list->property("count").toInt(); const int thumbsBefore = countNamed(win->contentItem(), QStringLiteral("imageThumb")); if (qEnvironmentVariableIsSet("CST_APP_DUMP") && list) std::printf("note: tool card: model %d, contentY %.1f of %.1f (view %.1f), " "%d strips, %d thumbs, %d cards\n", before, list->property("contentY").toDouble(), list->property("contentHeight").toDouble(), list->height(), countNamed(win->contentItem(), QStringLiteral("imageStrip")), thumbsBefore, countNamed(win->contentItem(), QStringLiteral("card:"))); event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"), QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}")); const QString imgs = QStringLiteral("img-fixture.png|shot.png|view_image|24x8"); check(QMetaObject::invokeMethod(win, "engineToolImages", Q_ARG(QVariant, QStringLiteral("call-img")), Q_ARG(QVariant, imgs)), QStringLiteral("images a tool made reach the view")); frame(win); const QVariantMap card = rowAt(before); check(card.value(QStringLiteral("images")) .toString() .contains(QLatin1String("img-fixture.png")) && card.value(QStringLiteral("name")).toString() == QLatin1String("view_image"), QStringLiteral("the card of that call carries the image")); check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) > thumbsBefore, QStringLiteral("its thumbnail is in the card (%1 -> %2)").arg(thumbsBefore) .arg(countNamed(win->contentItem(), QStringLiteral("imageThumb")))); maybeShot(win, QStringLiteral("-toolimage")); } /* --- the order the engine uses on a live turn: the result first, the * files after it (a card is complete by then, which used to lose them) --- */ { const int before = list->property("count").toInt(); const int cardsBefore = countNamed(win->contentItem(), QStringLiteral("fileCard")); event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-file"), QStringLiteral("deliver_file"), QStringLiteral("{\"path\":\"report.md\"}")); event4("engineToolEvent", QStringLiteral("done"), QStringLiteral("call-file"), QStringLiteral("deliver_file"), QStringLiteral("exit=0 bytes=120")); event4("engineToolResult", QStringLiteral("call-file"), QStringLiteral("deliver_file"), QVariant(0), QStringLiteral("delivered report.md (text/markdown, 120 B)")); const QString files = QStringLiteral("file|art-0123456789abcdef.md|report.md|tool|" "text/markdown|120|0x0"); check(QMetaObject::invokeMethod(win, "engineToolImages", Q_ARG(QVariant, QStringLiteral("call-file")), Q_ARG(QVariant, files)), QStringLiteral("the files a tool delivered reach the view")); frame(win); const QVariantMap card = rowAt(before); check(card.value(QStringLiteral("images")) .toString() .contains(QLatin1String("art-0123456789abcdef.md")), QStringLiteral("a card that is already complete still takes them")); check(countNamed(win->contentItem(), QStringLiteral("fileCard")) > cardsBefore, QStringLiteral("so the delivered file is on the card (%1 -> %2)") .arg(cardsBefore) .arg(countNamed(win->contentItem(), QStringLiteral("fileCard")))); } /* --- a narrow window: dock -> drawer, and the button opens it --- */ resizeTo(win, 460, 900); check(docked && !docked->isVisible(), QStringLiteral("narrow window: the docked sidebar folds away")); check(navButton && navButton->isVisible(), QStringLiteral("narrow window: the drawer button appears")); clickMouse(win, QStringLiteral("navButton")); check(navPanel && navPanel->isVisible(), QStringLiteral("drawer opens over the transcript")); /* A phone closes the drawer with the back key, not with a second tap. */ QTest::keyClick(win, Qt::Key_Back); QTest::qWait(120); check(navPanel && !navPanel->isVisible(), QStringLiteral("back closes the drawer")); clickMouse(win, QStringLiteral("navButton")); check(navPanel && navPanel->isVisible(), QStringLiteral("and the drawer button opens it again")); clickMouse(win, QStringLiteral("navButton")); check(navPanel && !navPanel->isVisible(), QStringLiteral("drawer closes again")); resizeTo(win, 1200, 820); check(docked && docked->isVisible(), QStringLiteral("widening again docks the sidebar back")); /* --- the phone's back key closes what is open -------------------------- */ /* This one is last on purpose: on the offscreen platform a popup closed by a * key leaves its modal overlay over the window -- the dialog says it is * closed while the overlay still eats clicks. Nothing that clicks may come * after it. */ { QMetaObject::invokeMethod(win, "openSettings"); QTest::qWait(300); QQuickItem *panel = item(win, QStringLiteral("settingsPanel")); check(panel && panel->isVisible(), QStringLiteral("back: the settings dialog is open to close")); /* Which layer is open is asked of QML, not of an item's `visible`: the * dialog's content item lives on after a close and still reports visible * while it fades. backLayer() is what the Back key reads, so the test asks * for exactly that. */ QVariant before; QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before)); check(before.toString() == QStringLiteral("settings"), QStringLiteral("back: the settings layer is the one open")); QTest::keyClick(win, Qt::Key_Back); QTest::qWait(200); QVariant after; QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after)); note(QStringLiteral("after back: layer \"%1\"").arg(after.toString())); check(after.toString().isEmpty(), QStringLiteral("back closes the dialog instead of the window")); check(win->isVisible(), QStringLiteral("and the window is still there")); /* With nothing open the key belongs to the platform: a phone leaves the * window with it, so this side must not take it. On a desktop nothing * happens and the window has to still be there; on a phone the window * going away *is* the hand-over, so what is asked is that no layer was * touched (whether the activity is already gone is the platform's own * timing). This is the last thing the run does on a phone: the activity * leaving takes the process with it, so the log can end on the check below * without the closing line -- every line is flushed as it is written. */ QTest::keyClick(win, Qt::Key_Back); QTest::qWait(120); QVariant idle; QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle)); #if defined(Q_OS_ANDROID) note(QStringLiteral("after the last back: window visible %1") .arg(win->isVisible() ? 1 : 0)); check(idle.toString().isEmpty(), QStringLiteral("back with nothing open is left to the platform")); #else check(idle.toString().isEmpty() && win->isVisible(), QStringLiteral("back with nothing open is left to the platform")); #endif } /* A picture of the last state on request (see maybeShot). */ maybeShot(win, QString()); std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed"); if (g_runLog.isOpen()) { g_runLog.write("--- "); g_runLog.write(g_fail ? "FAILED" : "all UI checks passed"); g_runLog.write("\n"); g_runLog.close(); } return g_fail ? 1 : 0; } } // namespace app