import unittest
from unittest.mock import MagicMock, patch, ANY, mock_open
import sys
import os
import json
import asyncio
import numpy as np
import torch
import gradio as gr
# Add project root to path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# Mock heavy dependencies before importing app
with patch('transformers.AutoProcessor.from_pretrained'), \
patch('transformers.Gemma3ForConditionalGeneration.from_pretrained'), \
patch('langchain_huggingface.HuggingFaceEmbeddings'), \
patch('langchain_community.vectorstores.FAISS'):
import app
from app import (
detect_language, build_agent_prompt, get_device, get_embedding_function, get_llm,
extract_text_from_file, get_text_splitter, index_files, clear_index,
retrieve_relevant_chunks, build_rag_prompt, chat_agent_stream,
get_whisper, transcribe_audio, generate_speech, voice_chat_wrapper,
chat_wrapper, stream_handler, build_demo
)
class TestSageFullCoverage(unittest.TestCase):
# --- Group 1: Utils & ML Logic ---
@patch('app.get_llm')
def test_detect_language(self, mock_get_llm):
mock_model = MagicMock()
mock_processor = MagicMock()
mock_get_llm.return_value = (mock_model, mock_processor)
# Test messy output
mock_processor.batch_decode.return_value = ["The language used here is clearly German, my seeker."]
lang = detect_language("Hallo")
self.assertEqual(lang, "German")
# Test fallback
mock_processor.batch_decode.return_value = ["I am not sure."]
lang = detect_language("???")
self.assertEqual(lang, "English")
def test_build_agent_prompt(self):
prompt = build_agent_prompt("Hebrew", "Context content")
self.assertIn("Hebrew", prompt)
self.assertIn("Context content", prompt)
self.assertIn("Sacred Sage", prompt)
def test_get_device(self):
device = get_device()
self.assertIsInstance(device, torch.device)
@patch('app.HuggingFaceEmbeddings')
def test_get_embedding_function(self, mock_emb):
# Reset global
app.EMBEDDING_FUNCTION = None
func = get_embedding_function()
self.assertIsNotNone(func)
mock_emb.assert_called_once()
@patch('app.AutoProcessor.from_pretrained')
@patch('app.Gemma3ForConditionalGeneration.from_pretrained')
def test_get_llm(self, mock_model, mock_proc):
app.LLM_MODEL = None
app.LLM_PROCESSOR = None
m, p = get_llm()
self.assertIsNotNone(m)
self.assertIsNotNone(p)
@patch('app.PdfReader')
def test_extract_text_from_file_pdf(self, mock_pdf):
mock_reader = mock_pdf.return_value
mock_reader.pages = [MagicMock(extract_text=lambda: "Page 1 content")]
text = extract_text_from_file("test.pdf")
self.assertEqual(text, "Page 1 content")
@patch('builtins.open', new_callable=mock_open, read_data="Text content")
def test_extract_text_from_file_txt(self, mock_file):
text = extract_text_from_file("test.txt")
self.assertEqual(text, "Text content")
def test_get_text_splitter(self):
splitter = get_text_splitter()
self.assertIsNotNone(splitter)
# --- Group 2: RAG & Indexing ---
@patch('app.extract_text_from_file')
@patch('app.get_text_splitter')
@patch('app.FAISS')
@patch('app.MongoDBHandler')
def test_index_files(self, mock_mongo, mock_faiss, mock_splitter, mock_extract):
mock_extract.return_value = "Long text content"
mock_splitter.return_value.split_text.return_value = ["chunk1", "chunk2"]
# Mock FAISS from_documents result and its index.ntotal
mock_store = MagicMock()
mock_store.index.ntotal = 2
mock_faiss.from_documents.return_value = mock_store
status, vs, mh = index_files(["file1.txt"], "uri", "db", "coll", True, None, None)
self.assertIn("Index aktualisiert", status)
mock_faiss.from_documents.assert_called()
def test_clear_index(self):
status, vs, mh = clear_index()
self.assertEqual(status, "Index geleert.")
self.assertIsNone(vs)
self.assertIsNone(mh)
def test_retrieve_relevant_chunks(self):
mock_vs = MagicMock()
mock_vs.similarity_search_with_score.return_value = [
(MagicMock(page_content="hit", metadata={"source": "doc1"}), 0.1)
]
results = retrieve_relevant_chunks("query", mock_vs, None)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["content"], "hit")
def test_build_rag_prompt(self):
chunks = [{"content": "c1", "source": "s1"}]
prompt = build_rag_prompt("question", chunks)
self.assertIn("c1", prompt)
self.assertIn("question", prompt)
# --- Group 3: Audio & Voice ---
@patch('whisper.load_model')
def test_get_whisper(self, mock_load):
app.WHISPER_MODEL = None
w = get_whisper()
self.assertIsNotNone(w)
mock_load.assert_called_once()
@patch('app.get_whisper')
def test_transcribe_audio(self, mock_get_w):
mock_w = mock_get_w.return_value
mock_w.transcribe.return_value = {"text": "Transcribed text"}
text = transcribe_audio("audio.wav")
self.assertEqual(text, "Transcribed text")
@patch('edge_tts.Communicate')
def test_generate_speech(self, mock_comm):
# Async test
mock_inst = MagicMock()
mock_comm.return_value = mock_inst
mock_inst.save = MagicMock(return_value=asyncio.Future())
mock_inst.save.return_value.set_result(None)
loop = asyncio.get_event_loop()
path = loop.run_until_complete(generate_speech("text", "German"))
self.assertTrue(path.endswith(".mp3"))
def test_stream_handler_silence(self):
# Test VAD logic transition to silence
sr = 16000
y = np.zeros(1024, dtype=np.int16)
state = {"buffer": [], "silence_counter": 0, "is_speaking": True}
new_state, audio_path = stream_handler((sr, y), state)
self.assertEqual(new_state["silence_counter"], 1)
self.assertIsNone(audio_path)
# --- Group 4: Actions & Orchestration ---
@patch('app.get_llm')
@patch('app.retrieve_relevant_chunks')
@patch('app.detect_language')
def test_chat_agent_stream(self, mock_detect, mock_rag, mock_get_llm):
mock_model = MagicMock()
mock_processor = MagicMock()
mock_get_llm.return_value = (mock_model, mock_processor)
mock_rag.return_value = []
mock_detect.return_value = "English"
# Generator test
gen = chat_agent_stream("msg", [], None, None)
self.assertTrue(hasattr(gen, '__next__'))
@patch('app.get_llm')
@patch('app.TextIteratorStreamer')
@patch('app.retrieve_relevant_chunks')
@patch('app.detect_language')
def test_purification(self, mock_detect, mock_rag, mock_streamer, mock_get_llm):
mock_model = MagicMock()
mock_processor = MagicMock()
mock_get_llm.return_value = (mock_model, mock_processor)
mock_rag.return_value = []
mock_detect.return_value = "English"
# Mock streamer yielding a tool call
mock_inst = mock_streamer.return_value
mock_inst.__iter__.return_value = ["Hello", " {\"name\":\"test\"}", " World"]
gen = chat_agent_stream("msg", [], None, None)
responses = list(gen)
# Final response should NOT contain the tool_call tags
for r in responses:
self.assertNotIn("", r)
self.assertIn("Hello", responses[-1])
self.assertIn("World", responses[-1])
@patch('app.chat_wrapper')
@patch('app.transcribe_audio')
@patch('app.generate_speech')
@patch('app.detect_language')
def test_voice_chat_wrapper(self, mock_detect, mock_tts, mock_stt, mock_chat):
mock_detect.return_value = "English"
mock_stt.return_value = "Hello"
# Async mock logic
async def mock_gen(t, lang="English"):
return "out.mp3"
mock_tts.side_effect = mock_gen
# history must have content for TTS to trigger
hist = [{"role": "assistant", "content": "Response"}]
# New yield: (h, t, upd_d, upd_m, a)
mock_chat.return_value = iter([(hist, {}, gr.update(), gr.update(), None)])
gen = voice_chat_wrapper("in.wav", [], {}, "tid", None, None)
res = None
for r in gen:
res = r
self.assertEqual(res[4], "out.mp3")
@patch('app.chat_agent_stream')
def test_chat_wrapper(self, mock_agent):
mock_agent.return_value = iter(["Part 1", "Part 2"])
history = []
threads = {}
gen = chat_wrapper("hello", history, threads, "tid", None, None)
# Yields: h, t, upd_d, upd_m, a
for h, t, ud, um, a in gen:
pass
self.assertEqual(history[-1]["content"], "Part 2")
self.assertIn("tid", threads)
# --- Group 5: UI Bindings & Internal Callbacks ---
def test_build_demo(self):
demo = build_demo()
self.assertIsInstance(demo, gr.Blocks)
def test_ui_callbacks(self):
# Use app-level handles
import app
# switch_thread(tid, t_state) -> hist, tid, upd_d, upd_m
h, tid, ud, um = app.switch_thread("tid", {"tid": {"history": ["msg"]}})
self.assertEqual(h, ["msg"])
self.assertEqual(tid, "tid")
# create_new_thread_callback(threads) -> threads, nid, upd, hist
threads, nid, update, hist = app.create_new_thread_callback({})
self.assertEqual(len(threads), 1)
self.assertEqual(hist, [])
# session_import_handler(file) -> hist, threads, tid, upd_d, upd_m
# (Mocking open for session_import_handler if needed, but here testing switch behavior)
# --- Group 6: Auxiliary Modules (Exhaustive) ---
def test_format_wisdom(self):
from spiritual_bridge import format_wisdom
match = ("words", "trans", "book", 1, 1)
res = format_wisdom(match, "test_cat")
self.assertEqual(res["category"], "test_cat")
self.assertEqual(res["reference"], "book 1:1")
@patch('pymongo.MongoClient')
def test_mongodb_handler_full(self, mock_client):
from mongochain import MongoDBHandler
handler = MongoDBHandler()
handler.collection = MagicMock()
# Test clear
handler.clear()
handler.collection.delete_many.assert_called()
# Test get_stats
handler.collection.count_documents.return_value = 10
stats = handler.get_stats()
self.assertEqual(stats["count"], 10)
# Test close
handler.client = MagicMock()
handler.close()
handler.client.close.assert_called()
if __name__ == '__main__':
unittest.main()