forked from WHC/Hermit
needs_confirm() (src/agent_loop.c) decides whether a tool asks by reading cfg->confirm_shell / confirm_write / confirm_net; the approval mode only relaxes those. The CLI fills them in (main.c: parse_confirm), the TUI and the WebUI do too -- the Qt window never did: AgentCore::open() memset the whole config and cst_agent_create() keeps what it is given, so every tool ran without asking while the sidebar said "ask". That is why nothing was ever asked on the phone (or on a desktop). - AgentCore: one mapping, applyApproval(), used by open() and setApproval(); the mode is remembered like the TUI's /approval and read back on start (else "ask", the only mode where the window's consent dialog is reached). - --uitest: two checks that the mode reaches the engine as those flags. Version 0.9.7 (local).
927 lines
42 KiB
C++
927 lines
42 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 <QFile>
|
|
#include <QImage>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QPainter>
|
|
#include <QPointingDevice>
|
|
#include <QQuickItem>
|
|
#include <QQmlApplicationEngine>
|
|
#include <QQmlContext>
|
|
#include <QQuickWindow>
|
|
#include <QTest>
|
|
|
|
#include <QVariantMap>
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
#include "cst_agent.h"
|
|
#include "cst_attach.h"
|
|
#include "ui/SessionImages.h"
|
|
#include "ui/ThemeIcons.h"
|
|
|
|
namespace app {
|
|
namespace {
|
|
|
|
int g_fail;
|
|
|
|
void check(bool ok, const QString &what) {
|
|
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
|
|
std::fflush(stdout);
|
|
if (!ok)
|
|
g_fail++;
|
|
}
|
|
|
|
/* 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");
|
|
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"));
|
|
const QString opened = win->property("statusText").toString();
|
|
check(opened.startsWith(QLatin1String("opened ")),
|
|
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
|
|
clickMouse(win, QStringLiteral("fileShare0"));
|
|
const QString shared = win->property("statusText").toString();
|
|
check(shared.contains(QLatin1String("report.md")),
|
|
QStringLiteral("Share names the file (%1)").arg(shared));
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* 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"));
|
|
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 (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"));
|
|
}
|
|
|
|
/* --- 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");
|
|
return g_fail ? 1 : 0;
|
|
}
|
|
|
|
} // namespace app
|