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.
172 lines
7.0 KiB
Python
172 lines
7.0 KiB
Python
"""Tests for the diary topic optimisation HTTP endpoint.
|
|
|
|
The endpoint wraps ``optimise_diary_topics`` in NDJSON streaming. The
|
|
contract under test is:
|
|
1. the endpoint streams start/progress/complete events correctly;
|
|
2. event payloads contain only counts and the date, never raw tag strings;
|
|
3. the btn-optimise-topics click handler is wired in the always-run page
|
|
setup section (same structural rule as btn-scrub-deflections).
|
|
|
|
The mapping logic (LLM call + DB write) is tested in
|
|
``test_diary_topic_optimise.py``. These tests mock ``optimise_diary_topics``
|
|
itself to isolate the endpoint's own responsibilities.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
try:
|
|
import flask # noqa: F401
|
|
_HAS_FLASK = True
|
|
except ImportError:
|
|
_HAS_FLASK = False
|
|
|
|
|
|
def _make_fake_optimise(events):
|
|
"""Return a callable that yields the given event dicts."""
|
|
def _fn(db, ollama_base_url, ollama_chat_model, ollama_embed_model=None, **kwargs):
|
|
yield from events
|
|
return _fn
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.skipif(not _HAS_FLASK, reason="Flask not available")
|
|
class TestDiaryOptimiseTopicsEndpoint:
|
|
@pytest.fixture(autouse=True)
|
|
def setup_app(self, tmp_path):
|
|
from src.desktop_app import memory_viewer
|
|
from src.jarvis.memory.db import Database
|
|
|
|
self.db_path = str(tmp_path / "test.db")
|
|
seed_db = Database(self.db_path)
|
|
for date_utc, summary, topics in [
|
|
("2026-04-10", "User cooked pasta.", "cook, pasta"),
|
|
("2026-04-15", "User went running.", "workout"),
|
|
("2026-04-27", "User discussed Python.", "python"),
|
|
]:
|
|
seed_db.upsert_conversation_summary(
|
|
date_utc=date_utc, summary=summary, topics=topics, source_app="jarvis",
|
|
)
|
|
self.seed_db = seed_db
|
|
|
|
memory_viewer.app.config["TESTING"] = True
|
|
self.client = memory_viewer.app.test_client()
|
|
|
|
# Controlled fake events from optimise_diary_topics.
|
|
_FAKE_EVENTS = [
|
|
{"date_utc": "2026-04-10", "topics_changed": True, "old_topic_count": 2, "new_topic_count": 2},
|
|
{"date_utc": "2026-04-15", "topics_changed": True, "old_topic_count": 1, "new_topic_count": 1},
|
|
{"date_utc": "2026-04-27", "topics_changed": False, "old_topic_count": 1, "new_topic_count": 1},
|
|
]
|
|
|
|
def _stream(self, fake_events=None) -> list[dict]:
|
|
if fake_events is None:
|
|
fake_events = self._FAKE_EVENTS
|
|
|
|
cfg = MagicMock()
|
|
cfg.ollama_base_url = "http://localhost:11434"
|
|
cfg.ollama_chat_model = "test-model"
|
|
cfg.ollama_embed_model = None
|
|
cfg.sqlite_vss_path = None
|
|
|
|
# Patch at both import paths that the endpoint may resolve to.
|
|
with (
|
|
patch("src.desktop_app.memory_viewer._get_db_path", return_value=self.db_path),
|
|
patch("src.desktop_app.memory_viewer.load_settings", return_value=cfg),
|
|
patch("src.jarvis.memory.conversation.optimise_diary_topics", _make_fake_optimise(fake_events)),
|
|
patch("jarvis.memory.conversation.optimise_diary_topics", _make_fake_optimise(fake_events)),
|
|
):
|
|
resp = self.client.post("/api/diary/optimise-topics")
|
|
assert resp.status_code == 200
|
|
events = []
|
|
for line in resp.data.decode("utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
events.append(json.loads(line))
|
|
return events
|
|
|
|
def test_endpoint_streams_start_progress_complete(self):
|
|
events = self._stream()
|
|
types = [e["type"] for e in events]
|
|
assert types[0] == "start"
|
|
assert types[-1] == "complete"
|
|
assert types.count("progress") == 3
|
|
|
|
def test_endpoint_wraps_events_with_type_and_processed(self):
|
|
events = self._stream()
|
|
progress = [e for e in events if e["type"] == "progress"]
|
|
for i, ev in enumerate(progress, start=1):
|
|
assert ev["processed"] == i
|
|
assert ev["total"] == 3
|
|
assert "date_utc" in ev
|
|
assert "topics_changed" in ev
|
|
|
|
def test_endpoint_payload_never_includes_raw_tag_strings(self):
|
|
"""Privacy contract: streaming events must not echo tag values."""
|
|
events = self._stream()
|
|
forbidden = ["cook", "pasta", "workout", "python"]
|
|
for ev in events:
|
|
blob = json.dumps(ev).lower()
|
|
for needle in forbidden:
|
|
assert needle not in blob, (
|
|
f"tag value {needle!r} leaked into event: {ev}"
|
|
)
|
|
|
|
def test_progress_event_keys_are_a_known_whitelist(self):
|
|
"""Lock down the progress-event shape to catch accidental field additions
|
|
that could carry tag text through the streaming UI."""
|
|
events = self._stream()
|
|
allowed = {
|
|
"type", "processed", "total",
|
|
"date_utc", "topics_changed",
|
|
"old_topic_count", "new_topic_count",
|
|
"error", "embedding_refreshed",
|
|
}
|
|
for ev in events:
|
|
if ev.get("type") != "progress":
|
|
continue
|
|
unknown = set(ev.keys()) - allowed
|
|
assert not unknown, (
|
|
f"unexpected progress-event keys: {unknown}. Add to whitelist "
|
|
f"deliberately — any new field is a potential data exfiltration "
|
|
f"channel through the streaming UI."
|
|
)
|
|
|
|
def test_complete_event_reports_aggregate_counts(self):
|
|
events = self._stream()
|
|
complete = events[-1]
|
|
assert complete["type"] == "complete"
|
|
assert complete["rows"] == 3
|
|
assert complete["rows_changed"] == 2 # two events have topics_changed=True
|
|
assert isinstance(complete["topics_merged"], int)
|
|
assert isinstance(complete["topics_expanded"], int)
|
|
|
|
def test_complete_reports_zero_changed_when_all_tags_optimal(self):
|
|
no_change_events = [
|
|
{"date_utc": "2026-04-10", "topics_changed": False, "old_topic_count": 2, "new_topic_count": 2},
|
|
{"date_utc": "2026-04-15", "topics_changed": False, "old_topic_count": 1, "new_topic_count": 1},
|
|
]
|
|
events = self._stream(fake_events=no_change_events)
|
|
complete = events[-1]
|
|
assert complete["rows_changed"] == 0
|
|
|
|
def test_optimise_button_handler_wired_outside_graph_init(self):
|
|
"""Regression guard: btn-optimise-topics must be wired in the
|
|
always-run page setup, not inside initGraph() which only fires
|
|
when the user opens the Knowledge tab."""
|
|
html = self.client.get("/").get_data(as_text=True)
|
|
|
|
wiring = "document.getElementById('btn-optimise-topics')"
|
|
assert wiring in html, "optimise-topics button has no click handler in the rendered page"
|
|
|
|
wiring_idx = html.index(wiring)
|
|
init_graph_idx = html.index("async function initGraph()")
|
|
assert wiring_idx < init_graph_idx, (
|
|
"btn-optimise-topics wiring is nested inside initGraph(); "
|
|
"the button will not work until the user first opens the Knowledge tab"
|
|
)
|