* fix(dictation-pad): keep Test Dictation results and move pad routing to Settings Keep late recognition callbacks on the test path. Move Dictation Pad routing to Settings. Fixes #983 Fixes #986 * fix(recognition): ignore stop sentinels in pending transcription Ignore stop signals when checking queued transcription work. Add regression coverage for a real stop path leaving a sentinel behind. Fixes #983 * fix(recognition): don't count orphaned queued audio as pending transcription * fix(recognition): count superseded workers as pending transcription * test(auto-pause): give the bare manager its worker-tracking state
1034 lines
39 KiB
Python
1034 lines
39 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 ast
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
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_pad_actions_never_write_config(self) -> None:
|
|
"""The pad receives edits but never persists the routing setting."""
|
|
config_manager = MagicMock()
|
|
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.append_text("hello world")
|
|
pad.handle_action("undo")
|
|
pad.handle_action("redo")
|
|
pad.delete_last_chars(5)
|
|
pad.set_capture_enabled(False)
|
|
pad._on_clear_clicked()
|
|
|
|
config_manager.set.assert_not_called()
|
|
config_manager.save_settings.assert_not_called()
|
|
finally:
|
|
pad.destroy()
|
|
|
|
def test_controller_routing_state_tracks_external_config_changes(self) -> None:
|
|
"""The pad reads Settings changes and syncs its controller without writing."""
|
|
config_manager = MagicMock()
|
|
config_manager.get_bool.side_effect = [False, True]
|
|
with patch.object(
|
|
DictationPad,
|
|
"_init_gtk_window",
|
|
side_effect=RuntimeError("gtk disabled in unit tests"),
|
|
):
|
|
pad = DictationPad(enabled=True, config_manager=config_manager)
|
|
try:
|
|
self.assertFalse(pad._capture_enabled())
|
|
self.assertFalse(pad.controller.enabled)
|
|
self.assertTrue(pad._capture_enabled())
|
|
self.assertTrue(pad.controller.enabled)
|
|
config_manager.set.assert_not_called()
|
|
config_manager.save_settings.assert_not_called()
|
|
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()
|
|
|
|
|
|
def _pad_source() -> str:
|
|
"""Read dictation_pad.py so GTK-facing methods can be exec'd under fake gi."""
|
|
path = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
"..",
|
|
"src",
|
|
"vocalinux",
|
|
"ui",
|
|
"dictation_pad.py",
|
|
)
|
|
with open(path, encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
|
|
def _fake_gdk() -> SimpleNamespace:
|
|
"""Gdk enums the pad handlers read, with real GDK numeric values."""
|
|
return SimpleNamespace(
|
|
WindowState=SimpleNamespace(WITHDRAWN=1, ICONIFIED=2),
|
|
VisibilityState=SimpleNamespace(UNOBSCURED=0, PARTIAL=1, FULLY_OBSCURED=2),
|
|
)
|
|
|
|
|
|
class TestDictationPadShelving(unittest.TestCase):
|
|
"""
|
|
Compositor-shelving handling for the pad window (issue #896).
|
|
|
|
On GNOME/Wayland an idle, fully covered toplevel gets shelved by the
|
|
compositor: it stays in the window list but draws nothing and accepts
|
|
no input, and present() alone cannot revive it. The pad counters that
|
|
by floating above other windows on X11, detecting the shelved state
|
|
via frame-callback staleness, and recreating the surface on show.
|
|
"""
|
|
|
|
def test_init_gtk_window_floats_and_watches_shelving(self) -> None:
|
|
"""Window construction pins the pad and subscribes shelving events."""
|
|
window = MagicMock()
|
|
|
|
tree = ast.parse(_pad_source())
|
|
func_node = next(
|
|
node
|
|
for cls in tree.body
|
|
if isinstance(cls, ast.ClassDef) and cls.name == "DictationPad"
|
|
for node in cls.body
|
|
if isinstance(node, ast.FunctionDef) and node.name == "_init_gtk_window"
|
|
)
|
|
module = ast.Module(body=[func_node], type_ignores=[])
|
|
namespace: dict = {"_PAD_WIDTH": 480, "_PAD_HEIGHT": 360, "_SHELF_WATCH_MS": 2000}
|
|
exec(compile(ast.fix_missing_locations(module), "<test>", "exec"), namespace)
|
|
|
|
fake_self = MagicMock()
|
|
# gi.repository is a shared session mock; scope the Window return value
|
|
# so no other test inherits this window or its recorded calls.
|
|
gtk_window = sys.modules["gi.repository"].Gtk.Window
|
|
with patch.object(gtk_window, "return_value", window):
|
|
namespace["_init_gtk_window"](fake_self)
|
|
|
|
window.set_keep_above.assert_called_once_with(True)
|
|
window.stick.assert_called_once()
|
|
window.add_events.assert_called_once()
|
|
connected = {call.args[0] for call in window.connect.call_args_list}
|
|
self.assertIn("window-state-event", connected)
|
|
self.assertIn("visibility-notify-event", connected)
|
|
# Frame-callback watchdog: tick callback on the textview plus a
|
|
# recurring GLib timeout running _shelf_watchdog.
|
|
fake_self._textview.add_tick_callback.assert_called_once_with(fake_self._on_frame_tick)
|
|
fake_self._GLib.timeout_add.assert_called_once_with(2000, fake_self._shelf_watchdog)
|
|
|
|
def test_window_state_event_tracks_shelving(self) -> None:
|
|
"""ICONIFIED or WITHDRAWN in new_window_state marks the pad shelved."""
|
|
pad = _pad_without_gtk()
|
|
try:
|
|
pad._Gdk = _fake_gdk()
|
|
widget = MagicMock()
|
|
|
|
pad._on_window_state_event(widget, SimpleNamespace(new_window_state=2))
|
|
self.assertTrue(pad._window_iconified)
|
|
|
|
pad._on_window_state_event(widget, SimpleNamespace(new_window_state=0))
|
|
self.assertFalse(pad._window_iconified)
|
|
|
|
pad._on_window_state_event(widget, SimpleNamespace(new_window_state=1))
|
|
self.assertTrue(pad._window_iconified)
|
|
finally:
|
|
pad.destroy()
|
|
|
|
def test_show_pad_deiconifies_shelved_window(self) -> None:
|
|
"""A visible but iconified pad is restored before it is presented."""
|
|
pad = _pad_without_gtk()
|
|
try:
|
|
pad._gtk_ready = True
|
|
pad._window = MagicMock()
|
|
pad._window.get_visible.return_value = True
|
|
pad._Gtk = MagicMock()
|
|
|
|
pad._window_iconified = True
|
|
pad.show_pad()
|
|
pad._window.deiconify.assert_called_once()
|
|
pad._window.present_with_time.assert_called_once()
|
|
|
|
pad._window.reset_mock()
|
|
pad._window_iconified = False
|
|
pad.show_pad()
|
|
pad._window.deiconify.assert_not_called()
|
|
pad._window.present_with_time.assert_called_once()
|
|
finally:
|
|
pad._window = None
|
|
pad.destroy()
|
|
|
|
def test_frame_tick_stamps_monotonic_time(self) -> None:
|
|
"""Each compositor frame callback updates the liveness stamp."""
|
|
pad = _pad_without_gtk()
|
|
try:
|
|
pad._GLib = MagicMock()
|
|
pad._GLib.get_monotonic_time.return_value = 123_000_000
|
|
self.assertTrue(pad._on_frame_tick(MagicMock(), MagicMock()))
|
|
self.assertEqual(pad._last_frame_ts, 123_000_000)
|
|
finally:
|
|
pad.destroy()
|
|
|
|
def test_shelf_watchdog_flags_stale_surface(self) -> None:
|
|
"""A visible window with dead frame callbacks is marked shelved."""
|
|
pad = _pad_without_gtk()
|
|
try:
|
|
pad._GLib = MagicMock()
|
|
pad._GLib.get_monotonic_time.return_value = 10_000_000
|
|
pad._window = MagicMock()
|
|
pad._window.get_visible.return_value = True
|
|
|
|
pad._last_frame_ts = 0
|
|
pad._shelf_watchdog()
|
|
self.assertFalse(pad._surface_shelved) # never drawn: not proof
|
|
|
|
pad._last_frame_ts = 8_800_000 # 1.2 s stale: still alive
|
|
pad._shelf_watchdog()
|
|
self.assertFalse(pad._surface_shelved)
|
|
|
|
pad._last_frame_ts = 8_000_000 # 2.0 s stale: shelved
|
|
pad._shelf_watchdog()
|
|
self.assertTrue(pad._surface_shelved)
|
|
|
|
pad._window.get_visible.return_value = False
|
|
pad._shelf_watchdog()
|
|
self.assertFalse(pad._surface_shelved) # hidden: not shelved
|
|
finally:
|
|
pad._window = None
|
|
pad.destroy()
|
|
|
|
def test_show_pad_recreates_shelved_surface(self) -> None:
|
|
"""A shelved pad is revived by hide+show_all, not present() alone."""
|
|
pad = _pad_without_gtk()
|
|
try:
|
|
pad._gtk_ready = True
|
|
pad._window = MagicMock()
|
|
pad._window.get_visible.return_value = True
|
|
pad._Gtk = MagicMock()
|
|
pad._surface_shelved = True
|
|
|
|
pad.show_pad()
|
|
pad._window.hide.assert_called_once()
|
|
pad._window.show_all.assert_called_once()
|
|
self.assertFalse(pad._surface_shelved)
|
|
pad._window.present_with_time.assert_called_once()
|
|
pad._window.deiconify.assert_not_called()
|
|
finally:
|
|
pad._window = None
|
|
pad.destroy()
|
|
|
|
def test_destroy_removes_shelf_watchdog(self) -> None:
|
|
"""The watchdog timeout is removed when the pad is destroyed."""
|
|
pad = _pad_without_gtk()
|
|
pad._GLib = MagicMock()
|
|
pad._shelf_watch_id = 77
|
|
pad.destroy()
|
|
pad._GLib.source_remove.assert_any_call(77)
|
|
self.assertIsNone(pad._shelf_watch_id)
|
|
|
|
def test_visibility_notify_repaints_only_after_obscured(self) -> None:
|
|
"""Return from full occlusion forces one fresh draw, once."""
|
|
pad = _pad_without_gtk()
|
|
try:
|
|
pad._Gdk = _fake_gdk()
|
|
pad._window = MagicMock()
|
|
widget = MagicMock()
|
|
obscured = SimpleNamespace(state=2) # FULLY_OBSCURED
|
|
visible = SimpleNamespace(state=0) # UNOBSCURED
|
|
|
|
pad._on_visibility_notify(widget, visible)
|
|
pad._window.queue_draw.assert_not_called()
|
|
|
|
pad._on_visibility_notify(widget, obscured)
|
|
pad._on_visibility_notify(widget, visible)
|
|
pad._window.queue_draw.assert_called_once()
|
|
|
|
pad._window.reset_mock()
|
|
pad._on_visibility_notify(widget, visible)
|
|
pad._window.queue_draw.assert_not_called()
|
|
finally:
|
|
pad._window = None
|
|
pad.destroy()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|