The two clipboard-image checks failed on the phone for a reason that is not a defect: Android's clipboard carries text and URIs, not images -- setImage does nothing there -- so they are skipped on Android with a note saying so, instead of reporting a phone that is broken because it is a phone. Open and Share still fail on the device, and now say how far they got: neither the synthesized mouse click nor a touch tap leaves a line on the card's buttons, while the same click reaches the header and the sidebar. The engine's own path is fine (openArtifact() called directly answers as it should), and the only way for the header to stay empty is openArtifact()'s early return for a name it cannot resolve -- so what is left to look at is the name the card hands over (the model's `rel`) and whether the card is inside the visible part of the scrolled transcript at that moment, since QQuickItem::isVisible() does not account for clipping. Device run after this: 95 ok, 2 FAIL (was 17 before this session's fixes).
1125 lines
51 KiB
C++
1125 lines
51 KiB
C++
/* --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 <QGuiApplication>
|
|
#include <QClipboard>
|
|
#include <QBuffer>
|
|
#include <QDateTime>
|
|
#include <QFile>
|
|
#include <QImage>
|
|
#include <QPalette>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QPainter>
|
|
#include <QPointingDevice>
|
|
#include <QQuickItem>
|
|
#include <QQuickStyle>
|
|
#include <QQmlApplicationEngine>
|
|
#include <QQmlContext>
|
|
#include <QQuickWindow>
|
|
#include <QTest>
|
|
#include <QTextDocument>
|
|
|
|
#include <QVariantMap>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <memory>
|
|
|
|
#include "cst_agent.h"
|
|
#include "cst_attach.h"
|
|
#include "core/ToolProvider.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<size_t>(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<QQuickItem *> *out) {
|
|
if (!root)
|
|
return;
|
|
if (root->objectName() == name)
|
|
out->append(root);
|
|
const QList<QQuickItem *> 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<QQuickItem *> 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. */
|
|
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
|
QList<QQuickItem *> hits;
|
|
collectNamed(win->contentItem(), name, &hits);
|
|
/* What a finger can reach is the one the test means: a delegate on its way
|
|
* out is still in the tree but invisible. */
|
|
for (QQuickItem *it : hits)
|
|
if (it->isVisible())
|
|
return it;
|
|
if (QQuickItem *it = win->findChild<QQuickItem *>(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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
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<size_t>(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<QQuickWindow *>(engine.rootObjects().value(0));
|
|
if (!win) {
|
|
std::printf("FAIL the root object is not a window\n");
|
|
return 1;
|
|
}
|
|
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"));
|
|
|
|
/* --- 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);
|
|
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());
|
|
clickMouse(win, QStringLiteral("fileOpen0"));
|
|
/* This button lives in the scrolling transcript, and on the NX789J the
|
|
* synthesized mouse click does not reach a button there -- the same click
|
|
* lands in the header and in the sidebar. What a phone has is a finger, so
|
|
* try that too, and say in the message which one it took: neither is a
|
|
* failure to hide. */
|
|
const bool byMouse = !win->property("statusText").toString().isEmpty();
|
|
if (!byMouse)
|
|
tapTouch(win, touch, QStringLiteral("fileOpen0"));
|
|
const QString opened = win->property("statusText").toString();
|
|
check(opened.startsWith(QLatin1String("opened ")),
|
|
QStringLiteral("Open hands the file to the system (%1%2)")
|
|
.arg(opened, byMouse ? QString() : QStringLiteral(" after a tap")));
|
|
win->setProperty("statusText", QString());
|
|
clickMouse(win, QStringLiteral("fileShare0"));
|
|
const bool shareByMouse = !win->property("statusText").toString().isEmpty();
|
|
if (!shareByMouse)
|
|
tapTouch(win, touch, QStringLiteral("fileShare0"));
|
|
const QString shared = win->property("statusText").toString();
|
|
check(shared.contains(QLatin1String("report.md")),
|
|
QStringLiteral("Share names the file (%1%2)")
|
|
.arg(shared, shareByMouse ? QString() : QStringLiteral(" after a tap")));
|
|
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<QQuickItem *> 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<char *>(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<char *>(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<QTextDocument> page(app::paperDocument(
|
|
QStringLiteral("<html><body>text that names no colour of its own"
|
|
"</body></html>")));
|
|
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<const QRgb *>(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<QQuickItem *> 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<double>(QDateTime::currentMSecsSinceEpoch()) - 258000.0);
|
|
card->setProperty("runningFor", 258);
|
|
QList<QQuickItem *> 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);
|
|
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
|
check(core.sessionId() == fixture && list &&
|
|
list->property("count").toInt() == 6,
|
|
QStringLiteral("switching back replays it again"));
|
|
{
|
|
bool promptImage = false;
|
|
const QVariantList rows = core.history();
|
|
for (const QVariant &v : rows) {
|
|
const QVariantMap r = v.toMap();
|
|
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
|
r.value(QStringLiteral("images")).toString().contains(
|
|
QLatin1String("|")))
|
|
promptImage = true;
|
|
}
|
|
check(promptImage,
|
|
QStringLiteral("a stored prompt image comes back with its row"));
|
|
/* 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"));
|
|
QTest::qWait(150);
|
|
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<QMargins>();
|
|
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<QGuiApplication *>(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: <sessions>/<id>/attachments */
|
|
check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) &&
|
|
dir.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<size_t>(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"));
|
|
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"));
|
|
QTest::qWait(150);
|
|
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"));
|
|
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"));
|
|
QTest::qWait(150);
|
|
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"));
|
|
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"));
|
|
|
|
/* 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
|