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
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.
265 lines
10 KiB
Python
265 lines
10 KiB
Python
"""Tests for diary-to-graph import feature.
|
|
|
|
Covers:
|
|
- Database.get_all_conversation_summaries() method
|
|
- /api/graph/import-diary streaming endpoint (requires flask)
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
import types
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Mock modules that may not be available in the test environment
|
|
_MOCK_MODULES = [
|
|
"PyQt6", "PyQt6.QtWidgets", "PyQt6.QtCore", "PyQt6.QtGui",
|
|
"PyQt6.QtWebEngineWidgets", "PyQt6.sip",
|
|
"requests", "requests.exceptions",
|
|
"psutil",
|
|
]
|
|
for _mod in _MOCK_MODULES:
|
|
if _mod not in sys.modules:
|
|
sys.modules[_mod] = MagicMock()
|
|
|
|
# Ensure requests.exceptions.Timeout is a proper exception class
|
|
sys.modules["requests"].exceptions.Timeout = type("Timeout", (Exception,), {})
|
|
|
|
from src.jarvis.memory.db import Database
|
|
|
|
|
|
# ── Database method tests ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def db_with_summaries(tmp_path):
|
|
"""Provide a database pre-populated with conversation summaries."""
|
|
db = Database(str(tmp_path / "test.db"), sqlite_vss_path=None)
|
|
|
|
# Insert some summaries in non-chronological order to test ordering
|
|
summaries = [
|
|
("2025-03-15", "User discussed work projects and deadlines.", "work,planning", "jarvis"),
|
|
("2025-01-10", "User talked about favourite coffee shops.", "food,coffee", "jarvis"),
|
|
("2025-06-22", "User mentioned upcoming holiday plans.", "travel,holiday", "jarvis"),
|
|
("2025-02-01", "User shared fitness routine details.", "health,fitness", "jarvis"),
|
|
]
|
|
|
|
for date_utc, summary, topics, source_app in summaries:
|
|
ts_utc = datetime.now(timezone.utc).isoformat()
|
|
db.conn.execute(
|
|
"""INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(date_utc, ts_utc, summary, topics, source_app),
|
|
)
|
|
db.conn.commit()
|
|
|
|
yield db
|
|
db.close()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetAllConversationSummaries:
|
|
"""Tests for Database.get_all_conversation_summaries()."""
|
|
|
|
def test_returns_all_summaries(self, db_with_summaries):
|
|
"""Should return every summary in the database."""
|
|
rows = db_with_summaries.get_all_conversation_summaries()
|
|
assert len(rows) == 4
|
|
|
|
def test_ordered_by_date_ascending(self, db_with_summaries):
|
|
"""Summaries should be ordered oldest-first for chronological import."""
|
|
rows = db_with_summaries.get_all_conversation_summaries()
|
|
dates = [row["date_utc"] for row in rows]
|
|
assert dates == sorted(dates)
|
|
assert dates[0] == "2025-01-10"
|
|
assert dates[-1] == "2025-06-22"
|
|
|
|
def test_empty_database(self, db):
|
|
"""Should return an empty list when no summaries exist."""
|
|
rows = db.get_all_conversation_summaries()
|
|
assert rows == []
|
|
|
|
def test_returns_expected_fields(self, db_with_summaries):
|
|
"""Each row should have the standard conversation_summaries fields."""
|
|
rows = db_with_summaries.get_all_conversation_summaries()
|
|
row = rows[0]
|
|
assert "date_utc" in row.keys()
|
|
assert "summary" in row.keys()
|
|
assert "topics" in row.keys()
|
|
assert "source_app" in row.keys()
|
|
|
|
def test_contains_summary_text(self, db_with_summaries):
|
|
"""Summaries should contain the actual text that was stored."""
|
|
rows = db_with_summaries.get_all_conversation_summaries()
|
|
texts = [row["summary"] for row in rows]
|
|
assert any("coffee" in t for t in texts)
|
|
assert any("fitness" in t for t in texts)
|
|
|
|
|
|
# ── Import endpoint tests ─────────────────────────────────────────────
|
|
|
|
try:
|
|
import flask as _flask # noqa: F401
|
|
_HAS_FLASK = True
|
|
except ImportError:
|
|
_HAS_FLASK = False
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available")
|
|
class TestImportDiaryEndpoint:
|
|
"""Tests for /api/graph/import-diary streaming endpoint."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_app(self, tmp_path):
|
|
"""Set up Flask test client with a temporary database."""
|
|
from src.desktop_app.memory_viewer import app, get_graph_store
|
|
|
|
self.db_path = str(tmp_path / "test.db")
|
|
|
|
# Create database with summaries
|
|
self.db = Database(self.db_path, sqlite_vss_path=None)
|
|
self.db.conn.execute(
|
|
"""INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
("2025-03-15", "2025-03-15T12:00:00Z", "User likes dark roast coffee.", "food", "jarvis"),
|
|
)
|
|
self.db.conn.execute(
|
|
"""INSERT INTO conversation_summaries (date_utc, ts_utc, summary, topics, source_app)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
("2025-03-16", "2025-03-16T12:00:00Z", "User works at Acme Corp.", "work", "jarvis"),
|
|
)
|
|
self.db.conn.commit()
|
|
|
|
app.config["TESTING"] = True
|
|
self.client = app.test_client()
|
|
|
|
yield
|
|
self.db.close()
|
|
|
|
def _parse_ndjson(self, data: bytes) -> list[dict]:
|
|
"""Parse newline-delimited JSON from response data."""
|
|
lines = data.decode("utf-8").strip().split("\n")
|
|
return [json.loads(line) for line in lines if line.strip()]
|
|
|
|
@patch("src.desktop_app.memory_viewer._get_db_path")
|
|
@patch("src.desktop_app.memory_viewer.load_settings")
|
|
@patch("src.jarvis.memory.graph_ops.call_llm_direct")
|
|
def test_import_streams_progress(self, mock_llm, mock_settings, mock_db_path):
|
|
"""Should stream start, progress, and complete messages."""
|
|
mock_db_path.return_value = self.db_path
|
|
|
|
cfg = MagicMock()
|
|
cfg.ollama_base_url = "http://localhost:11434"
|
|
cfg.ollama_chat_model = "test-model"
|
|
cfg.llm_chat_timeout_sec = 10.0
|
|
cfg.llm_thinking_enabled = False
|
|
mock_settings.return_value = cfg
|
|
|
|
# LLM returns facts for extraction, NONE for placement (writes to root)
|
|
mock_llm.side_effect = [
|
|
'["Likes dark roast coffee"]', # extract facts from summary 1
|
|
"NONE", # traverse for fact 1 (no children, goes to root)
|
|
'["Works at Acme Corp"]', # extract facts from summary 2
|
|
"NONE", # traverse for fact 2
|
|
]
|
|
|
|
resp = self.client.post("/api/graph/import-diary")
|
|
assert resp.status_code == 200
|
|
|
|
messages = self._parse_ndjson(resp.data)
|
|
types = [m["type"] for m in messages]
|
|
|
|
assert "start" in types
|
|
assert "progress" in types
|
|
assert "complete" in types
|
|
|
|
start_msg = next(m for m in messages if m["type"] == "start")
|
|
assert start_msg["total"] == 2
|
|
|
|
complete_msg = next(m for m in messages if m["type"] == "complete")
|
|
assert complete_msg["processed"] == 2
|
|
|
|
@patch("src.desktop_app.memory_viewer._get_db_path")
|
|
@patch("src.desktop_app.memory_viewer.load_settings")
|
|
def test_import_empty_diary(self, mock_settings, mock_db_path, tmp_path):
|
|
"""Should handle empty diary gracefully."""
|
|
empty_db_path = str(tmp_path / "empty.db")
|
|
empty_db = Database(empty_db_path, sqlite_vss_path=None)
|
|
mock_db_path.return_value = empty_db_path
|
|
|
|
cfg = MagicMock()
|
|
mock_settings.return_value = cfg
|
|
|
|
resp = self.client.post("/api/graph/import-diary")
|
|
messages = self._parse_ndjson(resp.data)
|
|
|
|
assert len(messages) == 1
|
|
assert messages[0]["type"] == "complete"
|
|
assert messages[0]["processed"] == 0
|
|
|
|
empty_db.close()
|
|
|
|
@patch("src.desktop_app.memory_viewer._get_db_path")
|
|
@patch("src.desktop_app.memory_viewer.load_settings")
|
|
@patch("src.jarvis.memory.graph_ops.call_llm_direct")
|
|
def test_import_continues_on_per_summary_error(self, mock_llm, mock_settings, mock_db_path):
|
|
"""If one summary fails, the import should continue with the rest."""
|
|
mock_db_path.return_value = self.db_path
|
|
|
|
cfg = MagicMock()
|
|
cfg.ollama_base_url = "http://localhost:11434"
|
|
cfg.ollama_chat_model = "test-model"
|
|
cfg.llm_chat_timeout_sec = 10.0
|
|
cfg.llm_thinking_enabled = False
|
|
mock_settings.return_value = cfg
|
|
|
|
# First summary extraction fails, second succeeds
|
|
mock_llm.side_effect = [
|
|
None, # extraction fails for summary 1
|
|
'["Works at Acme Corp"]', # extract facts from summary 2
|
|
"NONE", # traverse
|
|
]
|
|
|
|
resp = self.client.post("/api/graph/import-diary")
|
|
messages = self._parse_ndjson(resp.data)
|
|
|
|
progress_msgs = [m for m in messages if m["type"] == "progress"]
|
|
assert len(progress_msgs) == 2 # Both summaries processed
|
|
|
|
complete_msg = next(m for m in messages if m["type"] == "complete")
|
|
assert complete_msg["processed"] == 2
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available")
|
|
class TestImportDialogueDismissal:
|
|
"""Regression: after diary import succeeds, loadStats must not re-show the modal."""
|
|
|
|
def test_html_contains_diary_import_done_guard(self):
|
|
"""The loadStats check should be gated by diaryImportDone flag."""
|
|
from src.desktop_app.memory_viewer import app
|
|
|
|
app.config["TESTING"] = True
|
|
client = app.test_client()
|
|
resp = client.get("/")
|
|
html = resp.data.decode("utf-8")
|
|
|
|
# The flag must be declared
|
|
assert "let diaryImportDone = false;" in html
|
|
|
|
# The flag must be set on import completion
|
|
assert "diaryImportDone = true;" in html
|
|
|
|
# The loadStats check must include the guard
|
|
assert "&& !diaryImportDone" in html
|
|
|
|
# The gate must be based on stored knowledge (total_tokens), not node count.
|
|
# Guards against a regression to the old `totalNodes <= 1` condition that kept
|
|
# re-prompting after a successful import filled the root node.
|
|
assert "totalTokens === 0" in html
|
|
assert "totalNodes <= 1" not in html
|