#include "app/controller.h" #include "app/pdf_canvas.h" #include "web/web_profile.h" #include #include #include #include #include #include #include #include #include #include #include using namespace docview; class ImeTest : public QObject { Q_OBJECT QJsonArray events_, commands_, phases_; QString output_ = qEnvironmentVariable("DOCVIEW_IME_OUTPUT"); bool wayland() const { return qEnvironmentVariable("DOCVIEW_IME_PLATFORM") == "wayland"; } bool eventFilter(QObject *object, QEvent *event) override { if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { auto *key = static_cast(event); events_.append(QJsonObject{{"object", object->metaObject()->className()}, {"type", event->type() == QEvent::KeyPress ? "press" : "release"}, {"key", key->key()}, {"text", key->text()}, {"scanCode", qint64(key->nativeScanCode())}, {"virtualKey", qint64(key->nativeVirtualKey())}, {"modifiers", int(key->modifiers())}}); } if (event->type() == QEvent::InputMethod) { auto *input = static_cast(event); events_.append(QJsonObject{{"object", object->metaObject()->className()}, {"preedit", input->preeditString()}, {"commit", input->commitString()}}); } return false; } bool run(const QString &program, const QStringList &arguments) { QProcess process; process.start(program, arguments); if (!process.waitForStarted(3000)) return false; QElapsedTimer deadline; deadline.start(); while (process.state() != QProcess::NotRunning && deadline.elapsed() < 10000) QTest::qWait(5); if (process.state() != QProcess::NotRunning) { process.kill(); process.waitForFinished(1000); } const auto out = QString::fromUtf8(process.readAllStandardOutput()); const auto err = QString::fromUtf8(process.readAllStandardError()); commands_.append(QJsonObject{{"program", program}, {"arguments", QJsonArray::fromStringList(arguments)}, {"exitCode", process.exitCode()}, {"stdout", out}, {"stderr", err}}); if (process.exitCode()) qWarning() << program << arguments << out << err; QTest::qWait(60); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } bool setEngine(const QString &name) { if (!run("gdbus", {"call", "--address", qEnvironmentVariable("IBUS_ADDRESS"), "--dest", "org.freedesktop.IBus", "--object-path", "/org/freedesktop/IBus", "--method", "org.freedesktop.IBus.SetGlobalEngine", name})) return false; return run("ibus", {"engine"}) && commands_.last().toObject().value("stdout").toString().trimmed() == name; } bool keys(const QStringList &keys) { if (!wayland()) return run("xdotool", QStringList{"key", "--clearmodifiers", "--delay", "65"} + keys); QStringList arguments; for (const auto &key : keys) arguments << "-k" << key << "-s" << "65"; return run("wtype", arguments); } bool text(const QString &value) { return wayland() ? run("wtype", {"-d", "100", value}) : run("xdotool", {"type", "--clearmodifiers", "--delay", "100", value}); } bool raw(int code, bool shift = false) { // wtype supplies its own keysym map. Wayland checks IME transport and // logical shortcuts; only the X11 cases test physical US/JP keycodes. if (wayland()) { if (code == 47 && shift) return keys({"colon"}); if (code == 61 && !shift) return keys({"slash"}); if (code == 21 && shift) return keys({"plus"}); if (code == 20 && !shift) return keys({"minus"}); return false; } QStringList args{qEnvironmentVariable("DOCVIEW_IME_RAW_KEY"), QString::number(code)}; if (shift) args << "--shift"; return run("python3", args); } private slots: void initTestCase() { QCOMPARE(QGuiApplication::platformName(), qEnvironmentVariable("DOCVIEW_IME_PLATFORM")); QCOMPARE(qEnvironmentVariable("QT_IM_MODULE"), "ibus"); QCOMPARE(qEnvironmentVariable("DOCVIEW_PRIVATE_IME"), "1"); QVERIFY(!output_.isEmpty()); qApp->installEventFilter(this); } void realImeAndLogicalKeys_data() { QTest::addColumn("layout"); if (wayland()) QTest::newRow("virtual-keysyms") << QString("virtual-keysyms"); else { QTest::newRow("us") << QString("us"); QTest::newRow("jp") << QString("jp"); } } void realImeAndLogicalKeys() { QFETCH(QString, layout); events_ = {}; commands_ = {}; phases_ = {}; bool success = false; const auto save = qScopeGuard([&] { QFile file(output_ + "/" + layout + ".json"); if (file.open(QIODevice::WriteOnly)) file.write(QJsonDocument(QJsonObject{ {"success", success}, {"layout", layout}, {"qtVersion", qVersion()}, {"qtPlatform", QGuiApplication::platformName()}, {"events", events_}, {"commands", commands_}, {"phases", phases_}}).toJson()); }); QTemporaryDir isolated; QVERIFY(isolated.isValid()); const auto config = isolated.filePath("config.toml"); QFile f(config); QVERIFY(f.open(QIODevice::WriteOnly)); f.write("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n"); f.close(); Controller reader; reader.initialize(config, false, {config, isolated.filePath("state.json"), isolated.filePath("cache"), isolated.filePath("logs")}); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("reader", &reader); engine.load(QUrl("qrc:/qml/Main.qml")); QVERIFY(!engine.rootObjects().isEmpty()); auto *window = qobject_cast(engine.rootObjects().first()); QVERIFY(window); reader.attachWindow(window); window->resize(1100, 760); qApp->removeEventFilter(this); qApp->installEventFilter(this); auto *canvas = window->findChild(); QVERIFY(canvas); QVERIFY(QTest::qWaitForWindowExposed(window, 10000)); if (wayland()) { QVERIFY(run("swaymsg", {"-s", qEnvironmentVariable("SWAYSOCK"), QString("[pid=%1] focus").arg(QCoreApplication::applicationPid())})); } else QVERIFY(run("xdotool", {"windowfocus", "--sync", QString::number(window->winId())})); QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window, 5000); reader.openPath(qEnvironmentVariable("DOCVIEW_IME_PDF")); QTRY_VERIFY_WITH_TIMEOUT(reader.state() == "Ready" && canvas->viewportReady(), 15000); canvas->setZoom(300); QVERIFY(canvas->totalHeight() > canvas->height() + 300); canvas->scroll(0, 200); QCOMPARE(canvas->offset(), 200.0); QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 10000); QVERIFY(setEngine("xkb:us::eng")); if (!wayland()) { QVERIFY(run("setxkbmap", {"-model", layout == "jp" ? "jp106" : "pc105", "-layout", layout, "-option", ""})); QVERIFY(run("setxkbmap", {"-query"})); QVERIFY(run("xkbcomp", {qEnvironmentVariable("DISPLAY"), output_ + "/" + layout + ".xkb"})); } const auto offset = canvas->offset(); // Use physical X keycodes for symbols: ':' differs between US and JP. QVERIFY(raw(layout == "jp" ? 48 : 47, layout != "jp")); QTRY_COMPARE(reader.mode(), "command"); QVERIFY(keys({"Escape"})); QTRY_COMPARE(reader.mode(), "normal"); QVERIFY(raw(61)); QTRY_COMPARE(reader.mode(), "search"); auto *field = window->activeFocusItem(); QVERIFY(field); QVERIFY(field->metaObject()->indexOfProperty("inputMethodComposing") >= 0); QVERIFY(setEngine("mozc-jp")); QVERIFY(run("ibus", {"engine"})); QVERIFY(keys({"Hiragana"})); QVERIFY(text("nihongo")); QTRY_VERIFY_WITH_TIMEOUT(field->property("inputMethodComposing").toBool(), 5000); QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); QVERIFY(keys({"space"})); QTRY_VERIFY(field->property("inputMethodComposing").toBool()); QVERIFY(window->grabWindow().save(output_ + "/" + layout + "-conversion.png")); QVERIFY(keys({"Return"})); QTRY_VERIFY(!field->property("inputMethodComposing").toBool()); QTRY_COMPARE(field->property("text").toString(), QString::fromUtf8("日本語")); QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); phases_.append(QJsonObject{{"name", "conversion-enter-keeps-search"}, {"committed", field->property("text").toString()}}); // A second Enter performs the application's search; conversion Enter did not. QVERIFY(keys({"Return"})); QTRY_COMPARE(reader.mode(), "normal"); QVERIFY(raw(61)); QTRY_COMPARE(reader.mode(), "search"); field = window->activeFocusItem(); QVERIFY(text("jikan123")); QTRY_VERIFY_WITH_TIMEOUT(field->property("inputMethodComposing").toBool(), 5000); QCOMPARE(canvas->offset(), offset); QCOMPARE(reader.mode(), "search"); QVERIFY(reader.pending().isEmpty()); QVERIFY(keys({"space"})); QTRY_VERIFY(field->property("inputMethodComposing").toBool()); QVERIFY(keys({"Escape"})); QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); QTRY_VERIFY(field->property("inputMethodComposing").toBool()); phases_.append(QJsonObject{{"name", "conversion-escape-keeps-reading"}, {"offset", offset}}); QVERIFY(keys({"Escape"})); QTRY_VERIFY(!field->property("inputMethodComposing").toBool()); QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); QVERIFY(keys({"Escape"})); QTRY_COMPARE(reader.mode(), "normal"); phases_.append(QJsonObject{{"name", "letters-digits-escape-protected"}, {"offsetUnchanged", true}, {"offset", offset}}); // The command field uses a different accepted action from search. QVERIFY(raw(layout == "jp" ? 48 : 47, layout != "jp")); QTRY_COMPARE(reader.mode(), "command"); field = window->activeFocusItem(); QVERIFY(text("kakunin")); QTRY_VERIFY(field->property("inputMethodComposing").toBool()); QVERIFY(keys({"space"})); QVERIFY(keys({"Return"})); QTRY_COMPARE(field->property("text").toString(), QString::fromUtf8("確認")); QVERIFY(!field->property("inputMethodComposing").toBool()); QCOMPARE(reader.mode(), "command"); QCOMPARE(canvas->offset(), offset); QVERIFY(window->grabWindow().save(output_ + "/" + layout + "-command.png")); QVERIFY(keys({"Escape"})); QTRY_COMPARE(reader.mode(), "normal"); phases_.append(QJsonObject{{"name", "conversion-enter-keeps-command"}, {"committed", QString::fromUtf8("確認")}}); // Restore the engine and the actual map before checking logical zoom keys. QVERIFY(setEngine("xkb:us::eng")); if (!wayland()) QVERIFY(run("setxkbmap", {"-model", layout == "jp" ? "jp106" : "pc105", "-layout", layout, "-option", ""})); const auto zoom = canvas->zoom(); QVERIFY(raw(layout == "jp" ? 47 : 21, true)); QTRY_VERIFY(canvas->zoom() > zoom); const auto enlarged = canvas->zoom(); QVERIFY(raw(20)); QTRY_VERIFY(canvas->zoom() < enlarged); const auto beforeScroll = canvas->offset(); QVERIFY(keys({"j"})); QTRY_VERIFY(canvas->offset() > beforeScroll); const auto afterDown = canvas->offset(); QVERIFY(keys({"k"})); QTRY_VERIFY(canvas->offset() < afterDown); phases_.append(QJsonObject{{"name", wayland() ? "logical-symbols-and-navigation" : "physical-symbols-and-navigation"}, {"colon", true}, {"slash", true}, {"plus", true}, {"minus", true}, {"beforeScroll", beforeScroll}, {"afterDown", afterDown}, {"afterUp", canvas->offset()}}); bool japaneseCommit = false, preedit = false; for (const auto &value : events_) { const auto event = value.toObject(); preedit |= !event.value("preedit").toString().isEmpty(); japaneseCommit |= event.value("commit").toString() == QString::fromUtf8("日本語"); } QVERIFY(preedit); QVERIFY(japaneseCommit); QVERIFY(window->grabWindow().save(output_ + "/" + layout + "-final.png")); reader.closeDocument(); QCOMPARE(reader.state(), "Empty"); success = true; } }; int main(int argc, char **argv) { registerDocumentScheme(); QtWebEngineQuick::initialize(); QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); app.setApplicationName("DocView IME validation"); app.setOrganizationName("DocView"); qmlRegisterType("DocView", 1, 0, "PdfCanvas"); ImeTest test; return QTest::qExec(&test, argc, argv); } #include "test_ime.moc"