feat(text-injection): let config.json pin the injection backend (#649)

* feat(text-injection): let config.json pin the injection backend (#476)

Autodetection has to infer whether IBus commits actually reach the
focused application, and it cannot verify that: commit_text() reports
success even when the compositor drops the text. When the inference is
wrong the failure is silent, and dictation appears to work in XWayland
windows while doing nothing in native ones.

VOCALINUX_FORCE_BACKEND already provides an escape hatch, but it is an
environment variable: it covers a single run, is undocumented outside
the test suite, and a user whose compositor is misdetected has to wrap
the launcher in a shell script to make the choice stick.

Add a persistent `text_injection.backend` setting alongside it, accepting
the same ibus/wtype/ydotool/auto values. The environment variable still
wins so a backend can be A/B-tested for one run without touching the
saved configuration, and the startup log now names which of the two
pinned the choice so a stale setting is diagnosable from --debug output.

Reads the file directly rather than through ConfigManager, keeping this
package independent of the UI layer, exactly as _should_copy_to_clipboard
already does. An unreadable or corrupt config falls back to autodetection
instead of blocking startup, and an unrecognised value is ignored with a
warning so a typo cannot silently pin the wrong backend.

Defaults to "auto", so existing installs behave exactly as before.

* fix(text-injection): tell an explicit VOCALINUX_FORCE_BACKEND=auto from unset

The variable is meant to override the saved text_injection.backend pin for a
single run, which is what makes the two paths A/B-testable without editing
config. Setting it to auto -- the value that means "autodetect this time" --
did not do that: the reader collapsed unset and an explicit auto into the same
"auto" return, so the caller read both as "nothing was forced" and fell through
to the saved pin. The pin won, and there was no way to ask for autodetection
for one run short of editing the config file the variable exists to avoid.

Read the variable as three states instead: unset, an explicit auto, or a
backend name. Only the unset state consults config, so an explicit auto now
short-circuits to autodetection.

An unrecognised value is deliberately treated as unset rather than as auto. A
typo means the user failed to override their preference, not that they asked to
discard it, so falling through to a valid saved pin is the kinder reading.

_forced_backend() keeps its signature and its collapsed return, so its existing
callers and tests are untouched; the new three-state reading lives alongside it
and is what the preference resolver consumes.

* feat(text-injection): accept an xdotool pin and warn when ibus overrides the denylist

Two gaps in the pinnable backend set.

xdotool was not accepted, even though on X11 it is the tool that actually does
the typing. Users were told to pin a *Wayland* backend to mean "not IBus", and
get xdotool as a side effect. Naming it directly is what they were reaching for.
On Wayland it needs no special handling: the value skips IBus like any other
non-ibus pin and falls through to the existing XWayland fallback, so no branch
is added to the selection chain.

The accepted values now live in one tuple, because four places listed them --
both readers and both "expected ..." messages -- and a fifth value would have
had to be added to all four. The two messages derive from the tuple, so they
cannot fall behind it.

Pinning ibus also silently bypasses the compositor denylist. That check exists
because a denylisted compositor makes IBus report a commit as delivered while
the text never arrives (#478, #485), so a pin is the one setting that can put a
user back into a failure with no symptom other than nothing happening. The pin
is still honoured -- an override that ignores you is worse than one that warns
-- but it now says so at warning level, names the compositor, and gives the
remedy. It stays quiet when the compositor genuinely bridges IBus, including a
denylisted one running the ibus-wayland bridge, and on X11 where the denylist
does not apply.

The check is evaluated once. The guard chain short-circuits on the pin, so a
pinned run never reaches the existing compositor branch, and this is its first
and only call -- worth knowing because the check can shell out to pgrep or
gdbus. A test asserts the call count so that stays true.

Documents xdotool in the value table and rewrites the X11 paragraph, which
described xdotool as the implicit result of pinning something else rather than
as a value you can name.

* fix(text-injection): say so when the pinned backend is not the one in use

A pin could be quietly ignored. Pinning a tool that is not installed fell
through to autodetection and typed with whatever it found, under a log line
that had already announced the pin was overriding autodetection. Pinning
xdotool on Wayland did nothing at all, since the selection chain reaches
ydotool and wtype first. Pinning ibus where IBus support is unavailable skipped
the whole IBus branch. In each case the user set a preference, saw no
complaint, and got something else.

These are one condition, not three: whatever ended up doing the typing is not
what was pinned. The check compares the pin against the backend read back from
final state, so a cause nobody has thought of yet is covered without another
branch. It runs at the end of construction rather than inside
_check_dependencies, which returns early on two paths and which the wtype probe
can still override afterwards. The reason clause is added only where a generic
check answers it -- the binary is absent, or IBus is unavailable -- and omitted
otherwise rather than guessing.

Reporting the source separately was letting the environment variable and the
config setting disagree. _check_dependencies resolved the backend, then
re-derived which source had won by asking again -- so the environment was
parsed twice per construction, and a malformed value was reported to the user
twice. Resolution now returns the backend and its source together, which fixes
the duplicate warning and means the source named in a log line is the one that
actually chose the backend. _backend_preference() keeps its signature and reads
the first element.

Two log lines also claimed VOCALINUX_FORCE_BACKEND regardless of where the pin
came from, so a config-set backend was reported as an environment variable the
user had never set. They now name the real source.

The new warning is a startup diagnostic, not a live invariant: a tool switched
later by _try_recover_from_fallback is not reported again. Noted in the
docstring so the boundary is deliberate.

* docs(user-guide): file the injection-backend section under Troubleshooting

The section sat under Customization, directly beneath GPU Acceleration --
where the paragraph immediately above it reads "To check which backend is
being used" about Vulkan and CUDA. The word means something else there, and
two adjacent sections using it for different things is a bad place to explain
either.

It also is not a customization option. It exists because a compositor can make
IBus report a commit as delivered while nothing arrives (#478, #485), and
someone hitting that reads Troubleshooting, not Customization. The section
already opens on the symptom, so it needs no rewriting to belong there.

Pure reorder: no wording changes, and the heading text is unchanged so any
existing link still resolves.

* test(text-injection): cover the pin paths that skip the selection chain

_check_dependencies had three uncovered branches, all reachable by pinning a
backend and none of them exercised: the two early returns that hand over to
IBus before a tool is chosen, and the daemonless-ydotool fallback a wtype pin
lands on when wtype is absent. The early returns matter most, since the pin
check runs at the end of construction precisely so those paths cannot skip
it, and nothing proved that.

Adds the ydotool counterpart to the missing-binary case, so a check keyed on
one tool name cannot pass by coincidence; an X11 test for the guide's claim
that any non-ibus pin turns the IBus path off, with a control asserting IBus
is constructed without a pin so the assertion is the pin's doing; and an
end-to-end explicit-auto case, which until now was only proven at the
resolver rather than through selection.

The pin log line said it was "overriding backend autodetection" before
anything had been checked. An unavailable pin falls through to autodetection,
so that line announced an outcome it could not know and then read as
contradicting the "was not applied" warning. It now states the request only.

_run_with_pin gains ydotoold_ready, defaulting to the previous behaviour so
existing callers are unchanged.

* fix(text-injection): report a config file the backend pin cannot be read from

A corrupt config.json disappeared into logger.debug. The setting is
hand-edited -- that is the workflow the user guide documents -- so a stray
comma is a likely way to reach this, and staying quiet left the user with
autodetection and the silent IBus miss the pin was set to avoid, with nothing
to tell that apart from the pin not working. Unknown values already warned;
an unusable file now does too, naming the file and the parse error.

The bare handler was also catching AttributeError by accident. Valid JSON
that is not an object -- a bare list, string, number or null -- reaches the
lookup and raises there rather than at parse time, and so does a
text_injection section that is not an object, where the top level is a
mapping and the section is not. Both are now shape checks rather than
exception handling, which is what lets the handler narrow to OSError and
ValueError without those five shapes escaping it.

Locating the file moves out of the try. It fixes the handler being unable to
name the path it failed on, and it is a deliberate boundary: config_dir()
resolving a path is not a failure this should paper over, so it is no longer
guarded here at all.

A missing config.json stays silent, which is the default install.

* test(text-injection): stop the ibus-pin tests depending on an installed ydotoold

The bridged-compositor and X11 cases asserted that a run logs no warnings at
all. That is broader than what they are about, and _run_ibus_pin patches
shutil.which to claim every tool exists without patching the probe that then
shells out to one, so the real _ensure_ydotoold ran and warned whenever
/usr/bin/ydotoold was absent. The tests passed on a machine that happens to
have it installed and failed on the CI runners that do not.

The assertions now name the warning they are about, and the helper patches
_ensure_ydotoold the way its sibling _run_with_pin already did, so nothing in
this class of test reads the host's installed packages.

* fix(text-injection): report pending IBus initialization state

* docs(text-injection): clarify backend pin behavior

* fix(config): preserve a hand-edited backend across unrelated saves

ConfigManager writes its full in-memory configuration on every save. A user who edits text_injection.backend in config.json while Vocalinux is running therefore loses that choice when an unrelated Settings or tray save writes the stale default back.

Remember only the backend value observed at load or after a successful save. If the in-memory value is unchanged, preserve the current value from disk; if it changed through ConfigManager, treat that as the intentional write. Missing and explicit auto both reset to auto.

The save aborts rather than overwriting a file whose JSON or text_injection shape cannot be read safely. This is deliberately per-key: no recursive configuration merge or injection-layer dependency is added.

* style(text-injection): remove redundant typing suppression

* fix(config): keep invalid UTF-8 config from blocking startup

* fix(config): preserve malformed backend sections on unrelated saves

* fix(config): retain text setting changes after malformed config

* test(text-injection): annotate backend pin test helpers
This commit is contained in:
Hashing
2026-09-29 12:24:59 -07:00
committed by GitHub
parent be77ea40f4
commit 12e440ce1c
6 changed files with 1512 additions and 24 deletions
+57
View File
@@ -160,3 +160,60 @@ vocalinux --debug
```
See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for tray, audio, injection, and model issues. Distro notes: [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md). Updates: [UPDATE.md](UPDATE.md). Help channels: [SUPPORT.md](../SUPPORT.md).
### Text injection backend
Vocalinux types your dictated text using one of several backends. It picks one
automatically, and on most desktops the automatic choice is correct.
Autodetection can be wrong, though, and it fails in a way that is easy to
misread: on a compositor that does not relay IBus commits to native Wayland
applications, IBus reports the text as delivered while nothing appears. If
dictation works in some windows (typically XWayland ones, like a browser) but
silently does nothing in others, that is the symptom.
Pin the backend explicitly in `~/.config/vocalinux/config.json`:
```json
{
"text_injection": {
"backend": "wtype"
}
}
```
| Value | Backend |
|---|---|
| `auto` | Autodetect (default; autodetection may select IBus) |
| `ibus` | IBus input method; on Wayland, bypasses compositor checks and may silently do nothing in native Wayland apps |
| `wtype` | wtype virtual keyboard (Wayland) |
| `ydotool` | ydotool uinput (Wayland; needs `ydotoold`) |
| `xdotool` | xdotool (X11). On Wayland it only turns IBus off -- the Wayland tool is still picked automatically |
The setting takes effect on the next start. `auto` leaves normal autodetection
in place and may select IBus. An explicit non-IBus pin (`wtype`, `ydotool`, or
`xdotool`) skips IBus selection.
On X11 the injection tool is `xdotool` regardless of which non-`ibus` value you
pin, so `xdotool` is the name to use there when IBus is unreliable in a
particular application. Pinning `ibus` keeps the IBus path; pinning anything
else turns it off.
To try a backend for a single run without changing the saved setting, set
`VOCALINUX_FORCE_BACKEND`, which overrides the config value. Set it to `auto`
to ignore a saved pin for that run:
```bash
VOCALINUX_FORCE_BACKEND=wtype vocalinux --debug
```
The setting and environment override are read at startup, so restart Vocalinux
after editing `config.json`. The startup log first records a backend pin request;
it does not prove the backend was available or that text reached the focused
application. Later logs identify a fallback when a pin was not applied.
If a pinned tool is unavailable, Vocalinux warns and continues with its normal
fallback selection. `ydotool` also needs a usable `/dev/uinput` and a working
`ydotoold` setup. `xdotool` types into X11/XWayland windows, not native Wayland
windows. A live test in the target application is still the final confirmation
that text is delivered.
+269 -17
View File
@@ -14,7 +14,7 @@ import subprocess
import threading
import time
from enum import Enum
from typing import Optional
from typing import Optional, Tuple
from ..utils.host_process import host_env
from ..utils.paths import config_dir
@@ -106,6 +106,7 @@ class TextInjector:
wayland_mode: Force Wayland compatibility mode
"""
self._ibus_injector: Optional[IBusTextInjector] = None
self._backend_pin: Tuple[str, Optional[str]] = ("auto", None)
self.environment = self._detect_environment()
self._session_environment = self.environment
self._ibus_ready = False
@@ -177,6 +178,11 @@ class TextInjector:
except Exception as e:
logger.error(f"XWayland fallback test failed: {e}")
# Last, so it sees the final selection: _check_dependencies() returns early
# on two paths, and the wtype probe above can still demote a pin that was
# honoured up to that point.
self._warn_if_pin_not_honoured(*self._backend_pin)
def stop(self) -> None:
"""
Clean up resources and restore previous state.
@@ -249,6 +255,16 @@ class TextInjector:
"weston",
)
# Backends a user may pin explicitly, via VOCALINUX_FORCE_BACKEND or the
# text_injection.backend setting. Named once so the two readers and the two
# "expected ..." messages cannot drift apart as values are added.
_SELECTABLE_BACKENDS = ("ibus", "wtype", "ydotool", "xdotool")
@staticmethod
def _accepted_backends_help() -> str:
"""The accepted pin values, for user-facing "expected ..." messages."""
return "/".join(TextInjector._SELECTABLE_BACKENDS) + "/auto"
def _kde_virtual_keyboard_enabled(self) -> bool:
"""Return True when KWin VirtualKeyboard / input method is enabled.
@@ -475,6 +491,38 @@ class TextInjector:
logger.warning("ydotoold did not become ready in time")
return False
@staticmethod
def _forced_backend_setting() -> Optional[str]:
"""``VOCALINUX_FORCE_BACKEND`` as three distinct states.
``None`` when the variable is not set, ``"auto"`` when it is explicitly
set to auto, otherwise the backend name. The distinction matters:
``auto`` is how a user asks for autodetection *this run* despite a saved
``text_injection.backend`` pin, which is the one-run A/B test this
variable exists for. Collapsing it into "nothing was set" would let the
saved pin win and make the variable useless for that.
Accepts ``ibus``, ``wtype``, ``ydotool``, ``xdotool`` or ``auto``.
"""
raw = os.environ.get("VOCALINUX_FORCE_BACKEND")
if raw is None:
return None
value = raw.strip().lower()
if not value:
return None
if value == "auto":
return "auto"
if value in TextInjector._SELECTABLE_BACKENDS:
return value
logger.warning(
"Ignoring unknown VOCALINUX_FORCE_BACKEND=%r (expected %s)",
value,
TextInjector._accepted_backends_help(),
)
# Deliberately unset rather than "auto": a typo in a shell variable should
# not discard a valid saved pin, only fail to override it.
return None
@staticmethod
def _uinput_usable() -> bool:
"""Return True when this process can open ``/dev/uinput`` for write."""
@@ -519,19 +567,15 @@ class TextInjector:
when the inference is wrong, and makes the two paths A/B-testable
without editing code.
Accepts ``ibus``, ``wtype``, ``ydotool`` or ``auto``. Anything else is
Accepts ``ibus``, ``wtype``, ``ydotool``, ``xdotool`` or ``auto``. Anything else is
ignored with a warning, so a typo cannot silently pin a backend.
This covers the environment variable only. ``_backend_preference()``
combines it with the persistent ``text_injection.backend`` setting, and
needs unset and an explicit ``auto`` told apart -- see
``_forced_backend_setting()``.
"""
value = os.environ.get("VOCALINUX_FORCE_BACKEND", "").strip().lower()
if not value or value == "auto":
return "auto"
if value in ("ibus", "wtype", "ydotool"):
return value
logger.warning(
"Ignoring unknown VOCALINUX_FORCE_BACKEND=%r (expected ibus/wtype/ydotool/auto)",
value,
)
return "auto"
return TextInjector._forced_backend_setting() or "auto"
def _resolve_wayland_key_tool(self) -> Optional[str]:
"""Pick (or reuse) the Wayland virtual-keyboard tool for key events.
@@ -566,12 +610,206 @@ class TextInjector:
logger.info(f"Using {tool} for Wayland key events")
return tool
@staticmethod
def _configured_backend() -> str:
"""Backend pinned via ``text_injection.backend`` in config.json, or ``"auto"``.
The environment variable is fine for a one-off experiment, but a user
whose compositor is autodetected wrongly needs the choice to survive a
restart without wrapping the launcher in a shell script (issue #476).
Read from disk rather than through ConfigManager to keep this package
independent of the UI layer, matching ``_should_copy_to_clipboard()``.
A file that cannot be used is reported at warning level, not debug. This
setting is hand-edited, so a stray comma is a likely way to reach it, and
the symptom of staying quiet is the silent IBus miss the pin was set to
avoid -- indistinguishable from the pin simply not working.
The two shape checks below are what keep the lookup total, so the
handler only has to cover reading and parsing. Locating the file is
deliberately outside the handler: ``config_dir()`` resolving a path is
not a failure this should paper over, and computing it inside the
``try`` only meant the handler could not name the file it failed on.
"""
import json
config_path = os.path.join(config_dir(), "config.json")
if not os.path.exists(config_path):
return "auto"
try:
with open(config_path, "r") as f:
config = json.load(f)
except (OSError, ValueError) as e:
# Corrupt or unreadable must not block startup, but must not pass
# unmentioned either: the user edited this file expecting an effect.
logger.warning(
"Ignoring text_injection.backend: could not read %s (%s). "
"Continuing with backend autodetection.",
config_path,
e,
)
return "auto"
if not isinstance(config, dict):
logger.warning(
"Ignoring text_injection.backend: %s is not a JSON object. "
"Continuing with backend autodetection.",
config_path,
)
return "auto"
section = config.get("text_injection")
if section is None:
return "auto"
if not isinstance(section, dict):
logger.warning(
"Ignoring text_injection.backend: the text_injection section of %s "
"is not a JSON object. Continuing with backend autodetection.",
config_path,
)
return "auto"
value = str(section.get("backend", "") or "").strip().lower()
if not value or value == "auto":
return "auto"
if value in TextInjector._SELECTABLE_BACKENDS:
return value
logger.warning(
"Ignoring unknown text_injection.backend=%r (expected %s)",
value,
TextInjector._accepted_backends_help(),
)
return "auto"
@staticmethod
def _resolve_backend_pin() -> Tuple[str, Optional[str]]:
"""The pinned backend and where it came from, resolved once.
Returns ``(backend, source)``. ``source`` names the setting that won, or
is ``None`` when nothing pinned anything. Both come from a single pass so
callers that report the source cannot disagree with the value actually
used -- and so the environment is parsed once per construction, which
also means a malformed value is reported once rather than per caller.
``VOCALINUX_FORCE_BACKEND`` wins so a backend can still be A/B-tested
for one run without editing (or permanently changing) the user's config.
That includes an explicit ``auto``, which asks for autodetection this run
and so must short-circuit here rather than fall through to the saved pin.
"""
env = TextInjector._forced_backend_setting()
if env is not None:
return env, "VOCALINUX_FORCE_BACKEND"
configured = TextInjector._configured_backend()
if configured != "auto":
return configured, "text_injection.backend"
return "auto", None
@staticmethod
def _backend_preference() -> str:
"""The backend to pin, from the environment or config.json."""
return TextInjector._resolve_backend_pin()[0]
@staticmethod
def _resolved_backend_from_state(
ibus_injector: Optional[IBusTextInjector],
environment: DesktopEnvironment,
wayland_tool: Optional[str],
) -> Optional[str]:
"""Resolve a backend from one consistent snapshot of injector state."""
if ibus_injector is not None and environment in (
DesktopEnvironment.X11_IBUS,
DesktopEnvironment.WAYLAND_IBUS,
):
return "ibus"
if environment in (
DesktopEnvironment.X11,
DesktopEnvironment.WAYLAND_XDOTOOL,
):
return "xdotool"
return wayland_tool
def _resolved_backend(self) -> Optional[str]:
"""The backend actually in effect, read back from final state.
Deliberately reads ``environment`` before ``wayland_tool``: the wtype
probe in ``__init__`` demotes a failed wtype to ``WAYLAND_XDOTOOL``
without clearing ``wayland_tool``, and ``inject_text()`` follows
``environment``. Reading the tool first would report wtype while
injection actually goes through XWayland.
"""
with self._state_lock:
ibus_injector = self._ibus_injector
environment = self.environment
wayland_tool = getattr(self, "wayland_tool", None)
return self._resolved_backend_from_state(ibus_injector, environment, wayland_tool)
def _warn_if_pin_not_honoured(self, pinned: str, source: Optional[str]) -> None:
"""Say so when the backend in use is not the one that was pinned.
One comparison rather than a check per cause: a missing binary, a value
that does not apply to this session type, IBus being unavailable and the
wtype probe demoting to XWayland all end the same way -- something other
than the pinned backend is doing the typing -- and a new cause is covered
without adding a branch here.
This is a startup diagnostic, not a live invariant. It runs once, at the
end of construction. ``_try_recover_from_fallback()`` can later switch
the tool when ydotoold appears mid-session, so a pin that becomes
honoured (or stops being honoured) after startup is not reported again.
"""
if not source or pinned == "auto":
return
with self._state_lock:
ibus_injector = self._ibus_injector
environment = self.environment
ibus_ready = self._ibus_ready
ibus_init_failed = self._ibus_init_failed
wayland_tool = getattr(self, "wayland_tool", None)
resolved = self._resolved_backend_from_state(ibus_injector, environment, wayland_tool)
if (
pinned == "ibus"
and ibus_injector is not None
and not ibus_ready
and not ibus_init_failed
and resolved != "ibus"
):
logger.info(
"%s=%s: IBus initialization is pending; backend selection is not final.",
source,
pinned,
)
return
# Unreachable today (every path either sets a backend or raises before
# construction finishes), but guarded so a future path cannot render
# "using None instead" at a user.
if resolved is None or resolved == pinned:
return
if pinned in self._SELECTABLE_BACKENDS and pinned != "ibus":
reason = f" ({pinned} is not installed)" if not shutil.which(pinned) else ""
elif pinned == "ibus" and not is_ibus_available():
reason = " (IBus support is not available)"
else:
reason = ""
logger.warning(
"%s=%s was not applied%s; using %s instead.", source, pinned, reason, resolved
)
def _check_dependencies(self):
"""Check for the required tools for text injection."""
ibus_requested = False
forced = self._forced_backend()
if forced != "auto":
logger.info("VOCALINUX_FORCE_BACKEND=%s: overriding backend autodetection", forced)
forced, pin_source = self._resolve_backend_pin()
self._backend_pin = (forced, pin_source)
if pin_source and forced != "auto":
# States the request, not the result. Nothing has been checked for
# availability yet, and an unavailable pin falls through to
# autodetection further down -- so a word like "overriding" here
# would describe an outcome this line cannot know, and would read as
# contradicting the "was not applied" warning when it does not hold.
logger.info("%s=%s: backend pin requested", pin_source, forced)
# Prefer IBus on both X11 and Wayland - it sends Unicode directly,
# bypassing keyboard layout issues entirely
@@ -629,6 +867,20 @@ class TextInjector:
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
)
else:
# force_ibus short-circuits all three guards above, so a pinned
# run arrives here without any of them having been evaluated.
# This warns for the compositor guard only: that is the case
# with a documented silent failure (#478, #485), where IBus
# reports the commit as delivered and the text never arrives.
# The other two guards are out of scope for this warning.
if force_ibus and not self._wayland_compositor_bridges_ibus():
logger.warning(
"Compositor '%s' does not bridge IBus to native Wayland apps, but an "
"explicit ibus pin overrides that check. Dictation may silently do "
"nothing in native Wayland windows; remove the pin to fall back to "
"wtype/ydotool.",
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
)
try:
if wayland_scoped_ibus and not ibus_active:
logger.info(
@@ -660,11 +912,11 @@ class TextInjector:
# ydotool for native Wayland typing; wtype needs a Wayland socket.
if forced == "wtype" and wtype_available:
self.wayland_tool = "wtype"
logger.info("VOCALINUX_FORCE_BACKEND=wtype: using wtype for Wayland injection")
logger.info("%s=wtype: using wtype for Wayland injection", pin_source)
elif forced == "ydotool" and ydotool_available:
self._ensure_ydotoold()
self.wayland_tool = "ydotool"
logger.info("VOCALINUX_FORCE_BACKEND=ydotool: using ydotool for Wayland injection")
logger.info("%s=ydotool: using ydotool for Wayland injection", pin_source)
elif ydotool_available and self._ensure_ydotoold():
self.wayland_tool = "ydotool"
logger.info("Using ydotool for Wayland text injection")
+99 -3
View File
@@ -49,6 +49,8 @@ PASTE_SHORTCUTS: tuple[tuple[str, str], ...] = (
)
PASTE_SHORTCUT_IDS = frozenset(shortcut_id for shortcut_id, _label in PASTE_SHORTCUTS)
DEFAULT_PASTE_SHORTCUT = "auto"
DEFAULT_TEXT_INJECTION_BACKEND = "auto"
_NO_PRESERVED_TEXT_INJECTION_SECTION = object()
def normalize_paste_shortcut(shortcut: Any) -> str:
@@ -65,6 +67,24 @@ def normalize_sound_effect_tone(tone: Any) -> str:
return DEFAULT_SOUND_EFFECT_TONE
def _backend_from_config(config: Any) -> tuple[bool, Any]:
"""Return whether ``config`` has a usable text-injection section and its backend.
A missing section/key means the documented default, ``"auto"``. A
non-object section is not loaded into the in-memory defaults; an unrelated
save preserves that value verbatim instead of discarding a hand edit.
"""
if not isinstance(config, dict):
return False, DEFAULT_TEXT_INJECTION_BACKEND
if "text_injection" not in config:
return True, DEFAULT_TEXT_INJECTION_BACKEND
text_injection = config["text_injection"]
if not isinstance(text_injection, dict):
return False, DEFAULT_TEXT_INJECTION_BACKEND
return True, text_injection.get("backend", DEFAULT_TEXT_INJECTION_BACKEND)
# Default configuration
DEFAULT_CONFIG = {
"speech_recognition": { # Changed section name
@@ -144,6 +164,10 @@ DEFAULT_CONFIG = {
# Clipboard-paste chord: auto-detect terminals, or force Ctrl+V /
# Ctrl+Shift+V when a nested terminal panel is not detected.
"paste_shortcut": "auto",
# Text-injection backend: "auto" autodetects, or pin "ibus"/"wtype"/
# "ydotool"/"xdotool" when autodetection is wrong (#476).
# VOCALINUX_FORCE_BACKEND overrides this for a single run.
"backend": "auto",
},
"advanced": {
"power_user_mode": False,
@@ -238,6 +262,10 @@ class ConfigManager:
def __init__(self):
"""Initialize the configuration manager."""
self.config = copy.deepcopy(DEFAULT_CONFIG)
# The effective backend at load/save time. It lets save_config tell an
# intentional in-process change from a hand edit made on disk later.
self._backend_snapshot: Any = DEFAULT_TEXT_INJECTION_BACKEND
self._text_injection_snapshot = copy.deepcopy(DEFAULT_CONFIG["text_injection"])
self._ensure_config_dir()
self.load_config()
@@ -286,11 +314,24 @@ class ConfigManager:
with open(CONFIG_FILE, "r") as f:
user_config = json.load(f)
valid_backend_shape, backend = _backend_from_config(user_config)
if not isinstance(user_config, dict):
logger.error("Failed to load config: expected a JSON object")
return
if not valid_backend_shape:
# Keep unrelated valid settings, but do not replace the default
# text-injection mapping with a hand-edited scalar or list.
logger.error("Failed to load text_injection: expected a JSON object")
user_config = copy.deepcopy(user_config)
user_config.pop("text_injection", None)
self._backend_snapshot = backend
# Check if migration is needed BEFORE merging with defaults
needs_migration = self._check_needs_migration(user_config)
# Update the default config with user settings
self._update_dict_recursive(self.config, user_config)
self._text_injection_snapshot = copy.deepcopy(self.config["text_injection"])
logger.info(f"Loaded configuration from {CONFIG_FILE}")
# Migrate old config format if needed
@@ -299,9 +340,33 @@ class ConfigManager:
self._migrate_shortcuts_config(user_config)
except (json.JSONDecodeError, OSError) as e:
except (OSError, ValueError) as e:
logger.error(f"Failed to load config: {e}")
def _backend_on_disk(self) -> tuple[bool, Any, Any]:
"""Read only backend state needed to protect a hand edit during save."""
if not os.path.exists(CONFIG_FILE):
return True, DEFAULT_TEXT_INJECTION_BACKEND, _NO_PRESERVED_TEXT_INJECTION_SECTION
try:
with open(CONFIG_FILE, "r") as f:
disk_config = json.load(f)
except (OSError, ValueError) as e:
logger.error(f"Failed to save config: could not read existing config: {e}")
return False, DEFAULT_TEXT_INJECTION_BACKEND, _NO_PRESERVED_TEXT_INJECTION_SECTION
if not isinstance(disk_config, dict):
logger.error("Failed to save config: existing config is not a JSON object")
return False, DEFAULT_TEXT_INJECTION_BACKEND, _NO_PRESERVED_TEXT_INJECTION_SECTION
valid_backend_shape, backend = _backend_from_config(disk_config)
if not valid_backend_shape:
# A valid top-level config can still save unrelated settings. Keep
# the malformed section verbatim unless this process intentionally
# changed its backend, in which case it is explicitly repairing it.
return True, DEFAULT_TEXT_INJECTION_BACKEND, disk_config["text_injection"]
return True, backend, _NO_PRESERVED_TEXT_INJECTION_SECTION
def _check_needs_migration(self, user_config: dict) -> bool:
"""Check if the user config needs migration to add per-engine model sizes."""
sr_config = user_config.get("speech_recognition", {})
@@ -384,12 +449,43 @@ class ConfigManager:
self.save_config()
def save_config(self):
"""Save the current configuration to the config file."""
"""Save the current configuration, preserving an unchanged hand-edited backend."""
try:
# Ensure directory exists before writing
self._ensure_config_dir()
config_to_save = copy.deepcopy(self.config)
text_injection = self.config.get("text_injection")
backend_written = False
if isinstance(text_injection, dict):
disk_ok, disk_backend, preserved_section = self._backend_on_disk()
if not disk_ok:
return False
in_memory_backend = text_injection.get("backend", DEFAULT_TEXT_INJECTION_BACKEND)
backend_snapshot = getattr(
self, "_backend_snapshot", DEFAULT_TEXT_INJECTION_BACKEND
)
backend_changed = in_memory_backend != backend_snapshot
text_injection_changed = text_injection != self._text_injection_snapshot
if (
preserved_section is not _NO_PRESERVED_TEXT_INJECTION_SECTION
and not text_injection_changed
):
config_to_save["text_injection"] = copy.deepcopy(preserved_section)
else:
backend_to_save = in_memory_backend if backend_changed else disk_backend
# Do not mutate live state before json.dump succeeds. A failed
# write must not make an external edit look like our saved state.
config_to_save["text_injection"]["backend"] = backend_to_save
backend_written = True
with open(CONFIG_FILE, "w") as f:
json.dump(self.config, f, indent=4)
json.dump(config_to_save, f, indent=4)
if isinstance(text_injection, dict) and backend_written:
text_injection["backend"] = backend_to_save
self._backend_snapshot = backend_to_save
if isinstance(text_injection, dict):
self._text_injection_snapshot = copy.deepcopy(text_injection)
logger.info(f"Saved configuration to {CONFIG_FILE}")
return True
+184
View File
@@ -7,6 +7,7 @@ import os
import shutil
import tempfile
import unittest
from typing import Any
from unittest.mock import patch
import pytest
@@ -143,6 +144,16 @@ class TestConfigManager(unittest.TestCase):
# Verify logger.error was called for the broken JSON
self.mock_logger.error.assert_called()
def test_load_config_invalid_utf8_uses_defaults(self):
"""A config saved with invalid bytes must not prevent application startup."""
with open(self.temp_config_file, "wb") as handle:
handle.write(b"\xff\xfe{")
config_manager = ConfigManager()
self.assertEqual(config_manager.config, DEFAULT_CONFIG)
self.mock_logger.error.assert_called()
def test_save_config(self):
"""Test saving configuration to file."""
config_manager = ConfigManager()
@@ -173,6 +184,179 @@ class TestConfigManager(unittest.TestCase):
self.assertFalse(result)
self.mock_logger.error.assert_called()
def _write_raw_config(self, content: str) -> None:
with open(self.temp_config_file, "w") as handle:
handle.write(content)
def _write_config(self, config: Any) -> None:
with open(self.temp_config_file, "w") as handle:
json.dump(config, handle)
def _read_config(self) -> dict[str, Any]:
with open(self.temp_config_file) as handle:
return json.load(handle)
def test_unrelated_save_preserves_first_external_backend_edit(self):
"""A documented hand-edit must survive a Settings-style save."""
config_manager = ConfigManager()
self._write_config({"text_injection": {"backend": "wtype"}})
config_manager.set("general", "autostart", True)
self.assertTrue(config_manager.save_config())
saved = self._read_config()
self.assertEqual(saved["text_injection"]["backend"], "wtype")
self.assertTrue(saved["general"]["autostart"])
def test_second_external_backend_edit_survives_repeated_saves(self):
"""Remembering the first edit must not make the second one stale again."""
self._write_config({"text_injection": {"backend": "wtype"}})
config_manager = ConfigManager()
self._write_config({"text_injection": {"backend": "ydotool"}})
config_manager.set("general", "autostart", True)
self.assertTrue(config_manager.save_config())
self.assertEqual(self._read_config()["text_injection"]["backend"], "ydotool")
self._write_config({"text_injection": {"backend": "xdotool"}})
config_manager.set("ui", "start_minimized", True)
self.assertTrue(config_manager.save_config())
saved = self._read_config()
self.assertEqual(saved["text_injection"]["backend"], "xdotool")
self.assertTrue(saved["ui"]["start_minimized"])
def test_programmatic_backend_change_wins_over_external_edit(self):
"""An intentional in-process backend change is not discarded as stale."""
self._write_config({"text_injection": {"backend": "wtype"}})
config_manager = ConfigManager()
self._write_config({"text_injection": {"backend": "ydotool"}})
config_manager.set("text_injection", "backend", "xdotool")
self.assertTrue(config_manager.save_config())
self.assertEqual(self._read_config()["text_injection"]["backend"], "xdotool")
def test_external_auto_or_key_removal_resets_backend(self):
"""Both documented reset forms mean auto after an unrelated save."""
self._write_config({"text_injection": {"backend": "wtype"}})
config_manager = ConfigManager()
for external in (
{"text_injection": {"backend": "auto"}},
{"text_injection": {}},
None,
):
with self.subTest(external=external):
if external is None:
os.remove(self.temp_config_file)
else:
self._write_config(external)
config_manager.set("general", "autostart", True)
self.assertTrue(config_manager.save_config())
self.assertEqual(self._read_config()["text_injection"]["backend"], "auto")
def test_save_does_not_overwrite_unreadable_or_invalid_external_config(self):
"""A Settings toggle must not repair a hand-edited file by deleting it."""
config_manager = ConfigManager()
for raw in ("{broken json", "[]"):
with self.subTest(raw=raw):
self._write_raw_config(raw)
config_manager.set("general", "autostart", True)
self.assertFalse(config_manager.save_config())
with open(self.temp_config_file) as handle:
self.assertEqual(handle.read(), raw)
def test_unrelated_save_preserves_malformed_text_injection_section(self):
"""A bad backend section must not prevent unrelated Settings changes."""
for malformed_section in ([], "wtype", None):
with self.subTest(section=malformed_section):
self._write_config({"text_injection": malformed_section})
config_manager = ConfigManager()
self.assertEqual(config_manager.get("text_injection", "backend"), "auto")
self.assertTrue(config_manager.get_bool("text_injection", "auto_capitalize"))
config_manager.set("general", "autostart", True)
self.assertTrue(config_manager.save_config())
saved = self._read_config()
self.assertEqual(saved["text_injection"], malformed_section)
self.assertTrue(saved["general"]["autostart"])
def test_programmatic_backend_change_repairs_malformed_text_injection_section(self):
"""An intentional backend change can replace a section that cannot hold one."""
self._write_config({"text_injection": []})
config_manager = ConfigManager()
config_manager.set("text_injection", "backend", "wtype")
self.assertTrue(config_manager.save_config())
self.assertEqual(self._read_config()["text_injection"]["backend"], "wtype")
def test_text_injection_change_repairs_malformed_section(self):
"""A Settings change in that section must not disappear after a successful save."""
for key, value in (
("auto_capitalize", False),
("append_trailing_space", False),
("paste_shortcut", "ctrl+shift+v"),
):
with self.subTest(key=key):
self._write_config({"text_injection": []})
config_manager = ConfigManager()
config_manager.set("text_injection", key, value)
self.assertTrue(config_manager.save_config())
self.assertEqual(self._read_config()["text_injection"][key], value)
def test_failed_write_does_not_advance_text_injection_snapshot(self):
"""A later save must still persist a text-setting change after a failed write."""
config_manager = ConfigManager()
config_manager.set("text_injection", "auto_capitalize", False)
with patch("vocalinux.ui.config_manager.json.dump", side_effect=OSError("disk full")):
self.assertFalse(config_manager.save_config())
self._write_config({"text_injection": []})
self.assertTrue(config_manager.save_config())
self.assertFalse(self._read_config()["text_injection"]["auto_capitalize"])
def test_save_does_not_overwrite_invalid_utf8_external_config(self):
"""A decoding failure is just as unsafe to replace as malformed JSON."""
config_manager = ConfigManager()
raw = b"\xff\xfe{"
with open(self.temp_config_file, "wb") as handle:
handle.write(raw)
config_manager.set("general", "autostart", True)
self.assertFalse(config_manager.save_config())
with open(self.temp_config_file, "rb") as handle:
self.assertEqual(handle.read(), raw)
def test_failed_write_does_not_advance_backend_snapshot(self):
"""A failed save cannot make a later external edit look stale."""
config_manager = ConfigManager()
self._write_config({"text_injection": {"backend": "wtype"}})
with patch("vocalinux.ui.config_manager.json.dump", side_effect=OSError("disk full")):
self.assertFalse(config_manager.save_config())
self.assertEqual(config_manager.get("text_injection", "backend"), "auto")
self._write_config({"text_injection": {"backend": "ydotool"}})
config_manager.set("general", "autostart", True)
self.assertTrue(config_manager.save_config())
self.assertEqual(self._read_config()["text_injection"]["backend"], "ydotool")
def test_invalid_text_injection_shapes_do_not_break_loading(self):
"""A hand-edited non-object section leaves text-injection defaults usable."""
for config in ([], {"text_injection": []}, {"text_injection": "wtype"}):
with self.subTest(config=config):
self._write_config(config)
config_manager = ConfigManager()
self.assertEqual(config_manager.get("text_injection", "backend"), "auto")
self.assertTrue(config_manager.get_bool("text_injection", "auto_capitalize"))
def test_get_existing_value(self):
"""Test getting an existing configuration value from defaults."""
# Test that DEFAULT_CONFIG constant has the expected default engine
+223 -2
View File
@@ -2,13 +2,17 @@
Tests for text injection functionality.
"""
import contextlib
import json
import os
import subprocess
import sys
import threading
import unittest
from collections.abc import Iterator
from typing import Any
from unittest import mock
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, mock_open, patch
# Update import path to use the new package structure
from vocalinux.text_injection.text_injector import (
@@ -2856,6 +2860,223 @@ class TestCompositorIBusBridging(unittest.TestCase):
self.assertEqual(injector.wayland_tool, "ydotool")
@contextlib.contextmanager
def _fake_config(config: Any) -> Iterator[None]:
"""Pretend config.json holds ``config``; ``None`` means no file at all.
Kept off the filesystem on purpose: another suite patches
``tempfile.mkdtemp`` globally, so a real temp dir makes these tests
order-dependent.
"""
with patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"):
if config is None:
with patch("os.path.exists", return_value=False):
yield
return
payload = config if isinstance(config, str) else json.dumps(config)
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data=payload)):
yield
class TestConfiguredBackend(unittest.TestCase):
"""text_injection.backend in config.json pins the injection backend (#476)."""
def test_recognised_backends(self):
for value in ("ibus", "wtype", "ydotool", "xdotool"):
with _fake_config({"text_injection": {"backend": value}}):
self.assertEqual(TextInjector._configured_backend(), value)
def test_value_is_normalised(self):
with _fake_config({"text_injection": {"backend": " WType "}}):
self.assertEqual(TextInjector._configured_backend(), "wtype")
def test_auto_or_missing_means_auto(self):
for config in (
{"text_injection": {"backend": "auto"}},
{"text_injection": {"backend": ""}},
{"text_injection": {}},
{},
None, # no config.json at all
):
with _fake_config(config):
self.assertEqual(TextInjector._configured_backend(), "auto")
def test_unknown_value_falls_back_to_auto(self):
"""A typo must not silently pin the wrong backend."""
with _fake_config({"text_injection": {"backend": "wtpye"}}):
self.assertEqual(TextInjector._configured_backend(), "auto")
def test_corrupt_config_does_not_raise(self):
"""An unreadable config must not stop text injection from starting.
Only that it survives; that it also says so is
test_corrupt_config_warns_that_the_pin_is_ignored.
"""
with _fake_config("{not valid json"):
self.assertEqual(TextInjector._configured_backend(), "auto")
def _warnings(self, logs: Any) -> list[str]:
return [line for line in logs.output if line.startswith("WARNING")]
def test_corrupt_config_warns_that_the_pin_is_ignored(self):
"""A stray comma is the likely way to break a file people hand-edit.
Staying quiet leaves the user with autodetection and the silent IBus
miss they set the pin to avoid, with nothing to tell the two apart.
"""
with _fake_config("{not valid json"):
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
self.assertEqual(TextInjector._configured_backend(), "auto")
warned = self._warnings(logs)
self.assertTrue(warned, f"corrupt config was not reported: {logs.output}")
self.assertIn("text_injection.backend", warned[0])
def test_config_that_is_not_an_object_warns(self):
"""Valid JSON that is not an object reaches the lookup as a non-mapping.
Guarded rather than caught: the shape check is what lets the handler
stay narrow instead of swallowing every AttributeError raised below it.
"""
with _fake_config("[1, 2, 3]"):
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
self.assertEqual(TextInjector._configured_backend(), "auto")
warned = self._warnings(logs)
self.assertTrue(warned, f"non-object config was not reported: {logs.output}")
self.assertIn("not a JSON object", warned[0])
def test_text_injection_section_that_is_not_an_object_warns(self):
"""The top level being a mapping is not enough; the section can still not be.
Contrast with test_config_that_is_not_an_object_warns: a single
top-level check passes this input and then fails on the lookup.
"""
with _fake_config({"text_injection": [1, 2]}):
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
self.assertEqual(TextInjector._configured_backend(), "auto")
warned = self._warnings(logs)
self.assertTrue(warned, f"non-object section was not reported: {logs.output}")
self.assertIn("text_injection section", warned[0])
def test_absent_config_is_not_reported(self):
"""No config.json is the default install, not a problem to warn about."""
for config in (None, {}, {"text_injection": {}}):
with self.subTest(config=config):
# A mocked logger rather than assertLogs: the assertion is that
# nothing was logged, and assertLogs fails when nothing is.
with _fake_config(config):
with patch("vocalinux.text_injection.text_injector.logger") as mock_logger:
self.assertEqual(TextInjector._configured_backend(), "auto")
mock_logger.warning.assert_not_called()
class TestBackendPreference(unittest.TestCase):
"""The environment variable wins over the persisted setting."""
def test_environment_overrides_config(self):
"""A one-off experiment must not require editing the user's config."""
with _fake_config({"text_injection": {"backend": "ibus"}}):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "wtype"}):
self.assertEqual(TextInjector._backend_preference(), "wtype")
def test_config_used_when_environment_unset(self):
with _fake_config({"text_injection": {"backend": "ydotool"}}):
with patch.dict("os.environ", {}, clear=True):
self.assertEqual(TextInjector._backend_preference(), "ydotool")
def test_auto_when_neither_is_set(self):
with _fake_config(None):
with patch.dict("os.environ", {}, clear=True):
self.assertEqual(TextInjector._backend_preference(), "auto")
def test_explicit_auto_overrides_a_saved_pin(self):
"""``VOCALINUX_FORCE_BACKEND=auto`` asks for autodetection *this run*.
It must not be read as "nothing was set" and fall through to the saved
pin, or the variable cannot undo a pin for a single run -- which is the
one-off A/B test it exists for.
"""
with _fake_config({"text_injection": {"backend": "wtype"}}):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "auto"}):
self.assertEqual(TextInjector._backend_preference(), "auto")
def test_unset_environment_uses_the_saved_pin(self):
"""Regression guard for the case explicit ``auto`` must NOT behave like."""
with _fake_config({"text_injection": {"backend": "wtype"}}):
with patch.dict("os.environ", {}, clear=True):
self.assertEqual(TextInjector._backend_preference(), "wtype")
def test_typo_in_environment_is_treated_as_unset_not_as_auto(self):
"""A typo falls through to the saved pin rather than discarding it.
Deliberate: an unrecognised value means the user failed to override
their preference, not that they asked for autodetection. Contrast
``test_explicit_auto_overrides_a_saved_pin``, where they did ask.
"""
with _fake_config({"text_injection": {"backend": "wtype"}}):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "wtpye"}):
self.assertEqual(TextInjector._backend_preference(), "wtype")
class TestSelectableBackends(unittest.TestCase):
"""xdotool is a pinnable value, and both readers accept the same set."""
def test_xdotool_is_accepted_from_the_environment(self):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "xdotool"}):
self.assertEqual(TextInjector._forced_backend_setting(), "xdotool")
def test_xdotool_is_accepted_from_config(self):
with _fake_config({"text_injection": {"backend": "xdotool"}}):
self.assertEqual(TextInjector._configured_backend(), "xdotool")
def test_both_readers_accept_the_same_values(self):
"""A value valid in one source must be valid in the other."""
for value in TextInjector._SELECTABLE_BACKENDS:
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}):
self.assertEqual(TextInjector._forced_backend_setting(), value)
with _fake_config({"text_injection": {"backend": value}}):
self.assertEqual(TextInjector._configured_backend(), value)
def test_help_text_lists_every_selectable_value(self):
"""Guards the 'expected ...' messages against drifting from the tuple."""
help_text = TextInjector._accepted_backends_help()
for value in TextInjector._SELECTABLE_BACKENDS:
self.assertIn(value, help_text)
self.assertIn("auto", help_text)
class TestForcedBackendSetting(unittest.TestCase):
"""The raw three-state reading of VOCALINUX_FORCE_BACKEND.
``_forced_backend()`` collapses unset and explicit ``auto`` together, which
is fine for its own callers but loses the distinction ``_backend_preference()``
needs to tell "no opinion" from "autodetect this run".
"""
def test_unset_is_none_and_is_distinct_from_explicit_auto(self):
with patch.dict("os.environ", {}, clear=True):
self.assertIsNone(TextInjector._forced_backend_setting())
def test_explicit_auto_is_the_string_not_none(self):
for value in ("auto", " AUTO "):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}):
self.assertEqual(TextInjector._forced_backend_setting(), "auto")
def test_empty_value_counts_as_unset(self):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": " "}):
self.assertIsNone(TextInjector._forced_backend_setting())
def test_recognised_backends_are_returned(self):
for value in ("ibus", "wtype", "ydotool", "xdotool"):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}):
self.assertEqual(TextInjector._forced_backend_setting(), value)
def test_unknown_value_is_unset_rather_than_auto(self):
"""See ``test_typo_in_environment_is_treated_as_unset_not_as_auto``."""
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "ibsu"}):
self.assertIsNone(TextInjector._forced_backend_setting())
class TestForcedBackend(unittest.TestCase):
"""VOCALINUX_FORCE_BACKEND overrides backend autodetection."""
@@ -2865,7 +3086,7 @@ class TestForcedBackend(unittest.TestCase):
self.assertEqual(TextInjector._forced_backend(), "auto")
def test_recognised_backends(self):
for value in ("ibus", "wtype", "ydotool"):
for value in ("ibus", "wtype", "ydotool", "xdotool"):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}):
self.assertEqual(TextInjector._forced_backend(), value)
+680 -2
View File
@@ -1,13 +1,14 @@
"""Extra tests for text_injector.py to improve branch coverage."""
import json
import os
import subprocess
import sys
import threading
import unittest
from typing import Any, cast
from typing import Any, Optional, cast
from unittest import mock
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, mock_open, patch
import pytest
@@ -367,6 +368,500 @@ class TestCheckDependencies(unittest.TestCase):
self.assertEqual(obj.wayland_tool, "wtype")
mock_ibus_class.assert_not_called()
def test_config_backend_wtype_skips_ibus(self):
"""text_injection.backend=wtype pins wtype with no environment variable set.
The environment variable is a one-off; this is the persisted setting from
issue #476, so it has to survive a restart on its own.
"""
import json as _json
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.WAYLAND)
config = _json.dumps({"text_injection": {"backend": "wtype"}})
with (
patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True),
patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"),
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=config)),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class,
patch(
"shutil.which",
side_effect=lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None,
),
):
obj._check_dependencies()
self.assertEqual(obj.wayland_tool, "wtype")
mock_ibus_class.assert_not_called()
def test_x11_config_pin_never_constructs_ibus(self):
"""On X11 the user guide promises any non-ibus pin turns the IBus path off.
The X11 reporter on #476 wanted exactly that. The pin does not choose the
tool there -- xdotool always does the typing -- so the only thing the pin
changes on X11 is whether IBus runs, and that is what this asserts.
"""
import json as _json
from vocalinux.text_injection.text_injector import DesktopEnvironment
def run(pinned: Optional[str]) -> MagicMock:
"""Drive _check_dependencies on X11 under conditions IBus would pass."""
obj = _make_injector(DesktopEnvironment.X11)
section = {"backend": pinned} if pinned else {}
config = _json.dumps({"text_injection": section})
with (
patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True),
patch(
"vocalinux.text_injection.text_injector.config_dir",
return_value="/fake/config",
),
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=config)),
patch(
"vocalinux.text_injection.text_injector.is_ibus_available", return_value=True
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=True,
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class,
patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"),
):
obj._check_dependencies()
return mock_ibus_class
# Control: with no pin these same conditions do construct IBus, so a
# not-called assertion below is the pin's doing and not the environment's.
self.assertTrue(run(None).called, "control failed: IBus was not constructed without a pin")
for pinned in ("xdotool", "wtype"):
with self.subTest(backend=pinned):
run(pinned).assert_not_called()
def test_explicit_auto_environment_ignores_the_saved_config_pin(self):
"""VOCALINUX_FORCE_BACKEND=auto is how you get one run of plain autodetection.
The resolver already knows an explicit auto from an unset variable; this
drives the whole of _check_dependencies to show the saved pin really is
not consulted, rather than only that the resolver returned "auto".
"""
import json as _json
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
obj._backend_pin = ("auto", None)
config = _json.dumps({"text_injection": {"backend": "wtype"}})
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "auto"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"),
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=config)),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False),
patch.object(TextInjector, "_ensure_ydotoold", lambda s: True),
patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj._check_dependencies()
obj._warn_if_pin_not_honoured(*obj._backend_pin)
# Autodetection prefers ydotool when its daemon is ready; the saved wtype
# pin would have won had the explicit auto not discarded it.
self.assertEqual(obj.wayland_tool, "ydotool")
self.assertFalse(
[line for line in logs.output if "backend pin requested" in line],
f"an explicit auto still reported a pin: {logs.output}",
)
self.assertFalse(self._not_applied(logs.output))
def _run_ibus_pin(
self,
bridges: bool,
desktop: str = "Hyprland",
env: Any = None,
config_pin: bool = False,
) -> tuple[list[str], int]:
"""Drive _check_dependencies with an ibus pin; return (logs, bridge_call_count)."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(env or DesktopEnvironment.WAYLAND)
calls = []
def fake_bridges(self):
calls.append(1)
return bridges
environ = {
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": desktop,
}
if not config_pin:
environ["VOCALINUX_FORCE_BACKEND"] = "ibus"
with (
patch.dict(
os.environ,
environ,
clear=True,
),
patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"),
patch("os.path.exists", return_value=True),
patch(
"builtins.open",
mock_open(read_data=json.dumps({"text_injection": {"backend": "ibus"}})),
),
patch.object(TextInjector, "_wayland_compositor_bridges_ibus", fake_bridges),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector"),
patch.object(TextInjector, "_start_ibus_initialization", lambda s: None),
# shutil.which is patched to claim every tool exists, so without this
# the real probe shells out to a ydotoold that may not be installed
# and logs a warning that has nothing to do with the pin.
patch.object(TextInjector, "_ensure_ydotoold", lambda s: True),
patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj._check_dependencies()
return logs.output, len(calls)
@staticmethod
def _silent_failure_warnings(output):
"""Only the denylist-bypass warning, not every warning the run emits."""
return [line for line in output if "may silently do nothing" in line]
def test_ibus_pin_on_unbridged_compositor_warns_about_silent_failure(self):
"""Pinning ibus can re-enable the silent drop the denylist exists to stop."""
output, _ = self._run_ibus_pin(bridges=False)
self.assertTrue(
self._silent_failure_warnings(output),
f"expected a silent-failure warning, got: {output}",
)
def test_ibus_pin_on_bridged_compositor_does_not_warn(self):
"""The guard must key off the compositor, not merely off the pin.
A bridged desktop -- including a denylisted compositor with the
ibus-wayland bridge running -- is not a bypass and must stay quiet.
"""
output, _ = self._run_ibus_pin(bridges=True)
self.assertFalse(
self._silent_failure_warnings(output),
f"expected no silent-failure warning on a bridged compositor, got: {output}",
)
def test_config_ibus_pin_checks_the_compositor_bridge(self):
"""A saved IBus pin follows the same denylist warning as an env pin."""
for bridges in (False, True):
with self.subTest(bridges=bridges):
output, _ = self._run_ibus_pin(bridges=bridges, config_pin=True)
if bridges:
self.assertFalse(self._silent_failure_warnings(output))
else:
self.assertTrue(self._silent_failure_warnings(output))
self.assertTrue([line for line in output if "text_injection.backend=ibus" in line])
def test_ibus_pin_never_double_checks_the_compositor(self):
"""The bridging check shells out (pgrep / gdbus), so it must run at most once.
The elif chain short-circuits on ``not force_ibus``, so a pinned run
reaches the new guard without having evaluated the check already. If a
future edit drops that short-circuit, this catches the extra subprocess.
"""
for bridges in (False, True):
_, call_count = self._run_ibus_pin(bridges=bridges)
self.assertEqual(call_count, 1, f"bridges={bridges}: expected 1 call, got {call_count}")
def test_ibus_pin_on_x11_does_not_warn(self):
"""The denylist is a Wayland concern; X11 reaches apps through XIM."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
output, _ = self._run_ibus_pin(bridges=True, env=DesktopEnvironment.X11)
self.assertFalse(self._silent_failure_warnings(output))
def _run_with_pin(
self,
pin: Optional[str],
tools: tuple[str, ...],
ibus_available: bool = True,
bridges: bool = True,
env: Any = None,
ydotoold_ready: Optional[bool] = None,
ibus_ready: bool = False,
) -> tuple[Any, list[str]]:
"""Construct through _check_dependencies with a pin; return (injector, logs).
ydotoold_ready defaults to whether ydotool is installed, which is what
every caller wanted before the daemonless fallback needed exercising.
``ibus_ready`` promotes a pinned IBus path before reporting, modeling
the completed background initialization used by the honoured-pin cases.
Pass it explicitly to have ydotool present but its daemon unavailable.
"""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(env or DesktopEnvironment.WAYLAND)
obj._backend_pin = ("auto", None)
environ = {
"XDG_SESSION_TYPE": "x11" if env == DesktopEnvironment.X11 else "wayland",
"XDG_CURRENT_DESKTOP": "Hyprland",
}
if pin:
environ["VOCALINUX_FORCE_BACKEND"] = pin
with (
patch.dict(os.environ, environ, clear=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_available",
return_value=ibus_available,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True
),
patch.object(TextInjector, "_wayland_compositor_bridges_ibus", lambda s: bridges),
patch("vocalinux.text_injection.text_injector.IBusTextInjector"),
patch.object(TextInjector, "_start_ibus_initialization", lambda s: None),
patch.object(
TextInjector,
"_ensure_ydotoold",
lambda s: ("ydotool" in tools) if ydotoold_ready is None else ydotoold_ready,
),
patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}" if c in tools else None),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj._check_dependencies()
if ibus_ready:
obj.environment = (
DesktopEnvironment.X11_IBUS
if env == DesktopEnvironment.X11
else DesktopEnvironment.WAYLAND_IBUS
)
obj._ibus_ready = True
obj._warn_if_pin_not_honoured(*obj._backend_pin)
return obj, logs.output
def _not_applied(self, output):
return [line for line in output if "was not applied" in line]
def test_pin_with_missing_binary_warns_and_names_the_reason(self):
"""A pin for a tool that is not installed must not resume autodetection silently."""
_, output = self._run_with_pin("wtype", tools=("ydotool",))
warned = self._not_applied(output)
self.assertTrue(warned, f"expected a not-applied warning, got: {output}")
self.assertIn("is not installed", warned[0])
self.assertIn("using ydotool instead", warned[0])
def test_pin_that_does_not_apply_to_this_session_warns_without_a_reason(self):
"""xdotool on Wayland resolves to whatever autodetection picks; say so plainly."""
_, output = self._run_with_pin("xdotool", tools=("wtype", "ydotool", "xdotool"))
warned = self._not_applied(output)
self.assertTrue(warned, f"expected a not-applied warning, got: {output}")
self.assertNotIn("is not installed", warned[0])
def test_ibus_pin_warns_when_ibus_support_is_unavailable(self):
_, output = self._run_with_pin("ibus", tools=("wtype", "ydotool"), ibus_available=False)
warned = self._not_applied(output)
self.assertTrue(warned, f"expected a not-applied warning, got: {output}")
self.assertIn("IBus support is not available", warned[0])
def test_honoured_pin_does_not_warn(self):
_, output = self._run_with_pin("wtype", tools=("wtype", "ydotool"))
self.assertFalse(self._not_applied(output))
def test_no_pin_never_warns(self):
_, output = self._run_with_pin(None, tools=("wtype", "ydotool"))
self.assertFalse(self._not_applied(output))
def test_honoured_ibus_pin_on_unbridged_compositor_warns_only_once(self):
"""The denylist-bypass warning and this one must not both fire.
The pin IS honoured there, so the backend in use matches what was
pinned; only the bypass warning is appropriate.
"""
_, output = self._run_with_pin(
"ibus", tools=("wtype", "ydotool"), bridges=False, ibus_ready=True
)
self.assertTrue([line for line in output if "overrides that check" in line])
self.assertFalse(self._not_applied(output))
def test_honoured_ibus_pin_on_bridged_compositor_warns_neither_way(self):
_, output = self._run_with_pin(
"ibus", tools=("wtype", "ydotool"), bridges=True, ibus_ready=True
)
self.assertFalse([line for line in output if "overrides that check" in line])
self.assertFalse(self._not_applied(output))
def test_pinned_log_line_names_the_config_source_not_the_variable(self):
"""A config-sourced pin must not be reported as VOCALINUX_FORCE_BACKEND."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
obj._backend_pin = ("auto", None)
config = json.dumps({"text_injection": {"backend": "wtype"}})
with (
patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True),
patch("vocalinux.text_injection.text_injector.config_dir", return_value="/fake/config"),
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=config)),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False),
patch.object(TextInjector, "_ensure_ydotoold", lambda s: False),
patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}" if c == "wtype" else None),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj._check_dependencies()
pinned_lines = [line for line in logs.output if "wtype" in line and "=" in line]
self.assertTrue(pinned_lines)
self.assertFalse(
[line for line in pinned_lines if "VOCALINUX_FORCE_BACKEND" in line],
f"config-sourced pin reported as the env var: {pinned_lines}",
)
def test_x11_pin_resolving_to_xdotool_warns(self):
"""On X11 the tool is always xdotool, so a wtype pin cannot be honoured.
This is the X11 reporter's case on #476: they wanted IBus off, which a
non-ibus pin does, but the pin they set is not what ends up typing.
"""
from vocalinux.text_injection.text_injector import DesktopEnvironment
_, output = self._run_with_pin(
"wtype", tools=("xdotool", "wtype"), env=DesktopEnvironment.X11
)
warned = self._not_applied(output)
self.assertTrue(warned, f"expected a not-applied warning, got: {output}")
self.assertIn("using xdotool instead", warned[0])
def test_ibus_pin_that_fails_to_initialise_warns_without_a_reason(self):
"""IBus is available but construction blew up, so no cheap reason applies."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
obj._backend_pin = ("auto", None)
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "ibus"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True
),
patch.object(TextInjector, "_wayland_compositor_bridges_ibus", lambda s: True),
patch(
"vocalinux.text_injection.text_injector.IBusTextInjector",
side_effect=RuntimeError("boom"),
),
patch.object(TextInjector, "_ensure_ydotoold", lambda s: True),
patch("shutil.which", side_effect=lambda c: f"/usr/bin/{c}"),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj._check_dependencies()
obj._warn_if_pin_not_honoured(*obj._backend_pin)
warned = self._not_applied(logs.output)
self.assertTrue(warned, f"expected a not-applied warning, got: {logs.output}")
self.assertNotIn("not available", warned[0])
self.assertNotIn("not installed", warned[0])
def test_ydotool_pin_with_missing_binary_warns_and_names_the_reason(self):
"""The wtype case has a counterpart: every selectable tool can be absent.
Contrast with test_pin_with_missing_binary_warns_and_names_the_reason,
which pins the tool the fallback then chooses; here the pin and the
fallback are swapped, so a check keyed on one name would still pass.
"""
_, output = self._run_with_pin("ydotool", tools=("wtype",))
warned = self._not_applied(output)
self.assertTrue(warned, f"expected a not-applied warning, got: {output}")
self.assertIn("ydotool is not installed", warned[0])
self.assertIn("using wtype instead", warned[0])
def test_wtype_pin_without_wtype_falls_back_to_daemonless_ydotool(self):
"""ydotool present but ydotoold not ready is its own branch, and a pin reaches it.
Both diagnostics have to survive together: the fallback says the daemon
is missing, and the pin check says the pin is not what ended up typing.
"""
obj, output = self._run_with_pin("wtype", tools=("ydotool",), ydotoold_ready=False)
self.assertEqual(obj.wayland_tool, "ydotool")
self.assertTrue(
[line for line in output if "ydotoold not ready" in line],
f"expected the daemonless-ydotool warning, got: {output}",
)
warned = self._not_applied(output)
self.assertTrue(warned, f"expected a not-applied warning, got: {output}")
self.assertIn("using ydotool instead", warned[0])
def test_ibus_pin_survives_the_x11_missing_xdotool_early_return(self):
"""_check_dependencies returns early here, before any tool is selected.
The pin is still honoured -- IBus is what types -- so the pin check must
not report a failure just because the usual selection chain was skipped.
"""
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj, output = self._run_with_pin("ibus", tools=(), env=DesktopEnvironment.X11)
self.assertIsNotNone(obj._ibus_injector)
self.assertFalse(
self._not_applied(output),
f"honoured ibus pin reported as not applied: {output}",
)
def test_ibus_pin_survives_the_no_tools_early_return(self):
"""The other early return: Wayland with nothing installed but IBus asked for.
Without the early return this path raises RuntimeError, so reaching the
pin check at all is part of what this asserts.
"""
obj, output = self._run_with_pin("ibus", tools=())
self.assertIsNotNone(obj._ibus_injector)
self.assertFalse(
self._not_applied(output),
f"honoured ibus pin reported as not applied: {output}",
)
def test_pin_log_states_the_request_without_claiming_the_outcome(self):
"""The pin line runs before anything is checked, so it may not claim success.
A pin that is not installed is reported by this line and then
contradicted by the not-applied warning, which is what made an
outcome-shaped word wrong here.
"""
_, output = self._run_with_pin("wtype", tools=("ydotool",))
requested = [line for line in output if "backend pin requested" in line]
self.assertTrue(requested, f"expected the pin-requested line, got: {output}")
self.assertFalse(
[line for line in output if "overriding" in line],
f"pin line still claims an outcome it cannot know: {output}",
)
def test_force_backend_ydotool_skips_ibus_and_wtype(self):
"""VOCALINUX_FORCE_BACKEND=ydotool pins ydotool even when wtype is available."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
@@ -1148,6 +1643,189 @@ class TestStop(unittest.TestCase):
class TestBackgroundIBusInitialization(unittest.TestCase):
def test_constructor_reports_pending_ibus_pin_without_claiming_success(self):
"""A constructed injector is not active until its background warmup succeeds."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
ibus = MagicMock()
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "x11", "VOCALINUX_FORCE_BACKEND": "ibus"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=True,
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector", return_value=ibus),
patch.object(TextInjector, "_start_ibus_initialization"),
patch("shutil.which", return_value=None),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj = TextInjector()
self.assertEqual(obj.environment, DesktopEnvironment.X11)
self.assertIs(obj._ibus_injector, ibus)
self.assertTrue(
[
line
for line in logs.output
if line.endswith("IBus initialization is pending; backend selection is not final.")
],
f"expected pending diagnostic, got: {logs.output}",
)
self.assertFalse([line for line in logs.output if "was not applied" in line])
def test_constructor_reports_ready_ibus_as_honoured(self):
"""A completed warmup promotes the environment before startup reporting."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
ibus = MagicMock()
def initialize_now(obj: TextInjector) -> None:
obj._ibus_injector = ibus
obj._initialize_ibus_in_background()
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "ibus"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=True,
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector", return_value=ibus),
patch.object(TextInjector, "_start_ibus_initialization", initialize_now),
patch.object(TextInjector, "_wayland_compositor_bridges_ibus", lambda s: True),
patch.object(TextInjector, "_ensure_ydotoold", lambda s: True),
patch("shutil.which", side_effect=lambda name: "/usr/bin/" + name),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj = TextInjector()
self.assertEqual(obj.environment, DesktopEnvironment.WAYLAND_IBUS)
self.assertTrue(obj._ibus_ready)
self.assertFalse([line for line in logs.output if "was not applied" in line])
self.assertFalse([line for line in logs.output if "initialization is pending" in line])
def test_constructor_reports_ibus_failure_before_startup_diagnostic(self):
"""A known warmup failure must use the ordinary fallback mismatch warning."""
from vocalinux.text_injection.text_injector import TextInjector
ibus = MagicMock()
ibus.prepare_engine.side_effect = RuntimeError("not ready")
def initialize_now(obj: TextInjector) -> None:
obj._ibus_injector = ibus
obj._initialize_ibus_in_background()
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "x11", "VOCALINUX_FORCE_BACKEND": "ibus"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=True,
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector", return_value=ibus),
patch.object(TextInjector, "_start_ibus_initialization", initialize_now),
patch("shutil.which", side_effect=lambda name: "/usr/bin/" + name),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj = TextInjector()
self.assertTrue(obj._ibus_init_failed)
self.assertTrue([line for line in logs.output if "IBus initialization failed" in line])
warned = [line for line in logs.output if "was not applied" in line]
self.assertTrue(warned, f"expected mismatch warning, got: {logs.output}")
self.assertIn("using xdotool instead", warned[0])
self.assertFalse([line for line in logs.output if "initialization is pending" in line])
def test_constructor_ibus_construction_failure_keeps_fallback_diagnostic(self):
"""A constructor failure has no injector and still reports the fallback."""
from vocalinux.text_injection.text_injector import TextInjector
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "x11", "VOCALINUX_FORCE_BACKEND": "ibus"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=True,
),
patch(
"vocalinux.text_injection.text_injector.IBusTextInjector",
side_effect=RuntimeError("constructor failed"),
),
patch("shutil.which", side_effect=lambda name: "/usr/bin/" + name),
self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs,
):
obj = TextInjector()
self.assertIsNone(obj._ibus_injector)
warned = [line for line in logs.output if "was not applied" in line]
self.assertTrue(warned, f"expected mismatch warning, got: {logs.output}")
self.assertIn("using xdotool instead", warned[0])
def test_late_ibus_failure_keeps_background_warning_after_pending_state(self):
"""A later failure retains the existing background warning and fallback."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
obj._ibus_injector = MagicMock()
obj.wayland_tool = "ydotool"
obj._ibus_injector.prepare_engine.side_effect = RuntimeError("late failure")
obj._backend_pin = ("ibus", "VOCALINUX_FORCE_BACKEND")
with self.assertLogs("vocalinux.text_injection.text_injector", level="DEBUG") as logs:
obj._warn_if_pin_not_honoured(*obj._backend_pin)
obj._initialize_ibus_in_background()
self.assertTrue([line for line in logs.output if "initialization is pending" in line])
self.assertTrue([line for line in logs.output if "IBus initialization failed" in line])
self.assertTrue(obj._ibus_init_failed)
self.assertEqual(obj._resolved_backend(), "ydotool")
def test_resolved_backend_requires_ibus_environment_and_injector(self):
"""A stale injector object cannot override the final non-IBus route."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.WAYLAND_XDOTOOL)
obj._ibus_injector = MagicMock()
obj.wayland_tool = "wtype"
self.assertEqual(obj._resolved_backend(), "xdotool")
obj._ibus_injector = None
obj.environment = DesktopEnvironment.WAYLAND_IBUS
self.assertEqual(obj._resolved_backend(), "wtype")
def test_check_dependencies_starts_ibus_in_background(self):
from vocalinux.text_injection.text_injector import DesktopEnvironment