Add isolated native Rime service and MiSans candidate UI
This commit is contained in:
1 parent
f21167e1a2
commit
8e9a5838ab
28 files changed
+2381
-17
No files matched your search
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
imports.gi.versions.Gtk = '4.0';
|
||||
const {Gtk, Gio, GLib, Gdk} = imports.gi;
|
||||
const output = ARGV[0];
|
||||
const application = new Gtk.Application({application_id: 'dev.ferese.RimeValidation'});
|
||||
application.connect('activate', () => {
|
||||
const css = new Gtk.CssProvider(); css.load_from_string('window { font-family: "MiSans"; }');
|
||||
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
const window = new Gtk.ApplicationWindow({application, title: 'Ferese 原生 Rime 验证', default_width: 880, default_height: 560});
|
||||
const box = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 18,
|
||||
margin_start: 28, margin_end: 28, margin_top: 28, margin_bottom: 28});
|
||||
box.append(new Gtk.Label({label: '独立 Rime · 原生 Wayland 候选窗', xalign: 0}));
|
||||
box.append(new Gtk.Label({label: '输入 nihao;空格或数字选字,− / + 翻页,Shift 切换中英文。', wrap: true, xalign: 0}));
|
||||
const normal = new Gtk.Entry({placeholder_text: '普通输入框', hexpand: true});
|
||||
const password = new Gtk.Entry({placeholder_text: '敏感输入测试框(只使用测试字符)', visibility: false,
|
||||
input_purpose: Gtk.InputPurpose.PASSWORD, hexpand: true});
|
||||
const status = new Gtk.Label({label: '等待输入', xalign: 0});
|
||||
const save = () => {
|
||||
const focused = window.get_focus();
|
||||
const state = {normal: normal.get_text(), password_length: password.get_text().length,
|
||||
focus: focused && (focused === password || focused.is_ancestor(password)) ? 'password' : 'normal'};
|
||||
// This is a disposable validation fixture, containing only test input.
|
||||
GLib.file_set_contents(output, JSON.stringify(state));
|
||||
status.set_label('普通输入:' + normal.get_text().length + ' 字符;敏感输入:' + password.get_text().length + ' 字符');
|
||||
};
|
||||
for (const widget of [normal, password]) widget.connect('changed', save);
|
||||
box.append(normal); box.append(password); box.append(status);
|
||||
box.append(new Gtk.Box({vexpand: true}));
|
||||
for (const [name, action] of [['normal', () => normal.grab_focus()], ['password', () => password.grab_focus()],
|
||||
['clear', () => {normal.set_text(''); password.set_text(''); normal.grab_focus(); save();}],
|
||||
['top', () => {box.remove(normal); box.insert_child_after(normal, box.get_first_child().get_next_sibling()); normal.grab_focus();}],
|
||||
['bottom', () => {box.remove(normal); box.append(normal); normal.grab_focus();}]]) {
|
||||
const entry = new Gio.SimpleAction({name}); entry.connect('activate', action); application.add_action(entry);
|
||||
}
|
||||
window.set_child(box); window.present();
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {normal.grab_focus(); save(); return GLib.SOURCE_REMOVE;});
|
||||
});
|
||||
application.run([]);
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Opt-in real Wayland/GTK/Rime integration checks against the build-tree preview.
|
||||
|
||||
FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py
|
||||
Only disposable fixture entries are modified. The probe refuses host displays.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("preview", ROOT / "scripts/rime-preview.py")
|
||||
preview = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(preview)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_RIME") == "1", "opt-in native Rime validation")
|
||||
class NativeRimeTest(unittest.TestCase):
|
||||
def test_native_pipeline_and_process_boundary(self):
|
||||
preview.start()
|
||||
preview.start_ime()
|
||||
results = []
|
||||
|
||||
def state():
|
||||
return json.loads((preview.DATA / "entry-state.json").read_text())
|
||||
|
||||
def expect(text):
|
||||
preview.wait_for(lambda: state()["normal"] == text,
|
||||
f"Expected {text!r}; latest state {state()}", timeout=4)
|
||||
|
||||
def probe(*args):
|
||||
preview.probe(list(args))
|
||||
time.sleep(.12)
|
||||
|
||||
def clear():
|
||||
preview.focus_entry()
|
||||
probe("--key", "Escape")
|
||||
preview.action("clear")
|
||||
expect("")
|
||||
|
||||
def screenshot(name):
|
||||
c = preview.context()
|
||||
p = subprocess.run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "screenshot"], capture_output=True, check=True)
|
||||
(preview.DATA / name).write_bytes(p.stdout)
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao")
|
||||
expect("") # Preedit is displayed, not prematurely committed.
|
||||
screenshot("candidate-normal.png")
|
||||
self.assertLessEqual({window["app_id"] for window in preview.windows()},
|
||||
{"dev.ferese.RimeValidation", "io.github.lgse.Strata"})
|
||||
probe("--keys", " ")
|
||||
expect("你好")
|
||||
results.append("native GTK preedit, candidate popup and space commit")
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao1")
|
||||
expect("你好")
|
||||
results.append("numeric candidate selection")
|
||||
|
||||
clear()
|
||||
probe("--keys", "ni")
|
||||
pid = preview.prop(preview.IME, "MainPID")
|
||||
def composition():
|
||||
lines = preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).splitlines()
|
||||
return [json.loads(line) for line in lines if line.startswith('{') and '"event":"composition"' in line][-1]
|
||||
self.assertEqual(composition()["page"], 0)
|
||||
probe("--keys", "=")
|
||||
self.assertEqual(composition()["page"], 1)
|
||||
screenshot("candidate-horizontal-page2.png")
|
||||
probe("--keys", "-")
|
||||
self.assertEqual(composition()["page"], 0)
|
||||
probe("--chord", "Shift_L+equal")
|
||||
self.assertEqual(composition()["page"], 1)
|
||||
self.assertFalse(composition()["ascii"])
|
||||
probe("--keys", "-1")
|
||||
expect("你")
|
||||
results.append("horizontal candidates page with number-row minus, equal and Shift+equal")
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao", "--key", "BackSpace", "--keys", "o ")
|
||||
expect("你好")
|
||||
results.append("composition backspace and repair")
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao", "--key", "Escape")
|
||||
expect("")
|
||||
probe("--key", "Shift_L", "--keys", "abc123")
|
||||
expect("abc123")
|
||||
time.sleep(.8)
|
||||
expect("abc123") # Rejected keys must receive paired releases.
|
||||
probe("--keys", "-=")
|
||||
expect("abc123-=")
|
||||
probe("--chord", "Control_L+a", "--key", "BackSpace")
|
||||
expect("")
|
||||
probe("--key", "Shift_L", "--keys", "nihao ")
|
||||
expect("你好")
|
||||
results.append("escape, Shift mode switching, ASCII and Ctrl+A release pairing")
|
||||
|
||||
clear()
|
||||
preview.action("password")
|
||||
time.sleep(.2)
|
||||
pid = preview.prop(preview.IME, "MainPID")
|
||||
def compositions():
|
||||
return preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).count('"event":"composition"')
|
||||
before = compositions()
|
||||
probe("--keys", "nihao")
|
||||
preview.wait_for(lambda: state()["password_length"] == 5, "Sensitive field did not receive literal keys")
|
||||
self.assertEqual(compositions(), before)
|
||||
screenshot("sensitive-no-candidates.png")
|
||||
results.append("sensitive field bypasses Rime and has no candidates")
|
||||
|
||||
clear()
|
||||
c = preview.context()
|
||||
control = [str(ROOT / "target/debug/feresectl"), "--socket", c["socket"]]
|
||||
subprocess.run(control + ["toggle-maximized"], check=True)
|
||||
time.sleep(.4)
|
||||
preview.action("bottom")
|
||||
time.sleep(.2)
|
||||
probe("--keys", "nihao")
|
||||
screenshot("candidate-bottom-flipped.png")
|
||||
probe("--keys", " ")
|
||||
expect("你好")
|
||||
preview.action("top")
|
||||
subprocess.run(control + ["toggle-maximized"], check=True)
|
||||
time.sleep(.3)
|
||||
results.append("bottom-edge native candidate composition and commit; screenshot for placement inspection")
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao")
|
||||
theme = json.loads(preview.run(control + ["--json", "theme", "get"]))
|
||||
original_mode = theme["mode"]
|
||||
subprocess.run(control + ["theme", "mode", "light"], check=True, capture_output=True)
|
||||
preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == "light", "Light theme did not finish resolving")
|
||||
time.sleep(.35)
|
||||
screenshot("candidate-theme-light.png")
|
||||
subprocess.run(control + ["theme", "mode", original_mode], check=True, capture_output=True)
|
||||
preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == original_mode, "Original theme did not finish resolving")
|
||||
time.sleep(.35)
|
||||
results.append("candidate UI follows Ferese light/dark theme through its existing IPC")
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao")
|
||||
compositor_pid = preview.prop(preview.MAIN, "MainPID")
|
||||
subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", preview.IME], check=True)
|
||||
preview.wait_for(lambda: preview.prop(preview.IME, "ActiveState") != "active", "IME did not terminate")
|
||||
self.assertEqual(preview.prop(preview.MAIN, "ActiveState"), "active")
|
||||
self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid)
|
||||
probe("--keys", "abc")
|
||||
expect("abc")
|
||||
screenshot("ime-crash-desktop-survives.png")
|
||||
preview.start_ime()
|
||||
time.sleep(.2)
|
||||
probe("--keys", "nihao ")
|
||||
expect("abc你好")
|
||||
results.append("IME SIGKILL leaves desktop alive; ASCII works; restarted IME commits Chinese")
|
||||
|
||||
# The validation driver must refuse the real host before creating a keyboard.
|
||||
host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
|
||||
if not host.is_absolute():
|
||||
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
|
||||
p = subprocess.run([str(ROOT / "target/debug/ferese-rime-probe"), "--keys", "x"],
|
||||
env={**os.environ, "WAYLAND_DISPLAY": str(host), "FERESE_TEST_INPUT": "1"},
|
||||
text=True, capture_output=True)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("not an isolated Ferese", p.stderr)
|
||||
results.append("test driver refuses host desktop")
|
||||
|
||||
pid = preview.prop(preview.IME, "MainPID")
|
||||
maps = (Path("/proc") / pid / "maps").read_text()
|
||||
self.assertIn("librime.so", maps)
|
||||
self.assertNotIn("libibus", maps)
|
||||
self.assertNotIn("libfcitx", maps)
|
||||
results.append("independent process links librime, with no IBus or Fcitx library")
|
||||
|
||||
clear()
|
||||
probe("--keys", "nihao")
|
||||
(preview.DATA / "validation.json").write_text(json.dumps({"status": "passed", "checks": results}, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user