initial commit
This commit is contained in:
@@ -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()
|
||||
@@ -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関数はこの補助試験から実行しない。
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user