Spaces:
Running
Running
| """ | |
| tests/test_phase5.py | |
| ==================== | |
| Phase 5 — UI, TTS & Access Control Tests | |
| Tests: | |
| - KBManager: create, list, get, delete, password verification, | |
| duplicate detection, slug validation | |
| - WebSpeech (TTS): prepare_for_tts citation stripping, refusal handling | |
| - CitationPanel: format_citations_markdown output structure | |
| - UI helpers: KB choice lists, KB table formatting | |
| - AppStartup: verify build_app does not crash with mock components | |
| Run with: pytest tests/test_phase5.py -v | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from voicevault.models import Citation, KnowledgeBase | |
| # ------------------------------------------------------------------ # | |
| # Fixtures # | |
| # ------------------------------------------------------------------ # | |
| def manager(tmp_path: Path): | |
| """KBManager backed by a temporary SQLite database.""" | |
| from voicevault.kb.kb_manager import KBManager | |
| db_path = tmp_path / "test.db" | |
| # Ensure parent exists (tmp_path always exists) | |
| return KBManager(db_path=db_path) | |
| def _make_citation( | |
| source_file: str = "doc.pdf", | |
| page_number: int = 1, | |
| section: str = "Intro", | |
| excerpt: str = "Some text here.", | |
| relevance_score: float = 0.8, | |
| ) -> Citation: | |
| return Citation( | |
| source_file=source_file, | |
| page_number=page_number, | |
| section=section, | |
| excerpt=excerpt, | |
| relevance_score=relevance_score, | |
| ) | |
| # ------------------------------------------------------------------ # | |
| # KBManager — Creation # | |
| # ------------------------------------------------------------------ # | |
| class TestKBManagerCreate: | |
| """KB creation, validation, and listing.""" | |
| def test_create_returns_knowledge_base(self, manager) -> None: | |
| kb = manager.create_kb("test-kb", "Test KB") | |
| assert isinstance(kb, KnowledgeBase) | |
| def test_create_sets_kb_name(self, manager) -> None: | |
| kb = manager.create_kb("my-kb", "My KB") | |
| assert kb.kb_name == "my-kb" | |
| def test_create_sets_display_name(self, manager) -> None: | |
| kb = manager.create_kb("my-kb", "My Knowledge Base") | |
| assert kb.display_name == "My Knowledge Base" | |
| def test_create_public_kb_has_no_hash(self, manager) -> None: | |
| kb = manager.create_kb("public-kb", "Public KB") | |
| assert kb.password_hash is None | |
| assert not kb.is_protected | |
| def test_create_protected_kb_has_hash(self, manager) -> None: | |
| kb = manager.create_kb("secure-kb", "Secure KB", password="secret123") | |
| assert kb.password_hash is not None | |
| assert kb.is_protected | |
| def test_create_single_char_name_valid(self, manager) -> None: | |
| kb = manager.create_kb("a", "Single Char") | |
| assert kb.kb_name == "a" | |
| def test_duplicate_name_raises(self, manager) -> None: | |
| from voicevault.kb.kb_manager import KBManagerError | |
| manager.create_kb("dup-kb", "First") | |
| with pytest.raises(KBManagerError, match="already exists"): | |
| manager.create_kb("dup-kb", "Second") | |
| def test_invalid_name_uppercase_raises(self, manager) -> None: | |
| from voicevault.kb.kb_manager import KBManagerError | |
| with pytest.raises(KBManagerError): | |
| manager.create_kb("MyKB", "Invalid") | |
| def test_invalid_name_spaces_raises(self, manager) -> None: | |
| from voicevault.kb.kb_manager import KBManagerError | |
| with pytest.raises(KBManagerError): | |
| manager.create_kb("my kb", "Invalid") | |
| def test_invalid_name_leading_hyphen_raises(self, manager) -> None: | |
| from voicevault.kb.kb_manager import KBManagerError | |
| with pytest.raises(KBManagerError): | |
| manager.create_kb("-my-kb", "Invalid") | |
| def test_invalid_name_trailing_hyphen_raises(self, manager) -> None: | |
| from voicevault.kb.kb_manager import KBManagerError | |
| with pytest.raises(KBManagerError): | |
| manager.create_kb("my-kb-", "Invalid") | |
| def test_list_kbs_initially_empty(self, manager) -> None: | |
| assert manager.list_kbs() == [] | |
| def test_list_kbs_after_create(self, manager) -> None: | |
| manager.create_kb("alpha", "Alpha") | |
| manager.create_kb("beta", "Beta") | |
| kbs = manager.list_kbs() | |
| assert len(kbs) == 2 | |
| def test_list_kbs_returns_knowledge_base_models(self, manager) -> None: | |
| manager.create_kb("kb1", "KB One") | |
| kbs = manager.list_kbs() | |
| assert all(isinstance(kb, KnowledgeBase) for kb in kbs) | |
| def test_get_kb_returns_none_for_unknown(self, manager) -> None: | |
| assert manager.get_kb("nonexistent") is None | |
| def test_get_kb_returns_correct_kb(self, manager) -> None: | |
| manager.create_kb("findme", "Find Me") | |
| kb = manager.get_kb("findme") | |
| assert kb is not None | |
| assert kb.kb_name == "findme" | |
| # ------------------------------------------------------------------ # | |
| # KBManager — Delete # | |
| # ------------------------------------------------------------------ # | |
| class TestKBManagerDelete: | |
| """KB deletion behavior.""" | |
| def test_delete_removes_from_list(self, manager) -> None: | |
| manager.create_kb("todelete", "To Delete") | |
| assert manager.get_kb("todelete") is not None | |
| manager.delete_kb("todelete") | |
| assert manager.get_kb("todelete") is None | |
| def test_delete_nonexistent_raises(self, manager) -> None: | |
| from voicevault.kb.kb_manager import KBManagerError | |
| with pytest.raises(KBManagerError): | |
| manager.delete_kb("does-not-exist") | |
| def test_delete_reduces_list_count(self, manager) -> None: | |
| manager.create_kb("kb-a", "KB A") | |
| manager.create_kb("kb-b", "KB B") | |
| manager.delete_kb("kb-a") | |
| assert len(manager.list_kbs()) == 1 | |
| # ------------------------------------------------------------------ # | |
| # KBManager — Password Verification # | |
| # ------------------------------------------------------------------ # | |
| class TestKBManagerPassword: | |
| """Password hashing and verification.""" | |
| def test_public_kb_accessible_without_password(self, manager) -> None: | |
| manager.create_kb("open-kb", "Open KB") | |
| assert manager.verify_password("open-kb", None) is True | |
| def test_public_kb_accessible_with_any_password(self, manager) -> None: | |
| manager.create_kb("open-kb", "Open KB") | |
| assert manager.verify_password("open-kb", "anything") is True | |
| def test_protected_kb_correct_password_returns_true(self, manager) -> None: | |
| manager.create_kb("secure-kb", "Secure KB", password="correct-pass") | |
| assert manager.verify_password("secure-kb", "correct-pass") is True | |
| def test_protected_kb_wrong_password_returns_false(self, manager) -> None: | |
| manager.create_kb("secure-kb", "Secure KB", password="correct-pass") | |
| assert manager.verify_password("secure-kb", "wrong-pass") is False | |
| def test_protected_kb_no_password_returns_false(self, manager) -> None: | |
| manager.create_kb("secure-kb", "Secure KB", password="correct-pass") | |
| assert manager.verify_password("secure-kb", None) is False | |
| def test_unknown_kb_returns_false(self, manager) -> None: | |
| assert manager.verify_password("ghost-kb", "any") is False | |
| def test_bcrypt_hash_stored_not_plaintext(self, manager) -> None: | |
| manager.create_kb("hashed-kb", "Hashed KB", password="secret") | |
| kb = manager.get_kb("hashed-kb") | |
| assert kb.password_hash != "secret" | |
| assert kb.password_hash.startswith("$2b$") or kb.password_hash.startswith("$2a$") | |
| # ------------------------------------------------------------------ # | |
| # KBManager — Query Stats # | |
| # ------------------------------------------------------------------ # | |
| class TestKBManagerStats: | |
| """Query stats from SQLite.""" | |
| def test_get_query_stats_returns_dict(self, manager) -> None: | |
| stats = manager.get_query_stats() | |
| assert isinstance(stats, dict) | |
| def test_get_query_stats_has_required_keys(self, manager) -> None: | |
| stats = manager.get_query_stats() | |
| assert "total_queries" in stats | |
| assert "avg_latency_ms" in stats | |
| assert "avg_citation_count" in stats | |
| assert "queries_by_day" in stats | |
| def test_get_query_stats_empty_db_returns_zeros(self, manager) -> None: | |
| stats = manager.get_query_stats() | |
| assert stats["total_queries"] == 0 | |
| # ------------------------------------------------------------------ # | |
| # TTS — prepare_for_tts # | |
| # ------------------------------------------------------------------ # | |
| class TestPreparForTTS: | |
| """Test citation stripping for TTS.""" | |
| def test_strips_source_citation_markers(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| text = "The accuracy was 94% [Source: report.pdf, p.3]." | |
| result = prepare_for_tts(text) | |
| assert "[Source:" not in result | |
| assert "94%" in result | |
| def test_strips_multiple_citation_markers(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| text = "First [Source: a.pdf, p.1]. Second [Source: b.pdf, p.2]." | |
| result = prepare_for_tts(text) | |
| assert "[Source:" not in result | |
| assert "First" in result | |
| assert "Second" in result | |
| def test_returns_empty_for_refusal(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| assert prepare_for_tts("I could not find this.", is_refusal=True) == "" | |
| def test_returns_empty_for_empty_string(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| assert prepare_for_tts("") == "" | |
| def test_normal_text_unchanged(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| text = "Machine learning is a form of artificial intelligence." | |
| assert prepare_for_tts(text) == text | |
| def test_no_double_spaces_after_stripping(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| text = "The result [Source: x.pdf, p.1] was good." | |
| result = prepare_for_tts(text) | |
| assert " " not in result | |
| def test_stripped_text_is_stripped(self) -> None: | |
| from voicevault.tts.web_speech import prepare_for_tts | |
| result = prepare_for_tts(" Hello world ") | |
| assert result == result.strip() | |
| # ------------------------------------------------------------------ # | |
| # Citation Panel # | |
| # ------------------------------------------------------------------ # | |
| class TestCitationPanel: | |
| """Verify Markdown formatting of citation panel.""" | |
| def test_empty_citations_returns_placeholder(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| result = format_citations_markdown([]) | |
| assert "No citations" in result | |
| def test_single_citation_includes_filename(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| citations = [_make_citation("paper.pdf", 3)] | |
| result = format_citations_markdown(citations) | |
| assert "paper.pdf" in result | |
| def test_single_citation_includes_page_number(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| citations = [_make_citation("paper.pdf", 7)] | |
| result = format_citations_markdown(citations) | |
| assert "7" in result | |
| def test_single_citation_includes_section(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| citations = [_make_citation(section="Methodology")] | |
| result = format_citations_markdown(citations) | |
| assert "Methodology" in result | |
| def test_single_citation_includes_excerpt(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| citations = [_make_citation(excerpt="Key finding here.")] | |
| result = format_citations_markdown(citations) | |
| assert "Key finding here." in result | |
| def test_multiple_citations_all_present(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| citations = [ | |
| _make_citation("alpha.pdf", 1), | |
| _make_citation("beta.pdf", 2), | |
| ] | |
| result = format_citations_markdown(citations) | |
| assert "alpha.pdf" in result | |
| assert "beta.pdf" in result | |
| def test_citations_numbered(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| citations = [_make_citation("doc.pdf", 1), _make_citation("doc.pdf", 2)] | |
| result = format_citations_markdown(citations) | |
| assert "[1]" in result | |
| assert "[2]" in result | |
| def test_output_is_string(self) -> None: | |
| from ui.components.citation_panel import format_citations_markdown | |
| assert isinstance(format_citations_markdown([]), str) | |
| # ------------------------------------------------------------------ # | |
| # UI Helper Functions # | |
| # ------------------------------------------------------------------ # | |
| class TestUIHelpers: | |
| """Test UI helper functions from ask_tab and kb_tab.""" | |
| def test_get_kb_choices_empty_when_no_kbs(self, manager) -> None: | |
| from ui.tabs.ask_tab import _get_kb_choices | |
| choices = _get_kb_choices(manager) | |
| assert choices == [] | |
| def test_get_kb_choices_lists_kb_names(self, manager) -> None: | |
| from ui.tabs.ask_tab import _get_kb_choices | |
| manager.create_kb("kb-one", "KB One") | |
| manager.create_kb("kb-two", "KB Two") | |
| choices = _get_kb_choices(manager) | |
| assert "kb-one" in choices | |
| assert "kb-two" in choices | |
| def test_get_kb_table_placeholder_when_empty(self, manager) -> None: | |
| from ui.tabs.kb_tab import _get_kb_table | |
| table = _get_kb_table(manager) | |
| assert len(table) == 1 | |
| assert "(none)" in table[0][0] | |
| def test_get_kb_table_row_per_kb(self, manager) -> None: | |
| from ui.tabs.kb_tab import _get_kb_table | |
| manager.create_kb("first-kb", "First KB") | |
| manager.create_kb("second-kb", "Second KB") | |
| table = _get_kb_table(manager) | |
| assert len(table) == 2 | |
| def test_get_kb_table_protected_shows_lock(self, manager) -> None: | |
| from ui.tabs.kb_tab import _get_kb_table | |
| manager.create_kb("locked-kb", "Locked KB", password="secret") | |
| table = _get_kb_table(manager) | |
| assert "🔒" in table[0][4] | |
| def test_append_chat_adds_both_messages(self) -> None: | |
| from ui.tabs.ask_tab import _append_chat | |
| chatbot = [{"role": "assistant", "content": "Hello!"}] | |
| result = _append_chat(chatbot, "user query", "assistant response") | |
| assert len(result) == 3 | |
| assert result[-2]["role"] == "user" | |
| assert result[-1]["role"] == "assistant" | |
| assert result[-2]["content"] == "user query" | |
| def test_append_chat_does_not_mutate_original(self) -> None: | |
| from ui.tabs.ask_tab import _append_chat | |
| chatbot = [] | |
| _append_chat(chatbot, "q", "a") | |
| assert chatbot == [] | |
| # ------------------------------------------------------------------ # | |
| # App Startup (Smoke Test) # | |
| # ------------------------------------------------------------------ # | |
| class TestAppStartup: | |
| """Verify app builds without errors using mock pipeline objects.""" | |
| def test_build_app_returns_blocks(self, tmp_path: Path) -> None: | |
| import gradio as gr | |
| from app import build_app | |
| from voicevault.kb.kb_manager import KBManager | |
| db_path = tmp_path / "test.db" | |
| kb_manager = KBManager(db_path=db_path) | |
| transcriber = MagicMock() | |
| answer_chain = MagicMock() | |
| app = build_app(kb_manager, transcriber, answer_chain) | |
| assert isinstance(app, gr.Blocks) | |
| def test_ask_tab_builds_without_error(self, tmp_path: Path) -> None: | |
| import gradio as gr | |
| from ui.tabs.ask_tab import build_ask_tab | |
| from voicevault.kb.kb_manager import KBManager | |
| db_path = tmp_path / "test.db" | |
| kb_manager = KBManager(db_path=db_path) | |
| with gr.Blocks(): | |
| build_ask_tab(MagicMock(), MagicMock(), kb_manager, db_path) | |
| def test_kb_tab_builds_without_error(self, tmp_path: Path) -> None: | |
| import gradio as gr | |
| from ui.tabs.kb_tab import build_kb_tab | |
| from voicevault.kb.kb_manager import KBManager | |
| db_path = tmp_path / "test.db" | |
| kb_manager = KBManager(db_path=db_path) | |
| with gr.Blocks(): | |
| build_kb_tab(kb_manager, db_path) | |
| def test_analytics_tab_builds_without_error(self, tmp_path: Path) -> None: | |
| import gradio as gr | |
| from ui.tabs.analytics_tab import build_analytics_tab | |
| from voicevault.kb.kb_manager import KBManager | |
| db_path = tmp_path / "test.db" | |
| kb_manager = KBManager(db_path=db_path) | |
| with gr.Blocks(): | |
| build_analytics_tab(kb_manager, db_path) | |