feat(post-processing): pipe transcriptions through an optional postprocessing script (#479)
* feat(post-processing): pipe transcriptions through user-defined script Add a configurable post-processing step that passes each transcription result to an external executable via stdin and uses its stdout as the text to type. Enables custom transforms (formatting, LLM rewriting, etc.) without modifying Vocalinux. Falls back to original text on script failure or timeout. * test(post-processor): add unit tests and extract apply_post_processing Move config-reading logic from main.py closure into apply_post_processing() in post_processor.py so it can be tested independently. Add 11 unit tests covering PostProcessor.process() and apply_post_processing(). * docs(post-processing): document post-processing script feature * fix formatting of one file * fix(post-processing): run the user script with a stripped host environment Rebase compat: the host_process AST guard requires every subprocess spawned for a host binary to pass env=host_env() so an AppImage's LD_LIBRARY_PATH/GI_TYPELIB_PATH/PYTHONHOME do not leak in. Also give apply_post_processing() a typed ConfigManager parameter and keep text_callback_wrapper's local variable typed as str for mypy. * fix(post-processing): preserve paragraph breaks and tighten script handling Take script stdout verbatim instead of rstripping every trailing newline, so the "new paragraph" voice command's break survives a passthrough script. A single trailing newline is still dropped, but only when the transcription had none — it is almost always just the line terminator echo/print-style tools append. Also stop writing transformed output to the log (it can carry sensitive dictation into retained logs), catch only the expected process/OS/encoding failures instead of every Exception, and add the missing signature annotations. * fix(main): run the post-processing script off the recognition thread A user script may take seconds per segment, and text callbacks run on the recognition thread that drains the bounded audio-segment queue; stalling it drops queued dictation once the queue fills. Submit post-processing and injection to a dedicated single-worker executor so the backlog waits instead of losing speech, while keeping injection order. On timeout or failure the worker falls back to the original text, and an unexpected error is logged with a traceback and still injects the original rather than silently dropping the segment. Also pin the callback tests' get_str mock to no-script so they exercise the synchronous path deterministically, and add a callback test proving transformed text (including preserved paragraph breaks) reaches the injector. * docs(post-processing): annotate new callbacks and clarify stdout contract Add the missing type annotations on the new Settings page callbacks per the repository guide, and document that script stdout is used verbatim (a lone trailing newline added by line-oriented tools is dropped only when the transcription had none) and that scripts run on a dedicated worker. * test(main): annotate new post-processing callback tests * fix(main): bind queued dictation to its target app and drain worker on quit Route every transcription segment through the single post-processing worker (a pass-through when no script is configured) so clearing the script path mid-queue can no longer let a later segment overtake an earlier one still running. A segment that can sit in the backlog — a script is configured or a job is still running — is bound to the application focused when it was dictated; the worker drops it if focus has verifiably moved to another application, so delayed dictation cannot land in the wrong app. Wire the worker into the tray quit path via an on_quit hook: it clears the acceptance flag, cancels pending jobs, and waits — bounded by the script's own timeout — so nothing injects after the injector stops. * fix(main): keep voice actions ordered and probe focus off the recognition thread - Queue voice-command actions on the post-processing worker so an action can no longer overtake dictated text still waiting to be injected. - Move the submit-time focus probe onto a dedicated executor; the recognition thread only submits the probe, and the job joins the result at inject time, so compositor tools can no longer stall consumption of the bounded audio-segment queue. - Re-check the quit flag after the inject-time focus re-probe, then shut both workers down without waiting on a running script so quitting does not freeze on the script timeout. * fix(main): capture submit-time focus on dedicated threads and serialize quit - Replace the shared focus-probe executor with a fresh daemon thread per submission so a slow earlier probe cannot delay a later one past the focus change it is meant to capture; jobs read the result off a one-slot queue at inject time. - Give queued voice actions the same submit-time focus binding as text segments so a queued shortcut is dropped instead of fired into a newly focused application. - Hold injection_lock across the accept-check plus inject/action call so quit waits only for an in-flight injection instead of racing it. - Queue the IDLE reset of last_injected_text behind pending worker jobs so a queued 'delete that' still sees the text it targets. * fix(main): keep actions instant on a free worker and bound quit wait - An action now starts a submit-time focus probe only when it actually queues behind other jobs; with a free worker there is no delay window to bind against, so the probe's compositor calls are skipped and the action stays immediate even when a script is configured. - Probe and executor submission now happen inside pending_jobs_lock so queue order matches callback order — a job that saw an empty queue can no longer slip behind one that arrived while it was submitting. - Every inject-path subprocess call (xdotool window/key/type, wtype and ydotool type) is bounded by a timeout, and the quit hook's injection_lock wait is capped, so quitting can no longer freeze on a stalled injector subprocess. * fix(main): bind every action to submit-time focus without slowing it down - Every voice action now starts a submit-time focus probe: submission never guarantees immediate execution, so skipping the binding left a focus-change window in which a shortcut could fire into another app. The worker waits on the probe for at most one second — milliseconds on a healthy desktop — so immediate actions stay immediate while a genuinely stalled probe falls back to the injector's own targeting. - Quit waits for an in-flight injection without a cap again: every inject-path subprocess is individually bounded, so the wait always ends with the injection and a long transcription is never cut off. - The wtype/ydotool typing timeout is now derived from the configured per-key delay (a generous multiple), so a legitimately slow type can never be cut mid-text; a timed-out type no longer falls through to the clipboard fallback, which would duplicate the partial fragment. * fix(main): drop unverified actions and make quit-time injection abortable - An action whose submit-time focus probe cannot answer inside its one-second bound is now dropped instead of running unverified: a stalled probe can no longer leave a shortcut pointing at whatever application happens to be focused. - Quit asks the injector to abort in-flight injections, and injection paths honour it: the xdotool chunk loop stops between chunks, the wtype/ydotool type call runs as a polling Popen that can be killed, and paste/shortcut/backspace entry points exit early. The quit hook still waits for an in-flight injection, but the wait is bounded by at most one already-running subprocess call — never the injection's full length — and no subprocess is ever stopped mid-call by a timeout cap on the wait itself. - Annotate the remaining untyped test helpers. * fix(main): wait out slow focus probes; restore run() in wayland typing - An action no longer races its submit-time focus probe on a one-second bound: the job waits for the probe's own answer, whose compositor calls already carry per-call timeouts and short-circuit absent tools. A slow but living probe delays the command instead of discarding it, so a valid "undo" can no longer be lost to timing alone, and focus verification still decides every drop. - Wayland typing goes back to subprocess.run so tests keep intercepting the same entry point. Abortability is preserved by typing in 200-character chunks with the shutdown check between calls — a whole-string call could not be interrupted, but no single chunk holds the injector for more than its own scaled budget. - The abort flag gains a class-level default so injectors built without __init__ in tests still answer the checks; each real instance gets its own event in __init__. - Update call-shape assertions for the typed-call timeout and finish annotating the test methods touched by the callback refactor. * fix(inject): never retype text a failed chunked call already sent - _PartiallyTyped carries the confirmed-delivered prefix length out of the chunked type loops, so every downstream fallback continues from the untyped remainder: the Wayland-to-xdotool switch types only the tail, and the clipboard fallback copies only the tail, never the whole string a second time. - The xdotool retry loop gains the same accounting — a retry now resumes at the first untyped chunk instead of restarting at the top and duplicating everything the failed attempt already delivered. - Annotate the remaining test signature the repository guide requires. * fix(inject): report partial typing as failure even when clipboard lands A chunked type call that dies mid-text leaves only a prefix on screen, and the remainder-fallback keeps it that way: it copies the tail for a manual paste but now returns False, so the caller no longer records the full transcription as injected and a following "delete that" cannot erase text that was never typed. * fix(inject): keep deletion state aligned with delivered text inject_text now records how many characters a call confirmed on screen as last_typed_count: the whole length on success, the typed prefix after a partial failure, -1 when the delivered amount is unknowable. The transcription caller uses it to keep last_injected_text honest — a following "delete that" erases the typed prefix or nothing, never the previous segment or text that never arrived. * fix(inject): never replay input a timed-out portal job may have delivered - RemoteDesktopPortalError.delivered=None marks an unreported job's count as unknowable; all three portal fallbacks demote and skip instead of replaying, and text injection surfaces as TimeoutExpired so no full-text clipboard copy is offered - _PartiallyTyped counts raised after a portal demote are rebased onto the original text (typed_prefix) so the caller clips the real prefix - evdev: a consumed pure-modifier press is withheld and replayed ahead of the first non-target press, so holding RightAlt for AltGr composition still reaches the application - history: extend_latest gains an expected_latest CAS guard so a late segment from an older worker merges only into its own session's snippet, never into a newer session's entry - quit path aborts in-flight injections before waiting on the lock so quitting cannot freeze behind a long transcription's typing budget - tests: stop _ensure_ydotoold leaking a real Popen in Wayland tests * fix(shortcut): replay withheld modifiers across devices and after SYN_DROPPED - Replay withheld modifier presses from every device, not only the fd the character arrived on: split keyboards deliver AltGr and the character on separate devices, and a per-fd check forwarded a plain character. - Let the post-SYN_DROPPED resync run when only withheld modifiers exist for the fd, so a press whose release the dropped burst ate is pruned instead of replaying a stale modifier on the next ordinary key. * fix(history,pads): keep late segments pad-bound deliveries and pad deletions correct - Track the worker behind the ended session's snippet so late segments from an older worker cannot merge into a newer session's entry - Skip the focus-change drop for dictation-pad segments and pad-bound editing commands, which never target the focused application - Refuse 'delete that' when a manual pad edit blurred the segment boundaries instead of deleting the stale recorded length * style: drop unneeded nonlocal for mutated worker set * fix(shortcut): share one evdev device layer across shortcut listeners Each KeyboardShortcutManager built its own EvdevKeyboardBackend, and on evdev every backend opened and grabbed the same physical keyboards: the first grabber received all events and the rest received none, so per-language shortcuts could never fire on Wayland. All backends now bind to a process-wide EvdevDeviceHub that opens each keyboard once, owns the grabs and the paired uinput clones, and fans every event out to each registered engine. An event is re-emitted to applications only when no engine consumed it, so suppression semantics stay the same while per-engine matching state (combo progress, withheld modifiers, swallowed presses) remains independent. * fix(history): own late segments per worker and decide pad routing once - ended_worker_snippets maps each ended session's worker to its own (snippet, commit epoch): late deliveries merge into their session's entry however many sessions have committed since, so one session's stragglers never split across Recent Snippets entries. - The leftover-segment commit reads the live history epoch inside the session lock: a mid-session clear() no longer makes the stale ended epoch refuse valid post-clear dictation. - The pad-bound routing decision is captured once per job and passed to the injector/action handler, so a toggle flip between the focus check and delivery cannot reroute text or shortcuts into the focused app. - Dead recognition workers are pruned from the tracking set and map so long runs don't accumulate finished threads. - The custom-terms switch now suppresses GTK's own state-set on save failure so it shows the value that actually persisted. * fix(shortcut): serialize evdev device layer teardown against restart The last listener's unregister released the engine lock before joining the monitor thread and closing devices, so a new listener registering in that gap opened keyboards the stale teardown then closed — it stayed active but dead, and briefly grabbed keyboards stopped forwarding input. A lifecycle lock now covers the whole close/open transition in register, unregister, and the monitor's unexpected-exit teardown, so a cold start can only open devices after the previous generation finished closing (also making the new generation's grabs succeed). A generation stamp tells a monitor thread that outlived its join() its incarnation is over even when running has gone True again. * fix(injection): log focus-probe failures and narrow clipboard fallback catch A crashed focus probe turned into an unknown result with no logged error, indistinguishable from unavailable focus information; the partial-typing clipboard fallback caught every exception. Log the probe failure with context and narrow the clipboard catch to the exceptions the fallback path can actually raise. * fix(shortcut): join the evdev monitor outside the lifecycle lock If the monitor exited unexpectedly while the last listener unregistered, its cleanup queued on the lifecycle lock that unregister() held for the join — stalling shutdown for the join's full 2s timeout. The close now finishes inside the lock and the join waits after it is released, so the monitor's own cleanup can run and exit instead of queueing behind it. Closing before the join also dies the monitor's read/select on the closed fds right away. A _closed gate on _open_keyboard_device keeps a rescan from opening a device between detach and close that nothing would ever close. * fix(injection): narrow focus-probe catch to the raisable exception set get_focused_window() reports Optional and never raises; the catch now covers only what can realistically escape the queue write and logs it with context, keeping the waiting job released. * fix(shortcut): tag evdev device fds with the generation that opened them A monitor that outlived join() could still touch a newer generation's devices: the loop re-checked the generation only at the top, so mid-iteration it could dispatch from, forward to, or remove devices a restarted listener had just opened. Every fd is now tagged with the generation that opened it, and the tag is verified at each point a monitor could mutate shared state — fd lookup for dispatch, clone forwarding, forwarder release, device removal, and rescan opens — while dispatch re-checks the generation between events and the per-fd loop breaks on it. Cold starts bump the generation before opening so fresh devices are tagged for the new generation. Devices an older generation opened stay tagged stale, so its reader leaves them alone. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
dca937fc21
commit
d11bd1bf0d
@@ -769,6 +769,10 @@ class TestConfigManager(unittest.TestCase):
|
||||
new_config_manager = ConfigManager()
|
||||
self.assertFalse(new_config_manager.is_sound_effects_enabled())
|
||||
|
||||
def test_post_processing_script_path_empty_by_default(self) -> None:
|
||||
config_manager = ConfigManager()
|
||||
self.assertEqual(config_manager.get_str("post_processing", "script_path", ""), "")
|
||||
|
||||
|
||||
class TestTypedAccessors(unittest.TestCase):
|
||||
"""Tests for typed config accessors."""
|
||||
|
||||
@@ -23,6 +23,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
|
||||
DEVICE_RESCAN_SECONDS,
|
||||
EVDEV_AVAILABLE,
|
||||
MODIFIER_KEY_CODES,
|
||||
EvdevDeviceHub,
|
||||
EvdevKeyboardBackend,
|
||||
device_has_key,
|
||||
device_has_modifier_key,
|
||||
@@ -865,7 +866,7 @@ class TestEvdevKeyboardBackendHotplug:
|
||||
backend.devices = [device]
|
||||
backend.device_fds = [10]
|
||||
backend._remove_keyboard_device = MagicMock(
|
||||
side_effect=lambda fd, device: setattr(backend, "running", False)
|
||||
side_effect=lambda fd, device, generation: setattr(backend, "running", False)
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -880,7 +881,9 @@ class TestEvdevKeyboardBackendHotplug:
|
||||
):
|
||||
backend._monitor_devices()
|
||||
|
||||
backend._remove_keyboard_device.assert_called_once_with(10, device)
|
||||
backend._remove_keyboard_device.assert_called_once_with(
|
||||
10, device, backend._hub._generation
|
||||
)
|
||||
|
||||
def test_monitor_handles_fd_lookup_error(self):
|
||||
"""Test fd lookup errors do not try to remove an unknown device."""
|
||||
@@ -1641,3 +1644,409 @@ class TestEvdevGrabAndForwarding:
|
||||
|
||||
backend._handle_key_event.assert_not_called()
|
||||
forwarder.write_event.assert_called_once_with(other_event)
|
||||
|
||||
def test_pure_modifier_press_stays_consumed(self) -> None:
|
||||
"""A real PTT hold never reaches the application."""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
fd = 10
|
||||
backend._forwarders[fd] = MagicMock()
|
||||
backend._forwarded_held[fd] = set()
|
||||
backend._withheld_modifier[fd] = {}
|
||||
|
||||
assert backend._event_is_shortcut(fd, self._key_event(100, 1)) is True
|
||||
assert backend._event_is_shortcut(fd, self._key_event(100, 0)) is True
|
||||
backend._forwarders[fd].write_event.assert_not_called()
|
||||
|
||||
def test_altgr_chord_replays_withheld_press(self) -> None:
|
||||
"""RightAlt held for composition replays its press and release."""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
fd = 10
|
||||
forwarder = MagicMock()
|
||||
backend._forwarders[fd] = forwarder
|
||||
backend._forwarded_held[fd] = set()
|
||||
backend._withheld_modifier[fd] = {}
|
||||
|
||||
press = self._key_event(100, 1) # KEY_RIGHTALT down
|
||||
key_e = self._key_event(18, 1) # KEY_E down (AltGr+e)
|
||||
release = self._key_event(100, 0) # KEY_RIGHTALT up
|
||||
|
||||
assert backend._event_is_shortcut(fd, press) is True
|
||||
forwarder.write_event.assert_not_called()
|
||||
|
||||
assert backend._event_is_shortcut(fd, key_e) is False
|
||||
forwarder.write_event.assert_called_once_with(press)
|
||||
assert 100 in backend._forwarded_held[fd]
|
||||
|
||||
assert backend._event_is_shortcut(fd, release) is True
|
||||
# The replayed modifier's release passes through so the clone's key
|
||||
# state matches the physical keyboard.
|
||||
assert forwarder.write_event.call_count == 2
|
||||
forwarder.write_event.assert_called_with(release)
|
||||
|
||||
def test_withheld_state_drops_on_device_removal(self) -> None:
|
||||
"""Disconnecting a keyboard forgets its withheld modifier presses."""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
device = MagicMock()
|
||||
device.fileno.return_value = 10
|
||||
forwarder = MagicMock()
|
||||
backend.devices = [device]
|
||||
backend.device_fds = [10]
|
||||
backend.device_paths = {"/dev/input/event0"}
|
||||
backend._device_paths_by_fd = {10: "/dev/input/event0"}
|
||||
backend._forwarders = {10: forwarder}
|
||||
backend._withheld_modifier[10] = {100: [self._key_event(100, 1), False]}
|
||||
|
||||
backend._remove_keyboard_device(10, device)
|
||||
|
||||
assert backend._withheld_modifier == {}
|
||||
|
||||
def test_split_keyboard_altgr_replays_modifier_from_other_device(self) -> None:
|
||||
"""A chord spanning two devices still replays the withheld modifier.
|
||||
|
||||
On a split keyboard RightAlt arrives on one device and the character
|
||||
key on another: the modifier is withheld under its own fd, so the
|
||||
replay must scan every device — checking only the character key's fd
|
||||
would forward a plain character and lose the AltGr composition.
|
||||
"""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
mod_fd, char_fd = 10, 20
|
||||
mod_forwarder, char_forwarder = MagicMock(), MagicMock()
|
||||
backend._forwarders = {mod_fd: mod_forwarder, char_fd: char_forwarder}
|
||||
backend._forwarded_held = {mod_fd: set(), char_fd: set()}
|
||||
backend._withheld_modifier = {mod_fd: {}, char_fd: {}}
|
||||
|
||||
press = self._key_event(100, 1) # KEY_RIGHTALT down on the left half
|
||||
key_e = self._key_event(18, 1) # KEY_E down on the right half
|
||||
|
||||
assert backend._event_is_shortcut(mod_fd, press) is True
|
||||
mod_forwarder.write_event.assert_not_called()
|
||||
|
||||
assert backend._event_is_shortcut(char_fd, key_e) is False
|
||||
# The modifier press is replayed through ITS OWN device's clone —
|
||||
# before the character is forwarded — so the app sees AltGr+e.
|
||||
mod_forwarder.write_event.assert_called_once_with(press)
|
||||
assert 100 in backend._forwarded_held[mod_fd]
|
||||
|
||||
def test_syn_dropped_prunes_unreplayed_withheld_modifier(self) -> None:
|
||||
"""A withheld press whose release SYN_DROPPED ate leaves no stale entry.
|
||||
|
||||
When the modifier was never replayed the clone holds nothing, so the
|
||||
old early return skipped the withheld-state cleanup entirely and a
|
||||
later ordinary press would replay a modifier that is not held.
|
||||
"""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
fd = 10
|
||||
device = MagicMock()
|
||||
device.active_keys.return_value = [] # modifier already released
|
||||
backend._forwarders = {fd: MagicMock()}
|
||||
backend._forwarded_held = {fd: set()} # nothing forwarded
|
||||
backend._withheld_modifier = {fd: {100: [self._key_event(100, 1), False]}}
|
||||
|
||||
backend._resync_clone_key_state(fd, device)
|
||||
|
||||
assert backend._withheld_modifier[fd] == {}
|
||||
|
||||
|
||||
class TestSharedEvdevDeviceLayer:
|
||||
"""Several backends share one evdev reader instead of competing grabs.
|
||||
|
||||
Regression tests for the "language listeners compete for keyboards"
|
||||
finding: each EvdevKeyboardBackend used to open and grab its own
|
||||
InputDevice on the same keyboard, so the first grabber won and every
|
||||
other listener received no events — language shortcuts could never
|
||||
fire on Wayland.
|
||||
"""
|
||||
|
||||
def _key_event(self, code: int, value: int = 1) -> MagicMock:
|
||||
return MagicMock(type=ecodes.EV_KEY, code=code, value=value)
|
||||
|
||||
def _registered(self, *engines: EvdevKeyboardBackend) -> EvdevDeviceHub:
|
||||
"""Register engines on the shared hub without opening devices."""
|
||||
hub = engines[0]._hub
|
||||
hub.running = True
|
||||
for engine in engines:
|
||||
assert hub.register(engine) is True
|
||||
return hub
|
||||
|
||||
def test_backends_share_one_device_layer(self) -> None:
|
||||
"""Every backend binds to the same process-wide hub."""
|
||||
first = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
second = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
|
||||
assert first._hub is second._hub
|
||||
assert second.devices is first.devices
|
||||
assert second._forwarders is first._forwarders
|
||||
assert second._device_paths_by_fd is first._device_paths_by_fd
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices")
|
||||
def test_second_start_registers_without_reopening_devices(
|
||||
self, mock_find: Mock, mock_input_device: Mock, mock_uinput: Mock
|
||||
) -> None:
|
||||
"""The first backend opens and grabs once; later ones just register."""
|
||||
mock_find.return_value = ["/dev/input/event0"]
|
||||
device = MagicMock()
|
||||
device.name = "test-kbd"
|
||||
device.fileno.return_value = 10
|
||||
device.capabilities.return_value = {}
|
||||
mock_input_device.return_value = device
|
||||
forwarder = MagicMock()
|
||||
forwarder.device.path = "/dev/input/event9"
|
||||
mock_uinput.return_value = forwarder
|
||||
|
||||
first = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
with patch("select.select", return_value=([], [], [])):
|
||||
assert first.start() is True
|
||||
second = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
assert second.start() is True
|
||||
|
||||
assert mock_input_device.call_count == 1 # device opened once
|
||||
assert device.grab.call_count == 1 # grabbed once
|
||||
assert set(first._hub._engine_snapshot()) == {first, second}
|
||||
|
||||
second.stop()
|
||||
assert first._hub.running is True # layer outlives one listener
|
||||
first.stop()
|
||||
assert first._hub.running is False
|
||||
device.close.assert_called()
|
||||
|
||||
def test_two_engines_both_fire_on_one_device(self) -> None:
|
||||
"""Events on a shared keyboard reach every registered engine."""
|
||||
german = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
french = EvdevKeyboardBackend(shortcut="alt+d", mode="toggle")
|
||||
hub = self._registered(german, french)
|
||||
|
||||
german_fired = MagicMock()
|
||||
french_fired = MagicMock()
|
||||
german.register_toggle_callback(german_fired)
|
||||
french.register_toggle_callback(french_fired)
|
||||
|
||||
device = MagicMock()
|
||||
fd = 10
|
||||
device.read.return_value = [
|
||||
self._key_event(29, 1), # ctrl press (german tap 1)
|
||||
self._key_event(29, 0), # ctrl release
|
||||
self._key_event(29, 1), # ctrl press (german double-tap)
|
||||
self._key_event(56, 1), # alt press (french modifier)
|
||||
self._key_event(32, 1), # d press (french combo main key)
|
||||
]
|
||||
|
||||
hub._dispatch_events(fd, device)
|
||||
|
||||
time.sleep(0.1)
|
||||
german_fired.assert_called_once()
|
||||
french_fired.assert_called_once()
|
||||
|
||||
def test_shortcut_events_consumed_once_across_engines(self) -> None:
|
||||
"""An event claimed by any engine never reaches the application."""
|
||||
pure = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
combo = EvdevKeyboardBackend(shortcut="ctrl+f", mode="toggle")
|
||||
hub = self._registered(pure, combo)
|
||||
fd = 10
|
||||
forwarder = MagicMock()
|
||||
hub._forwarders = {fd: forwarder}
|
||||
hub._forwarded_held = {fd: set()}
|
||||
|
||||
ctrl_press = self._key_event(29, 1)
|
||||
f_press = self._key_event(33, 1)
|
||||
a_press = self._key_event(30, 1)
|
||||
ralt_press = self._key_event(100, 1)
|
||||
ralt_release = self._key_event(100, 0)
|
||||
device = MagicMock()
|
||||
device.read.return_value = [ctrl_press, f_press, a_press, ralt_press, ralt_release]
|
||||
|
||||
hub._dispatch_events(fd, device)
|
||||
|
||||
# f is consumed by the combo engine while ctrl is held, both right-alt
|
||||
# events by the pure-modifier engine; unrelated keys still pass through.
|
||||
forwarded = [call.args[0] for call in forwarder.write_event.call_args_list]
|
||||
assert forwarded == [ctrl_press, a_press]
|
||||
|
||||
def test_unregister_shuts_down_only_when_last_engine_leaves(self) -> None:
|
||||
"""The shared layer stays up until its last listener stops."""
|
||||
first = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
second = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
hub = self._registered(first, second)
|
||||
first.active = True
|
||||
second.active = True
|
||||
device = MagicMock()
|
||||
hub.devices = [device]
|
||||
|
||||
first.stop()
|
||||
assert hub.running is True
|
||||
device.close.assert_not_called()
|
||||
|
||||
second.stop()
|
||||
assert hub.running is False
|
||||
assert hub.devices == []
|
||||
device.close.assert_called_once()
|
||||
|
||||
def test_dispatch_reaches_only_registered_engines(self) -> None:
|
||||
"""A backend that never started observes nothing."""
|
||||
active = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
idle = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
hub = self._registered(active)
|
||||
active._handle_key_event = MagicMock()
|
||||
idle._handle_key_event = MagicMock()
|
||||
|
||||
device = MagicMock()
|
||||
device.read.return_value = [self._key_event(29, 1)]
|
||||
hub._dispatch_events(10, device)
|
||||
|
||||
active._handle_key_event.assert_called_once()
|
||||
idle._handle_key_event.assert_not_called()
|
||||
|
||||
def test_register_waits_for_in_flight_teardown(self) -> None:
|
||||
"""Devices opened after teardown starts are never closed by it.
|
||||
|
||||
Regression test for "keyboard listener can lose devices": the last
|
||||
engine's unregister used to release the lock before closing, so a
|
||||
new register could open keyboards in the gap only for the stale
|
||||
teardown to close them — the new listener stayed active but dead.
|
||||
"""
|
||||
hub = EvdevDeviceHub()
|
||||
old_engine = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
new_engine = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
old_device = MagicMock()
|
||||
hub.running = True
|
||||
hub._engines.add(old_engine)
|
||||
hub.devices = [old_device]
|
||||
|
||||
teardown_started = threading.Event()
|
||||
finish_teardown = threading.Event()
|
||||
|
||||
def blocking_close() -> None:
|
||||
teardown_started.set()
|
||||
finish_teardown.wait(timeout=5.0)
|
||||
|
||||
hub._close_all_devices = blocking_close
|
||||
|
||||
unregister_done = threading.Event()
|
||||
|
||||
def run_unregister() -> None:
|
||||
hub.unregister(old_engine)
|
||||
unregister_done.set()
|
||||
|
||||
threading.Thread(target=run_unregister, daemon=True).start()
|
||||
assert teardown_started.wait(timeout=5.0)
|
||||
|
||||
new_device = MagicMock()
|
||||
new_device.fileno.return_value = 42
|
||||
result: list[bool] = []
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices",
|
||||
return_value=["/dev/input/event9"],
|
||||
),
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.InputDevice",
|
||||
return_value=new_device,
|
||||
),
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.UInput",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
register_thread = threading.Thread(
|
||||
target=lambda: result.append(hub.register(new_engine)),
|
||||
daemon=True,
|
||||
)
|
||||
register_thread.start()
|
||||
register_thread.join(timeout=0.5)
|
||||
assert register_thread.is_alive() # blocked while teardown runs
|
||||
|
||||
finish_teardown.set()
|
||||
register_thread.join(timeout=5.0)
|
||||
assert result == [True]
|
||||
new_device.close.assert_not_called()
|
||||
assert hub.running is True
|
||||
assert hub.devices == [new_device]
|
||||
assert new_engine in hub._engines
|
||||
assert old_engine not in hub._engines
|
||||
|
||||
assert unregister_done.wait(timeout=5.0)
|
||||
hub.reset()
|
||||
|
||||
def test_unregister_joins_monitor_outside_the_lifecycle_lock(self) -> None:
|
||||
"""The monitor join must not hold the lock its cleanup queues on.
|
||||
|
||||
Regression test for "monitor cleanup delays shutdown": joining under
|
||||
_lifecycle_lock stalled the full 2s timeout whenever the monitor's
|
||||
unexpected-exit cleanup raced in for the same lock.
|
||||
"""
|
||||
hub = EvdevDeviceHub()
|
||||
engine = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
hub.running = True
|
||||
hub._engines.add(engine)
|
||||
monitor = MagicMock()
|
||||
hub.monitor_thread = monitor
|
||||
|
||||
lock_was_free = threading.Event()
|
||||
|
||||
def join_and_probe(timeout: float) -> None:
|
||||
if hub._lifecycle_lock.acquire(blocking=False):
|
||||
lock_was_free.set()
|
||||
hub._lifecycle_lock.release()
|
||||
|
||||
monitor.join = join_and_probe
|
||||
|
||||
hub.unregister(engine)
|
||||
|
||||
assert lock_was_free.is_set()
|
||||
assert hub.running is False
|
||||
assert hub.monitor_thread is None
|
||||
assert engine not in hub._engines
|
||||
hub.reset()
|
||||
|
||||
def test_stale_monitor_leaves_new_generation_devices_alone(self) -> None:
|
||||
"""A monitor that outlived its generation can't touch new devices.
|
||||
|
||||
Regression test for "old monitor reaches new devices": once the
|
||||
generation advanced, every fd the new listener opened is off-limits
|
||||
to the stale reader — no dispatch, no forward, no removal — even
|
||||
though its fd number is readable and mapped.
|
||||
"""
|
||||
hub = EvdevDeviceHub()
|
||||
hub.running = True
|
||||
hub._generation = 6
|
||||
|
||||
new_device = MagicMock()
|
||||
new_device.fileno.return_value = 10
|
||||
new_device.name = "External Keyboard"
|
||||
new_clone = MagicMock()
|
||||
hub.devices = [new_device]
|
||||
hub.device_fds = [10]
|
||||
hub.device_paths = {"/dev/input/event4"}
|
||||
hub._device_paths_by_fd = {10: "/dev/input/event4"}
|
||||
hub._forwarders = {10: new_clone}
|
||||
hub._fd_generation = {10: 6}
|
||||
hub._dispatch_events = MagicMock()
|
||||
|
||||
def select_then_stop(read_fds, write_fds, error_fds, timeout):
|
||||
hub.running = False
|
||||
return [10], [], []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.time.monotonic",
|
||||
side_effect=[0.0, 0.0],
|
||||
),
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.select.select",
|
||||
side_effect=select_then_stop,
|
||||
),
|
||||
):
|
||||
hub._monitor_devices(generation=5)
|
||||
|
||||
hub._dispatch_events.assert_not_called()
|
||||
|
||||
hub._forward_event(10, MagicMock(type=1, code=30, value=1), generation=5)
|
||||
new_clone.write_event.assert_not_called()
|
||||
|
||||
hub._remove_keyboard_device(10, new_device, generation=5)
|
||||
new_device.close.assert_not_called()
|
||||
assert hub.devices == [new_device]
|
||||
assert hub.device_fds == [10]
|
||||
|
||||
hub.reset()
|
||||
|
||||
@@ -1004,3 +1004,22 @@ def test_refresh_rebuilds_immediately_when_idle() -> None:
|
||||
assert tray._language_shortcuts_refresh_pending is False
|
||||
manager_class.assert_called_once_with(shortcut="alt+d", mode="toggle")
|
||||
manager.start.assert_called_once()
|
||||
|
||||
|
||||
def test_language_shortcut_managers_share_the_evdev_device_layer() -> None:
|
||||
"""Two per-language managers must not spawn competing evdev readers.
|
||||
|
||||
Regression test for PR #479 review: each manager used to open and grab
|
||||
its own InputDevice on the same keyboard, so only the first grabber
|
||||
ever saw events and language shortcuts could not fire on Wayland.
|
||||
"""
|
||||
from vocalinux.ui.keyboard_backends.evdev_backend import EvdevKeyboardBackend
|
||||
from vocalinux.ui.keyboard_shortcuts import KeyboardShortcutManager
|
||||
|
||||
with patch.object(EvdevKeyboardBackend, "is_available", return_value=True):
|
||||
german = KeyboardShortcutManager(backend="evdev", shortcut="alt+d", mode="toggle")
|
||||
french = KeyboardShortcutManager(backend="evdev", shortcut="ctrl+alt+f", mode="toggle")
|
||||
|
||||
assert isinstance(german.backend_instance, EvdevKeyboardBackend)
|
||||
assert isinstance(french.backend_instance, EvdevKeyboardBackend)
|
||||
assert french.backend_instance._hub is german.backend_instance._hub
|
||||
|
||||
+490
-52
@@ -197,6 +197,7 @@ class TestMainModule(unittest.TestCase):
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config_instance.get_model_size_for_engine.return_value = "medium"
|
||||
mock_config_instance.get_str.return_value = "" # no post-processing script
|
||||
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
@@ -262,6 +263,7 @@ class TestMainModule(unittest.TestCase):
|
||||
speech_engine=mock_speech_instance,
|
||||
text_injector=mock_text_instance,
|
||||
transcription_history=ANY,
|
||||
on_quit=ANY,
|
||||
dictation_pad=mock_pad_instance,
|
||||
)
|
||||
# The pad starts from the shared manager's live capture flag.
|
||||
@@ -273,10 +275,11 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_speech_instance.register_text_callback.assert_called_once()
|
||||
mock_speech_instance.register_segment_callback.assert_called_once()
|
||||
mock_speech_instance.register_action_callback.assert_called_once()
|
||||
# The registered wrapper delegates non-pad actions to ActionHandler.
|
||||
action_callback = mock_speech_instance.register_action_callback.call_args.args[0]
|
||||
action_callback("undo")
|
||||
mock_action_instance.handle_action.assert_called_once_with("undo")
|
||||
# The registered action callback queues the action onto the
|
||||
# post-processing worker, which dispatches to the action handler.
|
||||
action_cb = mock_speech_instance.register_action_callback.call_args.args[0]
|
||||
action_cb("select_all").result(timeout=10)
|
||||
mock_action_instance.handle_action.assert_called_once_with("select_all")
|
||||
mock_speech_instance.register_state_callback.assert_called_once()
|
||||
|
||||
# Verify the tray indicator was started
|
||||
@@ -305,6 +308,7 @@ class TestMainModule(unittest.TestCase):
|
||||
"speech_recognition": {},
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config_instance.get_str.return_value = "" # no post-processing script
|
||||
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
@@ -332,17 +336,19 @@ class TestMainModule(unittest.TestCase):
|
||||
text_callback = mock_speech_instance.register_text_callback.call_args.args[0]
|
||||
state_callback = mock_speech_instance.register_state_callback.call_args.args[0]
|
||||
|
||||
text_callback("Hello.")
|
||||
# Injection now happens on the post-processing worker; the returned
|
||||
# future drains it synchronously.
|
||||
text_callback("Hello.").result(timeout=10)
|
||||
state_callback(RecognitionState.PROCESSING)
|
||||
state_callback(RecognitionState.LISTENING)
|
||||
text_callback("World")
|
||||
text_callback("World").result(timeout=10)
|
||||
|
||||
calls = [call.args[0] for call in mock_text_instance.inject_text.call_args_list]
|
||||
self.assertEqual(calls, ["Hello. ", "World "])
|
||||
|
||||
state_callback(RecognitionState.IDLE)
|
||||
mock_text_instance.inject_text.reset_mock()
|
||||
text_callback("Next session")
|
||||
text_callback("Next session").result(timeout=10)
|
||||
# Trailing space persists in the previous field; next session starts clean
|
||||
# (no leading space) but still gets its own trailing space.
|
||||
mock_text_instance.inject_text.assert_called_once_with("Next session ")
|
||||
@@ -369,6 +375,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_config_instance.get.side_effect = lambda section, key, default=None: (
|
||||
auto_capitalize if section == "text_injection" and key == "auto_capitalize" else default
|
||||
)
|
||||
mock_config_instance.get_str.return_value = "" # no post-processing script
|
||||
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
@@ -424,7 +431,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_check_deps=mock_check_deps,
|
||||
)
|
||||
|
||||
text_callback("hello world. goodbye")
|
||||
text_callback("hello world. goodbye").result(timeout=10)
|
||||
mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye ")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@@ -453,7 +460,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_check_deps=mock_check_deps,
|
||||
)
|
||||
|
||||
text_callback("hello world. goodbye")
|
||||
text_callback("hello world. goodbye").result(timeout=10)
|
||||
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@@ -482,7 +489,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_check_deps=mock_check_deps,
|
||||
)
|
||||
|
||||
text_callback("hello world. goodbye")
|
||||
text_callback("hello world. goodbye").result(timeout=10)
|
||||
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@@ -1278,6 +1285,7 @@ def _boot_main_callbacks(
|
||||
append_trailing_space: bool = True,
|
||||
inject_ok: bool = True,
|
||||
dictate_to_pad: bool = False,
|
||||
post_script: str = "",
|
||||
) -> SimpleNamespace:
|
||||
"""Boot main() with mocked deps; return the registered callbacks and mocks.
|
||||
|
||||
@@ -1297,6 +1305,7 @@ def _boot_main_callbacks(
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config.get.return_value = False # auto_capitalize off
|
||||
mock_config.get_str.return_value = post_script
|
||||
mock_config.is_dictate_to_pad_enabled.return_value = dictate_to_pad
|
||||
mock_config_cls.return_value = mock_config
|
||||
|
||||
@@ -1327,6 +1336,15 @@ def _boot_main_callbacks(
|
||||
return_value=append_trailing_space,
|
||||
)
|
||||
)
|
||||
# Probing the focused window shells out to compositor tools; keep the
|
||||
# callback tests deterministic by making the probe report "unknown",
|
||||
# which keeps the permissive injection path.
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
return_value=None,
|
||||
)
|
||||
)
|
||||
mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments"))
|
||||
stack.enter_context(patch("sys.argv", ["vocalinux"]))
|
||||
|
||||
@@ -1358,50 +1376,358 @@ def _boot_main_callbacks(
|
||||
pad=mock_pad,
|
||||
pad_cls=mock_pad_cls,
|
||||
config=mock_config,
|
||||
mock_text=mock_text,
|
||||
mock_config=mock_config,
|
||||
mock_speech=mock_speech,
|
||||
mock_tray_cls=mock_tray_cls,
|
||||
)
|
||||
|
||||
|
||||
class TestMainCallbackTrailingSpaceEdges(unittest.TestCase):
|
||||
"""Exercise trailing-space edge paths through the real main() callback."""
|
||||
|
||||
def _boot_under_patches(
|
||||
self,
|
||||
*,
|
||||
append_trailing_space: bool = True,
|
||||
inject_ok: bool = True,
|
||||
post_script: str = "",
|
||||
) -> SimpleNamespace:
|
||||
"""Boot main() under mocks; the shared helper does the wiring."""
|
||||
return _boot_main_callbacks(
|
||||
append_trailing_space=append_trailing_space,
|
||||
inject_ok=inject_ok,
|
||||
post_script=post_script,
|
||||
)
|
||||
|
||||
def test_whitespace_only_is_skipped_through_main(self) -> None:
|
||||
boot = _boot_main_callbacks()
|
||||
"""Whitespace-only segments return without touching the injector."""
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
boot.text_cb(" \t ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
self.assertIsNone(boot.text_cb(" \t "))
|
||||
boot.mock_text.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_newline_segment_skips_trailing_space_through_main(self) -> None:
|
||||
boot = _boot_main_callbacks()
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
boot.text_cb("Hello.\n")
|
||||
boot.text_cb("Hello.\n").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("Hello.\n")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_legacy_mode_adds_leading_space_in_session(self) -> None:
|
||||
boot = _boot_main_callbacks(append_trailing_space=False)
|
||||
boot = self._boot_under_patches(append_trailing_space=False)
|
||||
try:
|
||||
boot.text_cb("Hello.")
|
||||
boot.text_cb("World")
|
||||
boot.text_cb("Hello.").result(timeout=10)
|
||||
boot.text_cb("World").result(timeout=10)
|
||||
calls = [c.args[0] for c in boot.text_system.inject_text.call_args_list]
|
||||
self.assertEqual(calls, ["Hello.", " World"])
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_failed_inject_does_not_remember_text(self) -> None:
|
||||
boot = _boot_main_callbacks(append_trailing_space=False, inject_ok=False)
|
||||
boot = self._boot_under_patches(append_trailing_space=False, inject_ok=False)
|
||||
try:
|
||||
boot.text_cb("Hello.")
|
||||
boot.text_cb("Hello.").result(timeout=10)
|
||||
boot.text_system.inject_text.reset_mock()
|
||||
boot.text_system.inject_text.return_value = True
|
||||
boot.text_cb("World")
|
||||
boot.text_cb("World").result(timeout=10)
|
||||
# Failure means last_injected stays empty; next segment has no leading space
|
||||
boot.text_system.inject_text.assert_called_once_with("World")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_post_processing_transform_reaches_injector(self) -> None:
|
||||
"""Configured script output is what gets injected, spacing rules included."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
run_result = MagicMock()
|
||||
run_result.returncode = 0
|
||||
run_result.stderr = ""
|
||||
run_result.stdout = "TRANSFORMED"
|
||||
with patch("vocalinux.post_processor.subprocess.run", return_value=run_result):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("TRANSFORMED ")
|
||||
|
||||
# A transformed paragraph break keeps its newlines (and so
|
||||
# skips the appended trailing space like any "\n" ending).
|
||||
boot.text_system.inject_text.reset_mock()
|
||||
run_result.stdout = "PARA ONE.\n\n"
|
||||
boot.text_cb("para one.\n\n").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("PARA ONE.\n\n")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_post_processing_empty_output_skips_injection(self) -> None:
|
||||
"""A script that emits nothing swallows the segment — nothing injected."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
run_result = MagicMock()
|
||||
run_result.returncode = 0
|
||||
run_result.stderr = ""
|
||||
run_result.stdout = ""
|
||||
with patch("vocalinux.post_processor.subprocess.run", return_value=run_result):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_segment_queues_behind_running_script_when_script_cleared(self) -> None:
|
||||
"""Clearing the script path mid-queue cannot let a later segment overtake."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="PROCESSED FIRST", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
first = boot.text_cb("first")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
# User disables the script while the first segment still runs;
|
||||
# the second must still queue behind it, not inject directly.
|
||||
boot.mock_config.get_str.return_value = ""
|
||||
second = boot.text_cb("second")
|
||||
gate.set()
|
||||
self.assertIsNotNone(first)
|
||||
self.assertIsNotNone(second)
|
||||
first.result(timeout=10)
|
||||
second.result(timeout=10)
|
||||
calls = [c.args[0] for c in boot.text_system.inject_text.call_args_list]
|
||||
self.assertEqual(calls, ["PROCESSED FIRST ", "second "])
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_queued_segment_dropped_when_focus_moves(self) -> None:
|
||||
"""A segment that outlived its target app is dropped, not injected."""
|
||||
from vocalinux.text_injection.focused_window import FocusedWindow
|
||||
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
window_a = FocusedWindow(app_id="editor", wm_class="Editor", process_name="editor")
|
||||
window_b = FocusedWindow(app_id="browser", wm_class="Browser", process_name="browser")
|
||||
run_result = MagicMock(returncode=0, stdout="OUT", stderr="")
|
||||
# First probe (submit time) sees the editor; the worker's re-probe
|
||||
# after the script finds the browser — different application.
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[window_a, window_b, window_b, window_b],
|
||||
),
|
||||
patch("vocalinux.post_processor.subprocess.run", return_value=run_result),
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_same_app_focus_change_still_injects(self) -> None:
|
||||
"""Focus probe returning the same app keeps the segment deliverable."""
|
||||
from vocalinux.text_injection.focused_window import FocusedWindow
|
||||
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
window = FocusedWindow(app_id="editor", process_name="editor")
|
||||
# Two editor windows differ only by title — same application.
|
||||
other_doc = FocusedWindow(app_id="editor", title="other.py", process_name="editor")
|
||||
run_result = MagicMock(returncode=0, stdout="hello", stderr="")
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[window, other_doc, other_doc],
|
||||
),
|
||||
patch("vocalinux.post_processor.subprocess.run", return_value=run_result),
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("hello ")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_voice_action_queues_behind_pending_text(self) -> None:
|
||||
"""A voice action cannot overtake a segment still waiting in the worker."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="PROCESSED", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
text_future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
# The action is issued while the script still holds the worker;
|
||||
# it must run only after the queued text has been injected.
|
||||
action_future = boot.action_cb("select_all")
|
||||
gate.set()
|
||||
text_future.result(timeout=10)
|
||||
action_future.result(timeout=10)
|
||||
call_names = [c[0] for c in boot.text_system.mock_calls]
|
||||
self.assertEqual(call_names, ["inject_text", "_inject_keyboard_shortcut"])
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_voice_action_without_script_still_uses_worker(self) -> None:
|
||||
"""With no script configured, actions still queue behind pending text."""
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
action_future = boot.action_cb("select_all")
|
||||
self.assertIsNotNone(action_future)
|
||||
action_future.result(timeout=10)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_called_once_with("ctrl+a")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_idle_reset_waits_for_queued_delete_action(self) -> None:
|
||||
"""A 'delete that' queued before IDLE must still see the text it targets."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="HELLO", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
text_future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
delete_future = boot.action_cb("delete_last")
|
||||
# The session ends while both jobs are queued; the reset must
|
||||
# run only after the delete action consumed last_injected_text.
|
||||
boot.state_cb(RecognitionState.IDLE)
|
||||
gate.set()
|
||||
text_future.result(timeout=10)
|
||||
delete_future.result(timeout=10)
|
||||
boot.text_system.press_backspace.assert_called_once_with(len("HELLO "))
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_action_waits_for_slow_probe(self) -> None:
|
||||
"""A stalled focus probe delays the action but never discards it."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
stall = threading.Event()
|
||||
|
||||
def stalled_probe() -> None:
|
||||
stall.wait(30)
|
||||
return None
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=stalled_probe,
|
||||
):
|
||||
action_future = boot.action_cb("select_all")
|
||||
self.assertIsNotNone(action_future)
|
||||
# While the probe has not answered the job cannot run the
|
||||
# shortcut; once it answers, the command must still fire.
|
||||
self.assertFalse(action_future.done())
|
||||
stall.set()
|
||||
action_future.result(timeout=10)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_called_once_with("ctrl+a")
|
||||
finally:
|
||||
stall.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_queued_action_dropped_when_focus_moves(self) -> None:
|
||||
"""A queued shortcut is dropped, not fired into the newly focused app."""
|
||||
from vocalinux.text_injection.focused_window import FocusedWindow
|
||||
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
probes_done = threading.Event()
|
||||
probe_calls: list = []
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="OUT", stderr="")
|
||||
|
||||
try:
|
||||
window_a = FocusedWindow(app_id="editor", wm_class="Editor", process_name="editor")
|
||||
window_b = FocusedWindow(app_id="browser", wm_class="Browser", process_name="browser")
|
||||
window_iter = iter([window_a, window_a, window_b, window_b, window_b, window_b])
|
||||
|
||||
def fake_probe() -> FocusedWindow:
|
||||
# Both submit-time probes capture the editor; the jobs' later
|
||||
# re-probes then see the browser.
|
||||
probe_calls.append(1)
|
||||
if len(probe_calls) == 2:
|
||||
probes_done.set()
|
||||
return next(window_iter)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=fake_probe,
|
||||
),
|
||||
patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run),
|
||||
):
|
||||
text_future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
action_future = boot.action_cb("select_all")
|
||||
# Wait for both submit-time probes so the re-probes in the
|
||||
# queued jobs deterministically observe the new application.
|
||||
self.assertTrue(probes_done.wait(timeout=5))
|
||||
gate.set()
|
||||
text_future.result(timeout=10)
|
||||
action_future.result(timeout=10)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_not_called()
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_quit_hook_stops_worker_and_blocks_new_submissions(self) -> None:
|
||||
"""The tray's on_quit hook drains the worker and rejects new segments."""
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
on_quit = boot.mock_tray_cls.call_args.kwargs["on_quit"]
|
||||
on_quit()
|
||||
self.assertIsNone(boot.text_cb("hello"))
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_running_job_drops_result_during_quit(self) -> None:
|
||||
"""A script mid-flight when quit begins cannot inject afterwards."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="TOO LATE", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
quit_thread = threading.Thread(
|
||||
target=boot.mock_tray_cls.call_args.kwargs["on_quit"]
|
||||
)
|
||||
quit_thread.start()
|
||||
gate.set()
|
||||
quit_thread.join(timeout=10)
|
||||
self.assertFalse(quit_thread.is_alive())
|
||||
future.result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
|
||||
class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""Pad capture routing through the real main() callback wrappers (#726).
|
||||
@@ -1413,7 +1739,7 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
def test_capture_routes_transcription_to_pad_not_injector(self) -> None:
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("hello")
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("hello ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
@@ -1423,8 +1749,8 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.pad.last_segment = "hello "
|
||||
boot.text_cb("hello")
|
||||
boot.action_cb("delete_last")
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("hello "))
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1446,7 +1772,7 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"paste",
|
||||
):
|
||||
boot.pad.handle_action.reset_mock()
|
||||
self.assertTrue(boot.action_cb(action))
|
||||
self.assertTrue(boot.action_cb(action).result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with(action)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
@@ -1459,9 +1785,9 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
try:
|
||||
# Dictated into the app; capture switched on afterwards: the
|
||||
# backspaces must still go to the app that received the text.
|
||||
boot.text_cb("into app")
|
||||
boot.text_cb("into app").result(timeout=10)
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = True
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.text_system.press_backspace.assert_called_once_with(len("into app "))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
|
||||
@@ -1470,9 +1796,9 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = True
|
||||
boot.pad.last_segment = "into pad "
|
||||
boot.pad.delete_last_chars.return_value = len("into pad ")
|
||||
boot.text_cb("into pad")
|
||||
boot.text_cb("into pad").result(timeout=10)
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = False
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("into pad "))
|
||||
self.assertEqual(boot.text_system.press_backspace.call_count, 1)
|
||||
finally:
|
||||
@@ -1482,11 +1808,11 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""Routing follows the shared manager's live value, not a snapshot."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=False)
|
||||
try:
|
||||
boot.text_cb("to app")
|
||||
boot.text_cb("to app").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("to app ")
|
||||
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = True
|
||||
boot.text_cb("to pad")
|
||||
boot.text_cb("to pad").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("to pad ")
|
||||
self.assertEqual(boot.text_system.inject_text.call_count, 1)
|
||||
finally:
|
||||
@@ -1495,7 +1821,7 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
def test_delete_that_with_empty_history_is_swallowed(self) -> None:
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1504,9 +1830,9 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
def test_idle_state_resets_delete_that_target(self) -> None:
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("pad text")
|
||||
boot.text_cb("pad text").result(timeout=10)
|
||||
boot.state_cb(RecognitionState.IDLE)
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1516,16 +1842,16 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""Undoing the last pad segment must not leave a stale delete length."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("hello")
|
||||
boot.text_cb("world")
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_cb("world").result(timeout=10)
|
||||
# The pad reports the surviving segment after its own undo pops
|
||||
# "world "; the next delete targets it, not the stale segment.
|
||||
boot.pad.last_segment = "hello "
|
||||
boot.pad.handle_action.return_value = True
|
||||
self.assertTrue(boot.action_cb("undo"))
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with("undo")
|
||||
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("hello "))
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1535,35 +1861,145 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""When pad undo removes the last segment, "delete that" is a no-op."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("only")
|
||||
boot.text_cb("only").result(timeout=10)
|
||||
boot.pad.last_segment = None
|
||||
boot.pad.handle_action.return_value = True
|
||||
self.assertTrue(boot.action_cb("undo"))
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_delete_that_after_manual_pad_edit_is_a_no_op(self) -> None:
|
||||
"""A manual edit blurs segment boundaries: "delete that" must not
|
||||
erase user-typed text with the stale recorded length."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
# The widget's own edit cleared the tracked segment boundaries.
|
||||
boot.pad.last_segment = None
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
# Tracking stays cleared, so a repeat cannot retry the stale length.
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_pad_bound_segment_survives_focus_change(self) -> None:
|
||||
"""Pad-bound text is not dropped when focus moved since the dictate."""
|
||||
# A configured script arms the submit-time focus probe.
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True, post_script="/bin/cat")
|
||||
try:
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
app_b = MagicMock()
|
||||
app_b.identity_blob.return_value = "app-b"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[app_a, app_b],
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("hello ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_segment_destination_decided_once_between_check_and_delivery(self) -> None:
|
||||
"""A toggle flip between the focus check and delivery cannot reroute
|
||||
a pad-bound segment into whichever application holds focus."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True, post_script="/bin/cat")
|
||||
try:
|
||||
# The routing decision reads the toggle once; a stale re-read at
|
||||
# delivery would see it off and inject into the focused app.
|
||||
boot.config.is_dictate_to_pad_enabled.side_effect = [True, False]
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
return_value=app_a,
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("hello ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_action_destination_decided_once_between_check_and_delivery(self) -> None:
|
||||
"""The same single decision binds a pad-targeted editing command."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.config.is_dictate_to_pad_enabled.side_effect = [True, False]
|
||||
boot.pad.handle_action.return_value = True
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
return_value=app_a,
|
||||
):
|
||||
self.assertTrue(boot.action_cb("select_all").result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with("select_all")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_app_bound_segment_drops_on_focus_change(self) -> None:
|
||||
"""App-bound text is still dropped when focus moved since the dictate."""
|
||||
# A configured script arms the submit-time focus probe.
|
||||
boot = _boot_main_callbacks(dictate_to_pad=False, post_script="/bin/cat")
|
||||
try:
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
app_b = MagicMock()
|
||||
app_b.identity_blob.return_value = "app-b"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[app_a, app_b],
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
boot.pad.append_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_pad_bound_action_survives_focus_change(self) -> None:
|
||||
"""Pad-bound editing commands are not dropped on a focus change."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.pad.handle_action.return_value = True
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
app_b = MagicMock()
|
||||
app_b.identity_blob.return_value = "app-b"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[app_a, app_b],
|
||||
):
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with("undo")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_pad_redo_restores_delete_target(self) -> None:
|
||||
"""A redo that brings the segment back must re-arm "delete that"."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("only")
|
||||
boot.text_cb("only").result(timeout=10)
|
||||
boot.pad.handle_action.return_value = True
|
||||
|
||||
# Undo to an empty pad clears the target but keeps the pad as the
|
||||
# last dictation destination.
|
||||
boot.pad.last_segment = None
|
||||
self.assertTrue(boot.action_cb("undo"))
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
|
||||
# Redo restores the segment; the next delete reaches the pad.
|
||||
boot.pad.last_segment = "only"
|
||||
self.assertTrue(boot.action_cb("redo"))
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("redo").result(timeout=10))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("only"))
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1771,15 +2207,17 @@ class TestSessionHistoryRecording(unittest.TestCase):
|
||||
segment_cb("second", time.monotonic())
|
||||
state_cb(RecognitionState.IDLE)
|
||||
|
||||
# The older session's worker decodes last: its text belongs to
|
||||
# the first session, not to the currently-newest entry.
|
||||
# The older session's worker decodes last: its text still belongs
|
||||
# to the first session — worker ownership extends that session's
|
||||
# own snippet rather than the currently-newest entry or an
|
||||
# orphan one.
|
||||
on_old_worker("late tail")
|
||||
self.assertEqual(history.get_all(), ["late tail", "second", "first"])
|
||||
self.assertEqual(history.get_all(), ["second", "first late tail"])
|
||||
|
||||
# A later straggler on that same worker still extends the
|
||||
# snippet it owns.
|
||||
# A later straggler on that same worker still extends the same
|
||||
# snippet it owns — one session never splits across entries.
|
||||
on_old_worker("more tail")
|
||||
self.assertEqual(history.get_all(), ["late tail more tail", "second", "first"])
|
||||
self.assertEqual(history.get_all(), ["second", "first late tail more tail"])
|
||||
finally:
|
||||
close_old_worker()
|
||||
stack.close()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for PostProcessor and apply_post_processing."""
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vocalinux.post_processor import PostProcessor, apply_post_processing
|
||||
from vocalinux.utils.host_process import host_env
|
||||
|
||||
|
||||
def _make_run_result(returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.returncode = returncode
|
||||
result.stdout = stdout
|
||||
result.stderr = stderr
|
||||
return result
|
||||
|
||||
|
||||
class TestPostProcessor(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.patch_run = patch("vocalinux.post_processor.subprocess.run")
|
||||
self.mock_run = self.patch_run.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.patch_run.stop()
|
||||
|
||||
def test_convenience_newline_stripped_when_input_had_none(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="hello\n")
|
||||
result = PostProcessor("/script.sh").process("hello")
|
||||
self.assertEqual(result, "hello")
|
||||
|
||||
def test_internal_newlines_preserved(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="a\nb\n")
|
||||
result = PostProcessor("/script.sh").process("a\nb")
|
||||
self.assertEqual(result, "a\nb")
|
||||
|
||||
def test_paragraph_break_preserved_verbatim(self) -> None:
|
||||
"""A passthrough script must not eat the "\n\n" from "new paragraph"."""
|
||||
self.mock_run.return_value = _make_run_result(stdout="end of paragraph.\n\n")
|
||||
result = PostProcessor("/script.sh").process("end of paragraph.\n\n")
|
||||
self.assertEqual(result, "end of paragraph.\n\n")
|
||||
|
||||
def test_script_added_paragraph_survives_convenience_strip(self) -> None:
|
||||
"""Only one trailing newline is treated as a line terminator."""
|
||||
self.mock_run.return_value = _make_run_result(stdout="a\n\n")
|
||||
result = PostProcessor("/script.sh").process("a")
|
||||
self.assertEqual(result, "a\n")
|
||||
|
||||
def test_empty_stdout_returns_empty_string(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="")
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_nonzero_exit_returns_original(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(returncode=1, stderr="oops")
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "original")
|
||||
|
||||
def test_timeout_returns_original(self) -> None:
|
||||
self.mock_run.side_effect = subprocess.TimeoutExpired(cmd="/script.sh", timeout=10)
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "original")
|
||||
|
||||
def test_os_error_returns_original(self) -> None:
|
||||
self.mock_run.side_effect = OSError("not found")
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "original")
|
||||
|
||||
def test_subprocess_called_with_correct_args(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="out")
|
||||
PostProcessor("/my/script.sh").process("input text")
|
||||
self.mock_run.assert_called_once_with(
|
||||
["/my/script.sh"],
|
||||
input="input text",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=host_env(),
|
||||
)
|
||||
|
||||
|
||||
class TestApplyPostProcessing(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.patch_processor = patch("vocalinux.post_processor.PostProcessor")
|
||||
self.mock_processor_cls = self.patch_processor.start()
|
||||
self.mock_processor = MagicMock()
|
||||
self.mock_processor_cls.return_value = self.mock_processor
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.patch_processor.stop()
|
||||
|
||||
def _config(self, script_path: str) -> MagicMock:
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_str.return_value = script_path
|
||||
return config_manager
|
||||
|
||||
def test_no_script_returns_original_text(self) -> None:
|
||||
config_manager = self._config("")
|
||||
result = apply_post_processing("hello", config_manager)
|
||||
self.assertEqual(result, "hello")
|
||||
self.mock_processor_cls.assert_not_called()
|
||||
|
||||
def test_script_configured_returns_processed_text(self) -> None:
|
||||
self.mock_processor.process.return_value = "modified"
|
||||
result = apply_post_processing("hello", self._config("/s.sh"))
|
||||
self.assertEqual(result, "modified")
|
||||
|
||||
def test_script_returns_empty_string_gives_none(self) -> None:
|
||||
self.mock_processor.process.return_value = ""
|
||||
result = apply_post_processing("hello", self._config("/s.sh"))
|
||||
self.assertIsNone(result)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the RemoteDesktop portal injection backend and its selection."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -722,6 +723,85 @@ class TestPortalInjectionPaths:
|
||||
assert injector._inject_shortcut_with_wayland_tool("Home+shift+End") is True
|
||||
assert mock_run.call_args[0][0] == ["wtype", "-M", "shift", "-k", "End", "-m", "shift"]
|
||||
|
||||
def test_partial_type_rebases_onto_portal_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A fallback's typed count must include what the portal delivered."""
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
# The portal typed 2 chars before the error; the fallback gets "x"*248.
|
||||
injector._portal.inject_text.side_effect = RemoteDesktopPortalError("boom", delivered=2)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
|
||||
calls = []
|
||||
|
||||
def wtype_side_effect(cmd: list, **kwargs: object) -> MagicMock:
|
||||
calls.append(cmd)
|
||||
if len(calls) == 2:
|
||||
raise subprocess.CalledProcessError(1, cmd, stderr="wedged")
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run", side_effect=wtype_side_effect),
|
||||
):
|
||||
with pytest.raises(ti._PartiallyTyped) as exc_info:
|
||||
injector._inject_with_wayland_tool("x" * 250)
|
||||
# The error count is against the original 250-char text, so the
|
||||
# caller clips the typed prefix instead of replaying it.
|
||||
assert exc_info.value.typed == 2 + 200
|
||||
|
||||
def test_inject_untracked_timeout_replays_nothing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A portal timeout with no delivered count must not retype anything."""
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
injector._portal.inject_text.side_effect = RemoteDesktopPortalError(
|
||||
"Timed out waiting for the portal", delivered=None
|
||||
)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
injector._inject_with_wayland_tool("hello")
|
||||
# The broken portal is demoted, but no fallback retypes the text.
|
||||
assert injector.wayland_tool == "wtype"
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_backspace_untracked_timeout_replays_nothing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
injector._portal.tap_keysym.side_effect = RemoteDesktopPortalError(
|
||||
"Timed out waiting for the portal", delivered=None
|
||||
)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
assert injector.press_backspace(3) is False
|
||||
assert injector.wayland_tool == "wtype"
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_shortcut_untracked_timeout_replays_nothing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
injector._portal.send_shortcut.side_effect = RemoteDesktopPortalError(
|
||||
"Timed out waiting for the portal", delivered=None
|
||||
)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
assert injector._inject_shortcut_with_wayland_tool("ctrl+a") is False
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_shortcut_routes_to_portal(self) -> None:
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
|
||||
@@ -275,6 +275,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=mock.ANY,
|
||||
env=mock.ANY,
|
||||
)
|
||||
|
||||
@@ -289,6 +290,8 @@ class TestTextInjector(unittest.TestCase):
|
||||
# Initialize injector
|
||||
injector = TextInjector()
|
||||
self.assertEqual(injector.wayland_tool, "ydotool")
|
||||
# _ensure_ydotoold spawns a real Popen, which patch("subprocess.run") misses.
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
|
||||
# Inject text
|
||||
injector.inject_text("Hello world")
|
||||
@@ -300,6 +303,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=mock.ANY,
|
||||
env=mock.ANY,
|
||||
)
|
||||
|
||||
@@ -566,7 +570,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
"""Test that inject_text returns False on failure."""
|
||||
injector = TextInjector()
|
||||
|
||||
self.mock_subprocess.side_effect = Exception("Injection failed")
|
||||
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, "xdotool")
|
||||
|
||||
result = injector.inject_text("Test")
|
||||
self.assertFalse(result)
|
||||
@@ -647,6 +651,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
injector.environment = DesktopEnvironment.WAYLAND
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
|
||||
# Reset call list so init calls don't interfere
|
||||
mock_run.reset_mock()
|
||||
@@ -686,6 +691,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
injector.environment = DesktopEnvironment.WAYLAND
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
|
||||
mock_run.reset_mock()
|
||||
injector._inject_with_wayland_tool("Hello world")
|
||||
@@ -1402,7 +1408,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
|
||||
# Make xdotool fail to trigger error path
|
||||
self.mock_subprocess.side_effect = Exception("Test error")
|
||||
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, "xdotool")
|
||||
|
||||
# Mock the audio import to fail
|
||||
with patch.dict("sys.modules", {"vocalinux.ui.audio_feedback": None}):
|
||||
|
||||
@@ -163,6 +163,23 @@ class TestTranscriptionHistory(unittest.TestCase):
|
||||
history.extend_latest("b")
|
||||
self.assertEqual(calls, [1])
|
||||
|
||||
def test_extend_entry_targets_by_id(self) -> None:
|
||||
"""A late segment lands in its own session's entry even when a newer
|
||||
session has committed a snippet on top of it."""
|
||||
history = TranscriptionHistory()
|
||||
first_id = history.add("session one")
|
||||
history.add("session two")
|
||||
self.assertTrue(history.extend_entry(first_id, "tail"))
|
||||
self.assertEqual(history.get_all(), ["session two", "session one tail"])
|
||||
|
||||
def test_extend_entry_unknown_id_refuses(self) -> None:
|
||||
"""An evicted or never-recorded id is refused so the caller falls
|
||||
back to recording the late text as its own snippet."""
|
||||
history = TranscriptionHistory()
|
||||
history.add("session one")
|
||||
self.assertFalse(history.extend_entry(9999, "tail"))
|
||||
self.assertEqual(history.get_all(), ["session one"])
|
||||
|
||||
def test_clear(self) -> None:
|
||||
history = TranscriptionHistory()
|
||||
history.add("a")
|
||||
|
||||
Reference in New Issue
Block a user