Files
2026-09-21 13:41:40 +09:00

218 lines
13 KiB
C++

#include "app/controller.h"
#include "app/pdf_canvas.h"
#include "web/web_profile.h"
#include <QInputMethodEvent>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QScopeGuard>
#include <QTemporaryDir>
#include <QtTest>
#include <QtWebEngineQuick>
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<QKeyEvent *>(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<QInputMethodEvent *>(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<QString>("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<QQuickWindow *>(engine.rootObjects().first()); QVERIFY(window);
reader.attachWindow(window); window->resize(1100, 760);
qApp->removeEventFilter(this); qApp->installEventFilter(this);
auto *canvas = window->findChild<PdfCanvas *>(); 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<PdfCanvas>("DocView", 1, 0, "PdfCanvas");
ImeTest test; return QTest::qExec(&test, argc, argv);
}
#include "test_ime.moc"