Files
voculinux/tests/test_dictation_pad.py
T
9898bb8dd9 feat(ui): in-app Dictation Pad for Wayland-safe dictation (#887)
* feat(ui): add in-app Dictation Pad for Wayland-safe dictation

Dictated text can now be routed into a text box inside Vocalinux's own
window instead of being injected into other applications — an explicit
fallback for desktops where cross-app injection is unreliable or blocked
(notably Wayland). The pad opens from the tray menu, captures dictation
when enabled via Settings or its own checkbox, and lets the user copy
text out by hand. 'delete that' removes the last segment in-pad.

Closes #726.

* fix(ui): consult live config for pad auto-reveal and Copy All

The reveal gate used the controller's enabled flag, which only syncs on
pad show or its own checkbox — enabling capture via the Settings switch
while the pad was closed silently buffered dictation with no reveal.
_apply_append now reads dictate_to_pad from the shared config.

Also: Copy All copies the widget text (dictation plus in-pad edits), and
re-showing the pad no longer does a wholesale set_text — the view stays
incrementally in sync, so manual edits survive hide/reopen.

* fix(ui): keep dictation pad capture and commands inside the pad

Address all Greptile findings on PR #887:

- Read the capture toggle from the shared ConfigManager instead of
  re-parsing config.json per segment, so a torn mid-write read can no
  longer fail-open and send dictation to the focused app
- Sync edits made directly in the pad widget back into the controller,
  keeping delete semantics consistent with the visible text
- Stamp queued widget operations with a generation tag so a Clear drops
  appends still sitting in the GTK idle queue
- Route undo/redo, select all/line/word/paragraph and cut/copy/paste to
  pad-local implementations while capturing, so their shortcuts never
  reach whichever application holds focus
- Track where the last segment was delivered so "delete that" follows
  the text rather than the current capture toggle
- Annotate every new signature and add callback-level tests that pin the
  routing, queue, and undo behavior

* fix(ui): keep pad controller, queued ops, and delete target in sync

Manual edits could overwrite queued dictation in the controller because
GTK input events outrank idle callbacks; the changed handler now replays
pending widget ops before syncing the view. The controller also tracks
dictated segments across undo snapshots, so a pad undo pops the "delete
that" target instead of leaving a stale length. Catches in the pad are
narrowed to the GTK/config error set per repo style, and missing type
annotations are added.

* fix(ui): close flush races in pad idle queue and delete retarget

Flushed idle ops stayed scheduled with GLib and ran a second time,
duplicating appends; each queued op now only acts while its entry is
still pending. A queued full-view refresh could also erase a manual
edit that landed first, so the changed-handler flush now drops stale
set-text ops instead of replaying them. And clearing the pad
destination on undo-to-empty kept a redo from restoring the "delete
that" target; the destination flag is preserved so redo re-arms it.

* fix(ui): replay pad idle ops before edits land, not after

Flushing in the changed handler still let queued appends leapfrog the
keystroke that fired it, and a stale dispatch could cancel a later
queued op with identical text. Pending entries now match by identity,
and the replay moved to insert-text/delete-range — signals that fire
before the edit is applied — so queued dictation and deletes keep true
chronological order against manual edits.

* fix(history): target late segments to their own snippet by id

A straggler from a just-ended session could merge into whichever snippet
was newest when it arrived, and audio captured before a session opened
was attributed to that session while decoding on its worker.

- TranscriptionHistory.add returns the snippet id and the new
  extend_entry() extends a specific entry, replacing the positional
  extend_latest target in the late-segment path
- session_started_floor stamps when a session opens; in-session
  attribution also requires the capture to postdate it
- test_capture_ceiling closes the mic-test window at callback restore so
  test speech still decoding can no longer leak into history

* fix(settings): keep deferred edits from overwriting a newer choice

The deferred-persist filter treated "config still equals the baseline" as
untouched, so a reopened dialog that applied a key back to its baseline
value could still lose it to the closed dialog worker.

- Record the generation of the apply that last wrote each advanced key;
  a pending edit now survives only when no newer apply wrote that key
- A worker apply whose snapshot a newer apply predates now skips its
  reconfigure and save entirely, so collection order — not lock order —
  decides which snapshot wins

* fix(settings): skip stale applies before stopping dictation

The superseded-apply check ran after stop_recognition, so a stale worker still interrupted a live dictation session. Recognition now stops inside the apply lock, after the generation check.

* fix(shortcuts): keep language edits from breaking dictation

Two per-language shortcut fixes (#805):

- A binding edit landing while a session is live no longer stops
  recognition. The dialog's per-keystroke persist still saves the new
  binding, but the tray defers the listener rebuild to the next IDLE
  transition, so a held push-to-talk keeps its release callback and a
  toggle session is never cut off mid-utterance.
- A row edited onto a key another row holds can no longer displace that
  row's binding. Each row's remembered shortcut is reserved for its own
  row before claims run in row order, so the stealing edit is rejected
  and the held binding survives.
- Persists that resolve to the already-saved bindings (mid-edit
  keystrokes, rejected duplicates, reverted fields) skip the save and
  the listener rebuild entirely.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 01:35:21 -07:00

807 lines
30 KiB
Python

"""
Tests for the in-app dictation pad (issue #726).
Drives the shipped DictationPadController (and config helpers) so capture
mode routes text into the pad's buffer instead of cross-app injection.
Important: these tests must not import real GTK / tray_indicator into
sys.modules — that breaks later tests that mock gi (see CI isolation).
"""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from vocalinux.ui.config_manager import DEFAULT_CONFIG, ConfigManager
from vocalinux.ui.dictation_pad import DictationPad, DictationPadController
def _ensure_test_config_dir(path: str) -> None:
parent_dir = os.path.dirname(path)
if not os.path.exists(parent_dir):
os.mkdir(parent_dir)
if not os.path.exists(path):
os.mkdir(path)
def _pad_without_gtk(enabled: bool = False) -> DictationPad:
"""
Build DictationPad without initializing a real GTK window.
Patches window construction so tests never load gi.repository (which
would pollute the process for later gi-mocked tests).
"""
with patch.object(
DictationPad,
"_init_gtk_window",
side_effect=RuntimeError("gtk disabled in unit tests"),
):
return DictationPad(enabled=enabled)
class TestDictationPadController(unittest.TestCase):
"""Unit tests for the pure buffer controller."""
def test_default_disabled_and_empty(self) -> None:
ctrl = DictationPadController()
self.assertFalse(ctrl.enabled)
self.assertEqual(ctrl.text, "")
def test_append_accumulates_segments(self) -> None:
ctrl = DictationPadController(enabled=True)
ctrl.append("Hello ")
ctrl.append("world.")
self.assertEqual(ctrl.text, "Hello world.")
def test_append_newlines_preserved(self) -> None:
ctrl = DictationPadController()
ctrl.append("first line")
ctrl.append("\n")
ctrl.append("second")
self.assertEqual(ctrl.text, "first line\nsecond")
def test_delete_last_removes_tail_chars(self) -> None:
ctrl = DictationPadController()
ctrl.append("Hello world")
self.assertEqual(ctrl.delete_last(6), 6)
self.assertEqual(ctrl.text, "Hello")
def test_delete_last_clamps_to_buffer_length(self) -> None:
ctrl = DictationPadController()
ctrl.append("hi")
self.assertEqual(ctrl.delete_last(10), 2)
self.assertEqual(ctrl.text, "")
def test_delete_last_on_empty_returns_zero(self) -> None:
ctrl = DictationPadController()
self.assertEqual(ctrl.delete_last(5), 0)
ctrl.append("x")
self.assertEqual(ctrl.delete_last(0), 0)
self.assertEqual(ctrl.delete_last(-3), 0)
self.assertEqual(ctrl.text, "x")
def test_clear_empties_buffer(self) -> None:
ctrl = DictationPadController()
ctrl.append("some dictation")
ctrl.clear()
self.assertEqual(ctrl.text, "")
def test_set_text_replaces_buffer(self) -> None:
ctrl = DictationPadController()
ctrl.append("dictated")
ctrl.set_text("dictated plus edits")
self.assertEqual(ctrl.text, "dictated plus edits")
# Identical content is a no-op, not a mutation: the first undo goes
# straight back to the pre-edit text.
ctrl.set_text("dictated plus edits")
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.text, "dictated")
def test_undo_restores_last_mutation_and_redo_replays(self) -> None:
ctrl = DictationPadController()
ctrl.append("one ")
ctrl.append("two ")
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.text, "one ")
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.text, "")
self.assertFalse(ctrl.undo())
self.assertTrue(ctrl.redo())
self.assertEqual(ctrl.text, "one ")
self.assertTrue(ctrl.redo())
self.assertEqual(ctrl.text, "one two ")
self.assertFalse(ctrl.redo())
def test_new_edit_clears_redo_lane(self) -> None:
ctrl = DictationPadController()
ctrl.append("a")
ctrl.undo()
ctrl.append("b")
self.assertFalse(ctrl.redo())
def test_delete_last_and_clear_are_undoable(self) -> None:
ctrl = DictationPadController()
ctrl.append("hello world")
ctrl.delete_last(6)
self.assertEqual(ctrl.text, "hello")
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.text, "hello world")
ctrl.clear()
self.assertEqual(ctrl.text, "")
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.text, "hello world")
def test_set_enabled_does_not_touch_buffer(self) -> None:
ctrl = DictationPadController()
ctrl.append("keep me")
ctrl.set_enabled(True)
ctrl.set_enabled(False)
self.assertEqual(ctrl.text, "keep me")
def test_last_segment_tracks_appends(self) -> None:
ctrl = DictationPadController()
self.assertIsNone(ctrl.last_segment)
ctrl.append("hello ")
ctrl.append("world ")
self.assertEqual(ctrl.last_segment, "world ")
def test_last_segment_tracks_undo_and_redo(self) -> None:
"""Undo pops the deletion target with the text it removed."""
ctrl = DictationPadController()
ctrl.append("hello ")
ctrl.append("world ")
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.last_segment, "hello ")
self.assertTrue(ctrl.undo())
self.assertIsNone(ctrl.last_segment)
self.assertTrue(ctrl.redo())
self.assertEqual(ctrl.last_segment, "hello ")
self.assertTrue(ctrl.redo())
self.assertEqual(ctrl.last_segment, "world ")
def test_last_segment_survives_delete_and_drops_on_set_text(self) -> None:
ctrl = DictationPadController()
ctrl.append("hello ")
ctrl.append("world ")
ctrl.delete_last(2)
self.assertEqual(ctrl.last_segment, "worl")
ctrl.delete_last(10)
self.assertIsNone(ctrl.last_segment)
ctrl.append("again ")
ctrl.set_text("manual rewrite")
self.assertIsNone(ctrl.last_segment)
# Undo restores the boundaries the manual rewrite blurred.
self.assertTrue(ctrl.undo())
self.assertEqual(ctrl.last_segment, "again ")
def test_clear_drops_segment_tracking(self) -> None:
ctrl = DictationPadController()
ctrl.append("gone ")
ctrl.clear()
self.assertIsNone(ctrl.last_segment)
class TestDictationPadConfig(unittest.TestCase):
"""ConfigManager helpers for the dictate_to_pad preference."""
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_config_dir = os.path.join(self.temp_dir.name, ".config/vocalinux")
_ensure_test_config_dir(self.temp_config_dir)
self.temp_config_file = os.path.join(self.temp_config_dir, "config.json")
self.config_dir_patcher = patch(
"vocalinux.ui.config_manager.CONFIG_DIR", self.temp_config_dir
)
self.config_file_patcher = patch(
"vocalinux.ui.config_manager.CONFIG_FILE", self.temp_config_file
)
self.makedirs_patcher = patch(
"vocalinux.ui.config_manager.os.makedirs",
side_effect=lambda path, exist_ok=True: _ensure_test_config_dir(path),
)
self.config_dir_patcher.start()
self.config_file_patcher.start()
self.makedirs_patcher.start()
_ensure_test_config_dir(self.temp_config_dir)
def tearDown(self) -> None:
self.config_dir_patcher.stop()
self.config_file_patcher.stop()
self.makedirs_patcher.stop()
self.temp_dir.cleanup()
def test_default_dictate_to_pad_disabled(self) -> None:
self.assertFalse(DEFAULT_CONFIG["text_injection"]["dictate_to_pad"])
cm = ConfigManager()
self.assertFalse(cm.is_dictate_to_pad_enabled())
def test_set_dictate_to_pad_persists(self) -> None:
cm = ConfigManager()
cm.set_dictate_to_pad(True)
self.assertTrue(cm.is_dictate_to_pad_enabled())
cm.save_config()
cm2 = ConfigManager()
self.assertTrue(cm2.is_dictate_to_pad_enabled())
def test_set_dictate_to_pad_false(self) -> None:
cm = ConfigManager()
cm.set_dictate_to_pad(True)
cm.set_dictate_to_pad(False)
self.assertFalse(cm.is_dictate_to_pad_enabled())
def test_corrupt_config_file_keeps_in_memory_capture_state(self) -> None:
"""A torn config.json write must not flip a running session's routing.
The live toggle is read from the shared manager's in-memory cache,
never re-parsed per segment: a partial save on disk cannot misroute
dictation back into whichever application holds focus.
"""
cm = ConfigManager()
cm.set_dictate_to_pad(True)
# Simulate a torn mid-write file.
with open(self.temp_config_file, "w") as f:
f.write('{"text_injection": {"dictate_to_pad": tr')
self.assertTrue(cm.is_dictate_to_pad_enabled())
# A fresh manager loading the torn file fails closed to the default.
self.assertFalse(ConfigManager().is_dictate_to_pad_enabled())
class TestDictationPadFacade(unittest.TestCase):
"""
Drive the shipped DictationPad API without a display.
Window construction is patched out so these tests never load GTK; the
controller must still capture text so nothing is silently dropped.
"""
def test_gtk_init_failure_keeps_buffer_usable(self) -> None:
pad = _pad_without_gtk(enabled=True)
self.assertFalse(pad._gtk_ready)
pad.append_text("dictated words ")
pad.append_text("more")
self.assertEqual(pad.controller.text, "dictated words more")
pad.destroy()
def test_delete_last_chars_headless(self) -> None:
pad = _pad_without_gtk()
try:
pad.append_text("hello ")
self.assertEqual(pad.delete_last_chars(6), 6)
self.assertEqual(pad.controller.text, "")
finally:
pad.destroy()
def test_show_pad_is_safe_headless(self) -> None:
pad = _pad_without_gtk()
try:
pad.show_pad() # must not raise
finally:
pad.destroy()
def test_set_capture_enabled_updates_controller(self) -> None:
pad = _pad_without_gtk()
try:
pad.set_capture_enabled(True)
self.assertTrue(pad.controller.enabled)
pad.set_capture_enabled(False)
self.assertFalse(pad.controller.enabled)
finally:
pad.destroy()
def test_capture_checkbox_persists_config(self) -> None:
"""The pad checkbox writes dictate_to_pad via the config manager."""
config_manager = MagicMock()
with patch.object(
DictationPad,
"_init_gtk_window",
side_effect=RuntimeError("gtk disabled in unit tests"),
):
pad = DictationPad(enabled=False, config_manager=config_manager)
try:
widget = MagicMock()
widget.get_active.return_value = True
pad._on_capture_toggled(widget)
config_manager.set.assert_called_once_with("text_injection", "dictate_to_pad", True)
config_manager.save_settings.assert_called_once()
self.assertTrue(pad.controller.enabled)
finally:
pad.destroy()
def test_apply_append_inserts_at_end_and_shows(self) -> None:
"""Widget path: append inserts at buffer end; enabled capture shows."""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = False
pad._textview = MagicMock()
pad._buffer = MagicMock()
pad._Gtk = MagicMock()
pad.controller.append("segment ")
pad._apply_append("segment ", pad._generation)
pad._buffer.insert.assert_called_once()
args = pad._buffer.insert.call_args[0]
self.assertEqual(args[1], "segment ")
pad._window.show_all.assert_called_once()
finally:
pad._window = None
pad.destroy()
def test_apply_append_does_not_show_when_disabled(self) -> None:
pad = _pad_without_gtk(enabled=False)
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = False
pad._textview = MagicMock()
pad._buffer = MagicMock()
pad.controller.append("segment ")
pad._apply_append("segment ", pad._generation)
pad._window.show_all.assert_not_called()
finally:
pad._window = None
pad.destroy()
def test_apply_append_reveals_when_enabled_via_settings(self) -> None:
"""Regression: capture toggled in Settings must reveal the pad too.
The reveal gate consults live config, not just the controller flag,
so a dictate_to_pad=True written by the Settings switch while the
pad is closed still surfaces the window on the first segment.
"""
config_manager = MagicMock()
config_manager.get_bool.return_value = True
with patch.object(
DictationPad,
"_init_gtk_window",
side_effect=RuntimeError("gtk disabled in unit tests"),
):
pad = DictationPad(enabled=False, config_manager=config_manager)
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = False
pad._textview = MagicMock()
pad._buffer = MagicMock()
pad._Gtk = MagicMock()
pad.controller.append("segment ")
pad._apply_append("segment ", pad._generation)
config_manager.get_bool.assert_called_with("text_injection", "dictate_to_pad", False)
self.assertTrue(pad.controller.enabled)
pad._window.show_all.assert_called_once()
finally:
pad._window = None
pad.destroy()
def test_apply_append_stays_hidden_when_settings_off(self) -> None:
"""A stale controller flag must not override live config."""
config_manager = MagicMock()
config_manager.get_bool.return_value = False
with patch.object(
DictationPad,
"_init_gtk_window",
side_effect=RuntimeError("gtk disabled in unit tests"),
):
pad = DictationPad(enabled=True, config_manager=config_manager)
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = False
pad._textview = MagicMock()
pad._buffer = MagicMock()
pad.controller.append("segment ")
pad._apply_append("segment ", pad._generation)
pad._window.show_all.assert_not_called()
self.assertFalse(pad.controller.enabled)
finally:
pad._window = None
pad.destroy()
def test_copy_all_prefers_widget_text(self) -> None:
"""Copy All copies the widget contents (dictation + in-pad edits)."""
pad = _pad_without_gtk()
try:
pad._gtk_ready = True
pad._Gtk = MagicMock()
pad._Gdk = MagicMock()
pad._GLib = MagicMock()
pad._copy_button = MagicMock()
pad._buffer = MagicMock()
pad._buffer.get_text.return_value = "dictated plus edits"
pad.controller.append("dictated")
pad._on_copy_all_clicked()
clipboard = pad._Gtk.Clipboard.get.return_value
clipboard.set_text.assert_called_once_with("dictated plus edits", -1)
finally:
pad.destroy()
def test_show_pad_does_not_clobber_widget_edits(self) -> None:
"""Re-showing the pad must not overwrite in-pad manual edits."""
pad = _pad_without_gtk()
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad._buffer = MagicMock()
pad._Gtk = MagicMock()
pad.show_pad()
# Never a wholesale set_text on (re)show: widget content is kept
# incrementally in sync by _apply_append/_apply_delete.
pad._buffer.set_text.assert_not_called()
finally:
pad._window = None
pad.destroy()
def test_apply_delete_removes_tail_from_widget(self) -> None:
pad = _pad_without_gtk()
try:
pad._gtk_ready = True
pad._textview = MagicMock()
pad._buffer = MagicMock()
end_iter = MagicMock()
pad._buffer.get_end_iter.return_value = end_iter
pad.controller.append("hello")
pad._apply_delete(5, pad._generation)
pad._buffer.delete.assert_called_once()
start_iter = pad._buffer.delete.call_args[0][0]
start_iter.backward_chars.assert_called_once_with(5)
finally:
pad.destroy()
def test_clear_drops_appends_still_queued_for_widget(self) -> None:
"""A Clear between append_text and its idle callback must win."""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("stale segment ")
pad._on_clear_clicked()
self.assertEqual(pad.controller.text, "")
# Run the queued idle callback — the stale generation skips it.
queued = pad._GLib.idle_add.call_args.args[0]
queued()
pad._buffer.insert.assert_not_called()
pad._buffer.set_text.assert_called_once_with("")
finally:
pad._window = None
pad.destroy()
def test_queued_append_before_clear_still_applies(self) -> None:
"""Appends queued before the bump run normally; only stale ones die."""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("kept ")
queued = pad._GLib.idle_add.call_args.args[0]
queued()
pad._buffer.insert.assert_called_once()
pad.append_text("post clear ")
pad._on_clear_clicked()
stale = pad._GLib.idle_add.call_args.args[0]
stale()
self.assertEqual(pad._buffer.insert.call_count, 1)
finally:
pad._window = None
pad.destroy()
def test_widget_edits_sync_back_to_controller(self) -> None:
"""Manual edits in the text view update the controller's buffer."""
pad = _pad_without_gtk()
try:
pad._buffer = MagicMock()
pad._buffer.get_text.return_value = "user edited text"
pad._on_buffer_changed(pad._buffer)
self.assertEqual(pad.controller.text, "user edited text")
# delete_last now computes against the edited content.
self.assertEqual(pad.delete_last_chars(4), 4)
self.assertEqual(pad.controller.text, "user edited ")
finally:
pad.destroy()
def test_buffer_changed_skips_programmatic_sync(self) -> None:
"""The pad's own widget writes must not feed back into the controller."""
pad = _pad_without_gtk()
try:
pad.controller.append("dictated")
pad._syncing_widget = True
pad._on_buffer_changed(MagicMock())
self.assertEqual(pad.controller.text, "dictated")
finally:
pad.destroy()
def test_widget_edit_flushes_queued_ops_before_sync(self) -> None:
"""A manual edit must not overwrite queued dictation in the controller.
Input events outrank idle callbacks, so a keystroke can land before a
queued append. The changed handler replays the queue first, keeping
the view — and the controller sync — in delivery order.
"""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("queued dictation ")
# The user edits before the idle callback runs; the queued op must
# be flushed into the widget before the view is read back.
pad._buffer.get_text.return_value = "user queued dictation "
pad._on_buffer_changed(pad._buffer)
calls = [c[0] for c in pad._buffer.mock_calls]
self.assertLess(calls.index("insert"), calls.index("get_text"))
self.assertEqual(pad.controller.text, "user queued dictation ")
self.assertEqual(pad._pending_idle, [])
finally:
pad._window = None
pad.destroy()
def test_flushed_ops_do_not_run_again_on_glib_dispatch(self) -> None:
"""An op a flush already ran must no-op when GLib still dispatches it."""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("queued ")
queued = pad._GLib.idle_add.call_args.args[0]
pad._flush_idle_ops()
pad._buffer.insert.assert_called_once()
# GLib still fires the callback later; without the pending-entry
# guard the insert would land a second time.
queued()
pad._buffer.insert.assert_called_once()
finally:
pad._window = None
pad.destroy()
def test_queued_append_lands_before_user_edit(self) -> None:
"""Dictation queued before a keystroke must precede it in the buffer."""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("dictated ")
# insert-text/delete-range fire before the edit is applied, so
# the queued append lands first and cannot leapfrog new text.
pad._on_buffer_user_edit()
pad._buffer.insert.assert_called_once()
self.assertEqual(pad._pending_idle, [])
finally:
pad._window = None
pad.destroy()
def test_same_text_appends_do_not_cancel_each_other(self) -> None:
"""A stale dispatch must not consume a later entry with the same text."""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("y")
pad._flush_idle_ops()
pad.append_text("z")
pad.append_text("y")
calls = pad._GLib.idle_add.call_args_list
calls[0].args[0]() # late dispatch of the already-flushed "y"
calls[1].args[0]() # "z"
calls[2].args[0]() # "y"
inserted = [c.args[1] for c in pad._buffer.insert.call_args_list]
self.assertEqual(inserted, ["y", "z", "y"])
finally:
pad._window = None
pad.destroy()
def test_widget_edit_drops_stale_full_refresh(self) -> None:
"""A queued full refresh must not erase a manual edit.
After a voice undo the post-undo refresh waits in the idle queue; a
keystroke landing first makes that refresh stale — running it would
wipe the edit before it ever reaches the controller.
"""
pad = _pad_without_gtk(enabled=True)
try:
pad._gtk_ready = True
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._textview = MagicMock()
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad.append_text("one ")
pad.append_text("two ")
pad.handle_action("undo")
# The undo refresh is still queued when the user types.
pad._buffer.get_text.return_value = "one two edited"
pad._on_buffer_changed(pad._buffer)
pad._buffer.set_text.assert_not_called()
self.assertEqual(pad.controller.text, "one two edited")
self.assertEqual(pad._pending_idle, [])
# GLib still dispatches the dropped refresh later — it must no-op.
queued = pad._GLib.idle_add.call_args.args[0]
queued()
pad._buffer.set_text.assert_not_called()
finally:
pad._window = None
pad.destroy()
def test_copy_all_flushes_queued_appends(self) -> None:
"""Copy All sees segments still waiting in the idle queue."""
pad = _pad_without_gtk()
try:
pad._gtk_ready = True
pad._Gtk = MagicMock()
pad._Gdk = MagicMock()
pad._GLib = MagicMock()
pad._copy_button = MagicMock()
pad._buffer = MagicMock()
pad._buffer.get_text.return_value = "whole pad "
pad.append_text("queued ")
pad._on_copy_all_clicked()
calls = [c[0] for c in pad._buffer.mock_calls]
self.assertLess(calls.index("insert"), calls.index("get_text"))
clipboard = pad._Gtk.Clipboard.get.return_value
clipboard.set_text.assert_called_once_with("whole pad ", -1)
finally:
pad.destroy()
def test_last_segment_passthrough_to_controller(self) -> None:
pad = _pad_without_gtk()
try:
self.assertIsNone(pad.last_segment)
pad.append_text("dictated ")
self.assertEqual(pad.last_segment, "dictated ")
finally:
pad.destroy()
def test_apply_append_stale_generation_is_dropped(self) -> None:
pad = _pad_without_gtk()
try:
pad._buffer = MagicMock()
pad._apply_append("old", pad._generation - 1)
pad._buffer.insert.assert_not_called()
finally:
pad.destroy()
def test_handle_action_undo_redo_sync_widget(self) -> None:
pad = _pad_without_gtk()
try:
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad.controller.append("segment ")
self.assertTrue(pad.handle_action("undo"))
self.assertEqual(pad.controller.text, "")
pad._GLib.idle_add.call_args.args[0]()
pad._buffer.set_text.assert_called_once_with("")
self.assertTrue(pad.handle_action("redo"))
self.assertEqual(pad.controller.text, "segment ")
finally:
pad.destroy()
def test_handle_action_with_empty_history_returns_false(self) -> None:
pad = _pad_without_gtk()
try:
self.assertFalse(pad.handle_action("undo"))
self.assertFalse(pad.handle_action("redo"))
finally:
pad.destroy()
def test_handle_action_selection_commands(self) -> None:
pad = _pad_without_gtk()
try:
pad._GLib = MagicMock()
pad._buffer = MagicMock()
it = MagicMock()
it.get_offset.return_value = 0
pad._buffer.get_iter_at_mark.return_value = it
pad._buffer.get_text.return_value = "para one\n\npara two"
for action in (
"select_all",
"select_line",
"select_word",
"select_paragraph",
):
self.assertTrue(pad.handle_action(action))
self.assertEqual(pad._GLib.idle_add.call_count, 4)
# Run the queued select_all and select_line applies.
pad._GLib.idle_add.call_args_list[0].args[0]()
pad._buffer.select_range.assert_called_once_with(
pad._buffer.get_start_iter.return_value,
pad._buffer.get_end_iter.return_value,
)
pad._GLib.idle_add.call_args_list[1].args[0]()
it.copy.return_value.set_line_offset.assert_called_once_with(0)
pad._GLib.idle_add.call_args_list[3].args[0]()
# "para one\n\npara two" — next blank line after offset 0 is at 8.
pad._buffer.get_iter_at_offset.assert_called_with(8)
finally:
pad.destroy()
def test_handle_action_clipboard_commands(self) -> None:
pad = _pad_without_gtk()
try:
pad._GLib = MagicMock()
pad._buffer = MagicMock()
pad._Gtk = MagicMock()
pad._Gdk = MagicMock()
for action in ("cut", "copy", "paste"):
self.assertTrue(pad.handle_action(action))
pad._GLib.idle_add.call_args.args[0]()
clipboard = pad._Gtk.Clipboard.get.return_value
pad._buffer.cut_clipboard.assert_called_once_with(clipboard, True)
pad._buffer.copy_clipboard.assert_called_once_with(clipboard)
pad._buffer.paste_clipboard.assert_called_once_with(clipboard, None, True)
finally:
pad.destroy()
def test_handle_action_rejects_unknown_and_headless(self) -> None:
"""Unknown actions and widget commands without a view are refused."""
pad = _pad_without_gtk()
try:
self.assertFalse(pad.handle_action("select_all"))
self.assertFalse(pad.handle_action("nonexistent"))
finally:
pad.destroy()
if __name__ == "__main__":
unittest.main()