File size: 17,345 Bytes
85f900d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
"""
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                                                              #
# ------------------------------------------------------------------ #


@pytest.fixture
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)