* 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>
807 lines
30 KiB
Python
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()
|