initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
cmake_minimum_required(VERSION 3.24)
project(DocViewImeValidation LANGUAGES CXX)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(FATAL_ERROR "This supplemental harness currently validates Linux only")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
get_filename_component(DOCVIEW_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
set(DOCVIEW_CANONICAL_BUILD "${DOCVIEW_ROOT}/build" CACHE PATH "Existing tested Release build")
find_package(Qt6 6.11.2 EXACT REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2 WebEngineQuick Test)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
pkg_check_modules(SECCOMP REQUIRED IMPORTED_TARGET libseccomp)
pkg_check_modules(TOML REQUIRED IMPORTED_TARGET tomlplusplus)
foreach(name docview_core docview_common docview_font_broker)
if(NOT EXISTS "${DOCVIEW_CANONICAL_BUILD}/lib${name}.a")
message(FATAL_ERROR "Build the canonical DocView targets first: ${name}")
endif()
add_library(${name} STATIC IMPORTED)
set_target_properties(${name} PROPERTIES IMPORTED_LOCATION "${DOCVIEW_CANONICAL_BUILD}/lib${name}.a")
endforeach()
qt_add_executable(test_ime test_ime.cpp
"${DOCVIEW_ROOT}/src/app/controller.cpp"
"${DOCVIEW_ROOT}/src/app/pdf_canvas.cpp"
"${DOCVIEW_ROOT}/src/web/web_profile.cpp"
"${DOCVIEW_ROOT}/resources.qrc")
target_include_directories(test_ime PRIVATE "${DOCVIEW_ROOT}/src")
target_link_libraries(test_ime PRIVATE docview_core docview_font_broker docview_common
PkgConfig::FONTCONFIG PkgConfig::SECCOMP PkgConfig::TOML Qt6::Core Qt6::Gui Qt6::Network Qt6::Quick
Qt6::Qml Qt6::QuickControls2 Qt6::WebEngineQuick Qt6::Test)
# Use exactly the recorded canonical workers; do not rebuild parser binaries.
foreach(worker docview-pdf-worker docview-archive-worker)
add_custom_command(TARGET test_ime POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E create_symlink
"${DOCVIEW_CANONICAL_BUILD}/${worker}" "$<TARGET_FILE_DIR:test_ime>/${worker}")
endforeach()
+32
View File
@@ -0,0 +1,32 @@
# 実IME・XKBの補助GUI試験
Ubuntu 24.04のIBus/Mozcを通して、本番QML・Controllerの検索欄とコマンド欄を検査する。`QInputMethodEvent`を手作業で送らず、X11では専用XvfbへXTestのキー入力を送る。US pc105とJP jp106の配列を明示し、`:`・`/`・`+`・`-`はキー番号から入力する。WaylandではXWaylandを無効にした専用Swayへwtypeの仮想キーを送り、Qt Wayland+IBusのD-Bus接続で検査する。
必要なUbuntuパッケージは`ibus ibus-mozc xdotool x11-xkb-utils libxtst6 xvfb dbus-x11 sway wtype`。既存のDocView Releaseビルド、Qt 6.11.2 SDK、通常の開発依存を用意する。専用VMでは追加パッケージを導入したが、ホストのIME・ユーザー設定は変更していない。
```sh
export PATH=/opt/docview-deps/bin:/opt/docview-qt/6.11.2/gcc_64/bin:$PATH
export LD_LIBRARY_PATH=/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib
export PKG_CONFIG_PATH=/opt/docview-deps/lib/pkgconfig
cmake -S docview-source/tests/ime -B docview-ime-build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DDOCVIEW_CANONICAL_BUILD=/home/docview/docview-build \
'-DCMAKE_PREFIX_PATH=/opt/docview-qt/6.11.2/gcc_64;/opt/docview-deps'
cmake --build docview-ime-build --parallel 4
python3 docview-source/tests/ime/run.py \
--binary docview-ime-build/test_ime --canonical-build docview-build \
--platform xcb --output validation/ime/new-x11-run
python3 docview-source/tests/ime/run.py \
--binary docview-ime-build/test_ime --canonical-build docview-build \
--platform wayland --output validation/ime/new-wayland-run
```
出力先は毎回新規にする。runnerはprivate D-Bus、選択したXvfb/Sway、IBusとMozc、専用のXDGディレクトリを作り、起動した子だけを識別して回収する。内部sandboxは有効。通常のCTest 22群には追加せず、既存core/common/font brokerと実ワーカーを使う別実行ファイルとして検証する。
「日本語」の変換確定Enterが検索を実行せず、次のEnterで検索すること、「確認」の変換確定がコマンドを実行しないことを確認する。`jikan123`の編集中・変換候補取消・未確定入力取消でも位置200を維持し、通常モードへ戻った後のj/kで200→248→200へ移動する。最初から上下移動できる表示寸法を要求する。
IBus 1.5.29のCLIは、エンジン切替後にlayoutが`default`だと終了コード1を返す。そのため`SetGlobalEngine`をD-Busで呼び、`ibus engine`で選択結果を照合する。[使用版の実装](https://github.com/ibus/ibus/blob/1.5.29/tools/main.vala#L222-L297)。IME開始はトグルではなく`Hiragana`を使用し、前ケースの入力モードに依存させない。
Waylandの仮想キーはwtypeが独自のキー配列を持つため、US/JP配列の試験として扱わない。Swayには試験中だけ仮想キーボードを常設し、短い入力ツールの終了で唯一のキーボードが抜けてQtの焦点を失うことを防ぐ。起動後のデバイス一覧を記録し、全子プロセスを回収する。
[実行結果](../results/ime/README.md)。物理キーボード、候補ウィンドウの外観、コンポジターのtext-inputプロトコル経由のIME連携、WindowsのIMEはこの試験の対象外。本番コンポーネントを使うが、製品のmain関数はこの補助試験から実行しない。
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Send an XTest keycode to the runner's private X server, without remapping it."""
import argparse
import ctypes as c
import os
from pathlib import Path
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('keycode', type=int)
p.add_argument('--shift', action='store_true')
a = p.parse_args()
runtime = Path(os.environ.get('XDG_RUNTIME_DIR', '/'))
if os.environ.get('DOCVIEW_PRIVATE_IME') != '1' or not runtime.name.startswith('docview-ime-'):
p.error('Only the dedicated private test session is permitted')
if not 8 <= a.keycode <= 255:
p.error('X11 keycode is out of range')
x = c.CDLL('libX11.so.6'); t = c.CDLL('libXtst.so.6')
x.XOpenDisplay.argtypes = [c.c_char_p]; x.XOpenDisplay.restype = c.c_void_p
x.XSync.argtypes = [c.c_void_p, c.c_int]; x.XCloseDisplay.argtypes = [c.c_void_p]
t.XTestFakeKeyEvent.argtypes = [c.c_void_p, c.c_uint, c.c_int, c.c_ulong]
d = x.XOpenDisplay(None)
if not d:
raise SystemExit('Private display is unavailable')
try:
if a.shift:
assert t.XTestFakeKeyEvent(d, 50, 1, 0)
assert t.XTestFakeKeyEvent(d, a.keycode, 1, 0)
assert t.XTestFakeKeyEvent(d, a.keycode, 0, 0)
if a.shift:
assert t.XTestFakeKeyEvent(d, 50, 0, 0)
x.XSync(d, 0)
finally:
x.XCloseDisplay(d)
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Exercise production Qt input controls through IBus/Mozc on private X11 or Wayland."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import signal
import subprocess
import sys
import tempfile
import time
import uuid
ROOT = Path(__file__).resolve().parents[2]
def sha(path):
with Path(path).open('rb') as f:
return hashlib.file_digest(f, 'sha256').hexdigest()
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--binary', type=Path, required=True)
p.add_argument('--canonical-build', type=Path, required=True)
p.add_argument('--output', type=Path, required=True)
p.add_argument('--platform', choices=['xcb', 'wayland'], default='xcb')
p.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS)
a = p.parse_args()
if a.output.exists():
p.error('Choose a new output directory')
if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or 'sandbox' in os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', ''):
p.error('Sandbox overrides are not permitted')
if not a.private_session:
with tempfile.TemporaryDirectory(prefix='docview-ime-') as private:
env = os.environ.copy()
for key in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'SWAYSOCK', 'I3SOCK', 'DBUS_SESSION_BUS_ADDRESS', 'IBUS_ADDRESS',
'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO', 'QT_QPA_PLATFORMTHEME']:
env.pop(key, None)
env.update(XDG_RUNTIME_DIR=private, XDG_CONFIG_HOME=private + '/config', XDG_STATE_HOME=private + '/state',
XDG_CACHE_HOME=private + '/cache', XDG_DATA_HOME=private + '/data',
QT_QPA_PLATFORM=a.platform, DOCVIEW_IME_PLATFORM=a.platform,
QT_QUICK_BACKEND='software', QT_SCALE_FACTOR='1', QT_FONT_DPI='96',
QT_IM_MODULE='ibus', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', DOCVIEW_PRIVATE_IME='1',
DOCVIEW_IME_RUN_ID=str(uuid.uuid4()))
bus = Path(private) / 'bus.conf'
bus.write_text('<busconfig><type>session</type><auth>EXTERNAL</auth>'
f'<listen>unix:tmpdir={private}</listen><policy context="default">'
'<allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
display = ['xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp'] if a.platform == 'xcb' else []
return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', *display, sys.executable, str(Path(__file__).resolve()),
*sys.argv[1:], '--private-session'], env=env).returncode
out = a.output.resolve(); out.mkdir(parents=True, exist_ok=False)
binary, build = a.binary.resolve(), a.canonical_build.resolve()
paths = [binary] + [build / n for n in ['docview', 'docview-pdf-worker', 'docview-archive-worker',
'libdocview_core.a', 'libdocview_common.a', 'libdocview_font_broker.a']]
paths += [ROOT / 'tests/fixtures/pdf/navigation.pdf', ROOT / 'resources.qrc']
paths += [p for folder in ['src', 'qml', 'resources', 'tests/ime'] for p in (ROOT / folder).rglob('*')
if p.is_file() and '__pycache__' not in p.parts]
before = {os.path.relpath(path, ROOT): sha(path) for path in sorted(set(paths))}
report = {'success': False, 'os': platform.freedesktop_os_release(), 'kernel': platform.release(),
'inputs': before, 'requestedPlatform': a.platform,
'scope': ('Actual IBus/Mozc through virtual XTest keys and US/JP XKB on private X11' if a.platform == 'xcb'
else 'Actual Qt Wayland and IBus/Mozc over D-Bus; private Sway and wtype virtual-keyboard keysyms, not physical US/JP mappings or compositor text-input protocol')
+ '; no physical keyboard, candidate-panel appearance or Windows acceptance'}
address = 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/ibus.sock'
env = os.environ.copy(); env.update(DOCVIEW_IME_CHILD_ID=str(uuid.uuid4()), IBUS_ADDRESS=address, DOCVIEW_IME_OUTPUT=str(out),
DOCVIEW_IME_PDF=str(ROOT / 'tests/fixtures/pdf/navigation.pdf'), DOCVIEW_IME_RAW_KEY=str(ROOT / 'tests/ime/raw_key.py'))
daemon = compositor = keyboard_keeper = None
tag = ('DOCVIEW_IME_CHILD_ID=' + env['DOCVIEW_IME_CHILD_ID']).encode()
def owned():
found = []
for directory in Path('/proc').glob('[0-9]*'):
try:
pid = int(directory.name)
if pid != os.getpid() and tag in (directory / 'environ').read_bytes().split(b'\0'):
found.append(pid)
except (OSError, ValueError):
pass
return found
try:
for name in ['docview-pdf-worker', 'docview-archive-worker']:
assert sha(binary.parent / name) == sha(build / name)
report['packages'] = subprocess.check_output(['dpkg-query', '-W', 'ibus', 'ibus-mozc', 'mozc-server', 'xdotool', 'libxtst6', 'xkb-data', 'sway', 'wtype'], text=True)
if a.platform == 'wayland':
runtime = Path(env['XDG_RUNTIME_DIR'])
config = runtime / 'sway.conf'
config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\n'
'output HEADLESS-1 mode 1920x1080\nfor_window [app_id=".*"] floating enable\n'
'default_border none\ndefault_floating_border none\nfocus_follows_mouse no\nmouse_warping none\n')
(out / 'sway.conf').write_bytes(config.read_bytes())
env.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', WLR_HEADLESS_OUTPUTS='1',
XDG_CURRENT_DESKTOP='sway', XDG_SESSION_TYPE='wayland', QT_USE_PORTAL='0')
with (out / 'compositor.log').open('w') as log:
compositor = subprocess.Popen(['sway', '-c', str(config)], env=env, stdout=log, stderr=subprocess.STDOUT)
for _ in range(100):
sockets = [p for p in runtime.glob('wayland-*') if p.is_socket()]
ipc = list(runtime.glob('sway-ipc.*.sock'))
if sockets and ipc or compositor.poll() is not None:
break
time.sleep(.1)
if not sockets or not ipc or compositor.poll() is not None:
raise RuntimeError('Private Sway did not become ready')
env.update(WAYLAND_DISPLAY=sockets[0].name, SWAYSOCK=str(ipc[0]))
report['waylandOutputs'] = json.loads(subprocess.check_output(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_outputs'], env=env))
# Keep a virtual keyboard attached throughout the test. Otherwise
# each short wtype process disconnects the seat's only keyboard,
# clears Qt's active focus and cancels an in-progress composition.
# The deferred key is never reached: test timeout is 180 seconds,
# and cleanup terminates this uniquely tagged child first.
with (out / 'keyboard-keeper.log').open('w') as log:
keyboard_keeper = subprocess.Popen(['wtype', '-s', '300000', '-k', 'Shift_L'], env=env,
stdout=log, stderr=subprocess.STDOUT)
for _ in range(100):
inputs = json.loads(subprocess.check_output(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_inputs'], env=env))
if any(row.get('type') == 'keyboard' for row in inputs) or keyboard_keeper.poll() is not None:
break
time.sleep(.05)
if not any(row.get('type') == 'keyboard' for row in inputs) or keyboard_keeper.poll() is not None:
raise RuntimeError('Persistent virtual keyboard did not attach')
report['waylandInputsBeforeApplication'] = inputs
with (out / 'ibus.log').open('w') as log:
daemon = subprocess.Popen(['ibus-daemon', '--address', address, '--panel', 'disable', '--config', 'disable',
'--emoji-extension', 'disable', '--cache', 'none'], env=env, stdout=log, stderr=subprocess.STDOUT)
ready = False
for _ in range(50):
query = subprocess.run(['ibus', 'list-engine'], env=env, capture_output=True, text=True, timeout=5)
if query.returncode == 0 and 'mozc-jp' in query.stdout:
(out / 'engines.txt').write_text(query.stdout); ready = True; break
if daemon.poll() is not None:
break
time.sleep(.1)
if not ready:
raise RuntimeError('IBus/Mozc did not become ready')
# Launch the installed engine explicitly: this session deliberately
# has no ambient D-Bus service activation.
with (out / 'mozc.log').open('w') as mozc_log:
mozc = subprocess.Popen(['/usr/lib/ibus-mozc/ibus-engine-mozc', '--ibus'], env=env, stdout=mozc_log, stderr=subprocess.STDOUT)
time.sleep(.5)
if mozc.poll() is not None:
raise RuntimeError(f'Mozc engine exited: {mozc.returncode}')
probe = subprocess.run(['gdbus', 'call', '--address', address, '--dest', 'org.freedesktop.IBus', '--object-path', '/org/freedesktop/IBus', '--method', 'org.freedesktop.IBus.SetGlobalEngine', 'mozc-jp'], env=env, capture_output=True, text=True, timeout=20)
(out / 'engine-probe.txt').write_text(probe.stdout + probe.stderr)
with (out / 'test.log').open('w') as testlog:
test = subprocess.run([str(binary)], env=env, stdout=testlog, stderr=subprocess.STDOUT, timeout=180)
report['testExitCode'] = test.returncode
layouts = ['us', 'jp'] if a.platform == 'xcb' else ['virtual-keysyms']
report['cases'] = {layout: json.loads((out / f'{layout}.json').read_text()) for layout in layouts}
report['success'] = test.returncode == 0 and all(case['success'] and case['qtPlatform'] == a.platform for case in report['cases'].values())
except Exception as error:
report['error'] = str(error)
finally:
if daemon:
try:
subprocess.run(['ibus', 'exit'], env=env, capture_output=True, timeout=5)
except subprocess.TimeoutExpired:
pass
for sig in [signal.SIGTERM, signal.SIGKILL]:
for pid in owned():
try:
os.kill(pid, sig)
except ProcessLookupError:
pass
time.sleep(.2)
if daemon:
daemon.wait(timeout=5)
if compositor:
compositor.wait(timeout=5)
if keyboard_keeper:
keyboard_keeper.wait(timeout=5)
report['remainingOwnedProcesses'] = owned()
report['inputsUnchanged'] = all(sha(ROOT / name) == value for name, value in before.items())
report['success'] &= report['inputsUnchanged'] and not report['remainingOwnedProcesses']
(out / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({k: report.get(k) for k in ['success', 'testExitCode', 'error', 'inputsUnchanged', 'remainingOwnedProcesses']}))
return 0 if report['success'] else 1
if __name__ == '__main__':
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
#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"