Files
javis_bot/tests/test_dictation_history.py
javis-bot c4abf63f38
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Transform isair/jarvis into a Discord-controlled voice assistant running on
the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact.

- bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral),
  voice channel join + voice receive/playback, pluggable VNC screen broadcast
  (selfbot live / noVNC / screenshot)
- bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS
  behind a thin localhost HTTP API
- .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite,
  docs/language-comparison.md and docs/vnc-xfce-setup.md

Language decision: hybrid (Python brain + Node/bun Discord layer) because
Discord blocks bot video; native screen broadcast only works via a Node
selfbot library.
2026-06-09 14:51:05 +09:00

653 lines
25 KiB
Python

"""
Tests for dictation history storage and UI integration.
"""
import json
import tempfile
import time
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
# DictationHistory storage tests
# ---------------------------------------------------------------------------
class TestDictationHistory:
"""Tests for the file-backed dictation history store."""
def _make_history(self, tmp_path):
from src.jarvis.dictation.history import DictationHistory
return DictationHistory(path=tmp_path / "history.json")
def test_add_and_get_all(self, tmp_path):
h = self._make_history(tmp_path)
entry = h.add("hello world", duration=2.5)
assert entry["text"] == "hello world"
assert entry["duration"] == 2.5
assert "id" in entry
assert "timestamp" in entry
entries = h.get_all()
assert len(entries) == 1
assert entries[0]["text"] == "hello world"
def test_get_all_returns_newest_first(self, tmp_path):
h = self._make_history(tmp_path)
h.add("first")
h.add("second")
h.add("third")
entries = h.get_all()
assert [e["text"] for e in entries] == ["third", "second", "first"]
def test_delete_entry(self, tmp_path):
h = self._make_history(tmp_path)
e1 = h.add("keep me")
e2 = h.add("delete me")
assert h.delete(e2["id"]) is True
assert h.count == 1
assert h.get_all()[0]["text"] == "keep me"
def test_delete_nonexistent_returns_false(self, tmp_path):
h = self._make_history(tmp_path)
h.add("something")
assert h.delete("nonexistent-id") is False
assert h.count == 1
def test_clear(self, tmp_path):
h = self._make_history(tmp_path)
h.add("one")
h.add("two")
h.clear()
assert h.count == 0
assert h.get_all() == []
def test_persistence_across_instances(self, tmp_path):
path = tmp_path / "history.json"
from src.jarvis.dictation.history import DictationHistory
h1 = DictationHistory(path=path)
h1.add("persisted text", duration=1.0)
h2 = DictationHistory(path=path)
entries = h2.get_all()
assert len(entries) == 1
assert entries[0]["text"] == "persisted text"
def test_max_entries_trimming(self, tmp_path):
from src.jarvis.dictation.history import DictationHistory
h = DictationHistory(path=tmp_path / "history.json", max_entries=3)
h.add("a")
h.add("b")
h.add("c")
h.add("d") # Should trim oldest
assert h.count == 3
texts = [e["text"] for e in h.get_all()]
assert "a" not in texts
assert texts == ["d", "c", "b"]
def test_empty_file_loads_gracefully(self, tmp_path):
path = tmp_path / "history.json"
path.write_text("")
from src.jarvis.dictation.history import DictationHistory
h = DictationHistory(path=path)
assert h.count == 0
def test_corrupt_file_loads_gracefully(self, tmp_path):
path = tmp_path / "history.json"
path.write_text("not valid json{{{")
from src.jarvis.dictation.history import DictationHistory
h = DictationHistory(path=path)
assert h.count == 0
def test_count_property(self, tmp_path):
h = self._make_history(tmp_path)
assert h.count == 0
h.add("x")
assert h.count == 1
h.add("y")
assert h.count == 2
def test_entry_has_uuid_id(self, tmp_path):
h = self._make_history(tmp_path)
e = h.add("test")
# UUID4 hex is 32 chars
assert len(e["id"]) == 32
assert e["id"].isalnum()
def test_entry_timestamp_is_recent(self, tmp_path):
h = self._make_history(tmp_path)
before = time.time()
e = h.add("test")
after = time.time()
assert before <= e["timestamp"] <= after
def test_reload_from_disk_picks_up_external_writes(self, tmp_path):
"""reload_from_disk should refresh entries written by another process."""
path = tmp_path / "history.json"
from src.jarvis.dictation.history import DictationHistory
h = DictationHistory(path=path)
assert h.count == 0
# Simulate another process writing entries directly to the file
external_entries = [
{"id": "aaa", "text": "from daemon", "timestamp": 1.0, "duration": 0.5},
]
path.write_text(json.dumps(external_entries))
# Before reload, in-memory state is stale
assert h.count == 0
h.reload_from_disk()
assert h.count == 1
assert h.get_all()[0]["text"] == "from daemon"
def test_reload_from_disk_is_thread_safe(self, tmp_path):
"""reload_from_disk should acquire the lock (no crash under contention)."""
import threading
from src.jarvis.dictation.history import DictationHistory
path = tmp_path / "history.json"
h = DictationHistory(path=path)
h.add("initial")
errors = []
def writer():
try:
for i in range(20):
h.add(f"entry-{i}")
except Exception as e:
errors.append(e)
def reloader():
try:
for _ in range(20):
h.reload_from_disk()
except Exception as e:
errors.append(e)
t1 = threading.Thread(target=writer)
t2 = threading.Thread(target=reloader)
t1.start()
t2.start()
t1.join()
t2.join()
assert errors == [], f"Thread safety errors: {errors}"
# ---------------------------------------------------------------------------
# DictationHistoryWindow tests
# ---------------------------------------------------------------------------
class TestDictationHistoryWindow:
"""Tests for the dictation history Qt window."""
def test_window_can_be_created(self):
"""Window should instantiate without errors."""
from src.desktop_app.dictation_history import DictationHistoryWindow
# Just check it doesn't crash (no QApplication needed for class inspection)
assert DictationHistoryWindow is not None
def test_window_has_signals(self):
"""Window should expose a signals object with new_entry."""
from src.desktop_app.dictation_history import DictationHistorySignals
signals = DictationHistorySignals()
assert hasattr(signals, "new_entry")
def test_set_history_stores_reference(self, tmp_path):
"""set_history should accept a DictationHistory instance."""
from src.desktop_app.dictation_history import DictationHistoryWindow
from src.jarvis.dictation.history import DictationHistory
h = DictationHistory(path=tmp_path / "h.json")
# Instantiate without QApplication — just test the attribute
win = DictationHistoryWindow.__new__(DictationHistoryWindow)
win._history = None
win.set_history = DictationHistoryWindow.set_history.__get__(win)
# We can't call set_history fully without Qt, but verify the method exists
assert callable(win.set_history)
def test_reload_keeps_list_items_parented_to_current_container(self, qapp, tmp_path):
"""Cards/placeholders must be children of the currently-installed
list container after a rebuild. The container is swapped atomically
on each _reload() — what matters is that the cards live inside
whatever container is now in the scroll area, not the old one.
"""
from src.desktop_app.dictation_history import DictationHistoryWindow
from src.jarvis.dictation.history import DictationHistory
history = DictationHistory(path=tmp_path / "h.json")
history.add("first")
history.add("second")
history.add("third")
window = DictationHistoryWindow(history=history)
for _ in range(3):
window._reload()
container = window._list_widget
for i in range(window._list_layout.count()):
item = window._list_layout.itemAt(i)
widget = item.widget()
if widget is not None:
assert widget.parent() is container, (
"List items must be children of the current container."
)
def test_on_new_entry_keeps_new_card_parented_to_container(self, qapp, tmp_path):
"""A card inserted via the new-entry signal must be parented to the
container, not promoted to a top-level widget.
"""
from src.desktop_app.dictation_history import (
DictationHistoryWindow,
_DictationCard,
)
from src.jarvis.dictation.history import DictationHistory
history = DictationHistory(path=tmp_path / "h.json")
window = DictationHistoryWindow(history=history)
window.isVisible = lambda: True # type: ignore[assignment]
entry = history.add("hello world", duration=1.0)
window._on_new_entry(entry)
# The reload rebuilds the container from scratch; assert the new
# card lives inside the *current* container.
container = window._list_widget
cards = [
window._list_layout.itemAt(i).widget()
for i in range(window._list_layout.count())
if isinstance(window._list_layout.itemAt(i).widget(), _DictationCard)
]
assert len(cards) == 1, (
"Expected exactly one _DictationCard in the visible window's layout."
)
for i in range(window._list_layout.count()):
item = window._list_layout.itemAt(i)
widget = item.widget()
if widget is not None:
assert widget.parent() is container
def test_on_new_entry_is_safe_when_window_hidden(self, qapp, tmp_path):
"""A dictation can complete before the user ever opens the history
window. In bundled mode the daemon runs in-process, so the engine's
on_dictation_result callback fires while the window is still hidden.
That path must not manipulate the widget tree — on Windows Qt 6 the
combination of creating cards and triggering queued event delivery
while the window has never been shown fast-fails inside Qt6Core.dll
(0xc0000409) (installer-mode-only crash reported after a successful
paste). When the user later opens the window, showEvent pulls the
fresh entries from history and rebuilds from scratch.
"""
from src.desktop_app.dictation_history import DictationHistoryWindow
from src.jarvis.dictation.history import DictationHistory
history = DictationHistory(path=tmp_path / "h.json")
window = DictationHistoryWindow(history=history)
assert not window.isVisible()
# Snapshot the layout contents before the signal.
before = [
window._list_layout.itemAt(i).widget()
for i in range(window._list_layout.count())
]
entry = history.add("late-arriving dictation", duration=1.0)
window._on_new_entry(entry)
# No new cards should be added while the window is hidden.
after = [
window._list_layout.itemAt(i).widget()
for i in range(window._list_layout.count())
]
assert before == after, (
"_on_new_entry must be a no-op while the window is hidden; "
"widget manipulation during hidden state caused a Qt6Core.dll "
"fast-fail on Windows."
)
# Later, when the user opens the window, the new entry must appear.
# Exercise the same code path showEvent runs (reload + rebuild) without
# actually showing a window — avoids platform-specific headless issues.
history.reload_from_disk()
window._reload()
rendered_texts = []
for i in range(window._list_layout.count()):
item = window._list_layout.itemAt(i)
widget = item.widget() if item else None
e = getattr(widget, "_entry", None)
if e is not None:
rendered_texts.append(e["text"])
assert "late-arriving dictation" in rendered_texts
def test_show_event_is_safely_re_callable(self, qapp, tmp_path):
"""showEvent must be callable repeatedly without orphaning widgets.
The tray menu opens the window every time, so show/hide cycles over a
session need to keep the list layout healthy.
"""
from src.desktop_app.dictation_history import DictationHistoryWindow
from src.jarvis.dictation.history import DictationHistory
history = DictationHistory(path=tmp_path / "h.json")
for i in range(5):
history.add(f"entry {i}")
window = DictationHistoryWindow(history=history)
for _ in range(3):
window.show()
qapp.processEvents() # let the deferred reload run
window.hide()
container = window._list_widget
for i in range(window._list_layout.count()):
item = window._list_layout.itemAt(i)
widget = item.widget()
if widget is not None:
assert widget.parent() is container
def test_show_event_defers_reload_off_paint_path(self, qapp, tmp_path):
"""showEvent must defer _reload() so it runs after the first paint.
Mutating the widget tree inside showEvent is re-entrant with Qt's
first paint pass and has triggered a Qt6Core fast-fail
(0xc0000409) on Qt 6.11 Windows. The window schedules the reload
via QTimer.singleShot(0, ...) so it lands on the next event-loop
tick, after the initial show paint has completed.
"""
from src.desktop_app.dictation_history import (
DictationHistoryWindow,
_DictationCard,
)
from src.jarvis.dictation.history import DictationHistory
history = DictationHistory(path=tmp_path / "h.json")
history.add("pre-existing")
window = DictationHistoryWindow(history=history)
# Track the container before show. If show triggered a synchronous
# rebuild, _list_widget would already be swapped.
before_container = window._list_widget
window.show()
# Before the event loop runs, the container should still be the
# original empty one — the reload is deferred.
assert window._list_widget is before_container
# After the event loop processes the deferred reload, the container
# is swapped and cards are present.
qapp.processEvents()
assert window._list_widget is not before_container
cards = [
window._list_layout.itemAt(i).widget()
for i in range(window._list_layout.count())
if isinstance(window._list_layout.itemAt(i).widget(), _DictationCard)
]
assert len(cards) == 1
def test_first_show_with_existing_entries_leaves_no_orphan_widgets(
self, qapp, tmp_path
):
"""After the first show with pre-existing on-disk entries, the
current container has no orphaned (non-layout) direct children.
Reproduces the open-after-dictate crash scenario: the user records
a dictation (entries land on disk), then opens the window. The
atomic-swap rebuild replaces the container wholesale, so the new
container's direct children are exactly the layout contents.
"""
from src.desktop_app.dictation_history import DictationHistoryWindow
from src.jarvis.dictation.history import DictationHistory
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QWidget
history = DictationHistory(path=tmp_path / "h.json")
history.add("pre-existing entry")
window = DictationHistoryWindow(history=history)
window.show()
qapp.processEvents() # let the deferred reload run
layout_widgets = set()
for i in range(window._list_layout.count()):
item = window._list_layout.itemAt(i)
w = item.widget() if item else None
if w is not None:
layout_widgets.add(id(w))
container = window._list_widget
for child in container.findChildren(QWidget, "", Qt.FindChildOption.FindDirectChildrenOnly):
if id(child) in layout_widgets:
continue
assert not child.isVisible(), (
f"Orphaned widget {type(child).__name__!r} left visible in "
"the current container."
)
def test_card_timestamp_does_not_feed_emoji_to_strftime(self, qapp):
"""The card timestamp label must not pass emojis through strftime.
On Windows with the bundled Python 3.11, datetime.strftime routes
through the C locale encoder which cannot encode non-BMP emoji
codepoints and raises UnicodeEncodeError. When that exception
escapes a Qt slot invocation (e.g. the deferred reload fired from
showEvent), Qt6Core triggers a fast-fail (0xc0000409) rather than
surfacing a catchable error, crashing the whole app.
This test reproduces the failure mode by forcing a locale whose
encoder can't handle U+1F4C5 — mirrors the bundled-Windows
behaviour that broke open-after-dictate for real users.
"""
import locale
import inspect
from src.desktop_app.dictation_history import _DictationCard
# Source check: the card source must not pass emoji literals into
# strftime. Catches future regressions even on locales where the
# runtime encoder happens to accept the codepoint.
source = inspect.getsource(_DictationCard.__init__)
for line in source.splitlines():
stripped = line.strip()
if "strftime(" not in stripped:
continue
# Allow only ASCII format specifiers inside strftime().
start = stripped.index("strftime(")
arg = stripped[start + len("strftime("):]
# Grab until matching close paren (simple heuristic, format
# strings don't contain parens).
close = arg.find(")")
if close >= 0:
arg = arg[:close]
assert arg.isascii(), (
f"strftime argument must be ASCII-only to survive Windows "
f"locale encoders; found non-ASCII in: {stripped!r}"
)
def test_show_event_reloads_entries_written_by_another_process(
self, qapp, tmp_path
):
"""Opening the window via the tray must surface entries that a sibling
process (the daemon subprocess) wrote after the desktop app started.
The desktop app owns one DictationHistory instance and the daemon owns
another; they only share the JSON file on disk. If showEvent() didn't
reload from disk, the window would render the desktop app's stale
in-memory cache and the user would see no new dictations from the
current session.
"""
from src.desktop_app.dictation_history import DictationHistoryWindow
from src.jarvis.dictation.history import DictationHistory
path = tmp_path / "h.json"
# Desktop-app-side history: loads what exists on disk at startup.
desktop_history = DictationHistory(path=path)
desktop_history.add("older entry from a previous session")
window = DictationHistoryWindow(history=desktop_history)
# Simulate the daemon subprocess adding entries through its own
# DictationHistory instance — same file, separate in-memory state.
daemon_history = DictationHistory(path=path)
daemon_history.add("first new dictation")
daemon_history.add("second new dictation")
# User opens the window via the tray menu.
window.show()
qapp.processEvents() # let the deferred reload run
rendered_texts = []
for i in range(window._list_layout.count()):
item = window._list_layout.itemAt(i)
widget = item.widget() if item else None
# Only cards expose `_entry`; placeholders are plain QLabels.
entry = getattr(widget, "_entry", None)
if entry is not None:
rendered_texts.append(entry["text"])
assert "first new dictation" in rendered_texts
assert "second new dictation" in rendered_texts
# ---------------------------------------------------------------------------
# Menu integration tests
# ---------------------------------------------------------------------------
class TestMenuIntegration:
"""Tests that the dictation history menu item is wired up in app.py."""
def test_create_menu_has_dictation_action(self):
"""The create_menu method should define a dictation history action."""
import inspect
from src.desktop_app.app import JarvisSystemTray
source = inspect.getsource(JarvisSystemTray.create_menu)
assert "Dictation History" in source
assert "dictation_history_action" in source
def test_show_dictation_history_method_exists(self):
from src.desktop_app.app import JarvisSystemTray
assert hasattr(JarvisSystemTray, "show_dictation_history")
assert callable(getattr(JarvisSystemTray, "show_dictation_history"))
# ---------------------------------------------------------------------------
# Engine integration — history is saved on successful dictation
# ---------------------------------------------------------------------------
class TestEngineHistoryIntegration:
"""Tests that the dictation engine saves to history."""
@pytest.fixture(autouse=True)
def _skip_if_no_deps(self):
try:
import numpy # noqa: F401
import pynput # noqa: F401
except ImportError:
pytest.skip("required dependencies not installed")
def test_engine_has_history_attribute(self):
from src.jarvis.dictation.dictation_engine import DictationEngine
import threading
engine = DictationEngine(
whisper_model_ref=lambda: MagicMock(),
whisper_backend_ref=lambda: "faster-whisper",
mlx_repo_ref=lambda: None,
hotkey="ctrl+shift+d",
transcribe_lock=threading.Lock(),
)
assert hasattr(engine, "history")
assert engine.history is not None
@patch("src.jarvis.dictation.dictation_engine._clipboard_paste")
def test_successful_dictation_saves_to_history(self, mock_paste, tmp_path):
import numpy as np
import threading
from src.jarvis.dictation.dictation_engine import DictationEngine
from src.jarvis.dictation.history import DictationHistory
mock_model = MagicMock()
mock_seg = MagicMock()
mock_seg.text = "dictated text"
mock_model.transcribe.return_value = ([mock_seg], MagicMock())
engine = DictationEngine(
whisper_model_ref=lambda: mock_model,
whisper_backend_ref=lambda: "faster-whisper",
mlx_repo_ref=lambda: None,
hotkey="ctrl+shift+d",
transcribe_lock=threading.Lock(),
)
# Replace history with one using temp path
engine.history = DictationHistory(path=tmp_path / "h.json")
frames = [np.zeros(8000, dtype=np.float32)] # 0.5s
engine._transcribe_and_paste(frames)
assert engine.history.count == 1
entry = engine.history.get_all()[0]
assert entry["text"] == "dictated text"
@patch("src.jarvis.dictation.dictation_engine._clipboard_paste")
def test_on_dictation_result_callback_called(self, mock_paste, tmp_path):
import numpy as np
import threading
from src.jarvis.dictation.dictation_engine import DictationEngine
from src.jarvis.dictation.history import DictationHistory
mock_model = MagicMock()
mock_seg = MagicMock()
mock_seg.text = "hello"
mock_model.transcribe.return_value = ([mock_seg], MagicMock())
results = []
engine = DictationEngine(
whisper_model_ref=lambda: mock_model,
whisper_backend_ref=lambda: "faster-whisper",
mlx_repo_ref=lambda: None,
hotkey="ctrl+shift+d",
transcribe_lock=threading.Lock(),
on_dictation_result=lambda entry: results.append(entry),
)
engine.history = DictationHistory(path=tmp_path / "h.json")
frames = [np.zeros(8000, dtype=np.float32)]
engine._transcribe_and_paste(frames)
assert len(results) == 1
assert results[0]["text"] == "hello"
@patch("src.jarvis.dictation.dictation_engine._clipboard_paste")
def test_empty_transcription_not_saved(self, mock_paste, tmp_path):
import numpy as np
import threading
from src.jarvis.dictation.dictation_engine import DictationEngine
from src.jarvis.dictation.history import DictationHistory
mock_model = MagicMock()
mock_model.transcribe.return_value = ([], MagicMock())
engine = DictationEngine(
whisper_model_ref=lambda: mock_model,
whisper_backend_ref=lambda: "faster-whisper",
mlx_repo_ref=lambda: None,
hotkey="ctrl+shift+d",
transcribe_lock=threading.Lock(),
)
engine.history = DictationHistory(path=tmp_path / "h.json")
frames = [np.zeros(8000, dtype=np.float32)]
engine._transcribe_and_paste(frames)
assert engine.history.count == 0