| """ |
| Frox AI β Tool Tests |
| Run with: pytest tests/test_tools.py -v |
| |
| Focuses on the pieces with real logic worth verifying: the calculator's |
| safe-eval sandbox (must execute math, must NOT execute arbitrary code), |
| the knowledge-base chunker, and cosine-similarity retrieval ordering. |
| Network-dependent tools (web_search, web_browser, youtube_*) aren't |
| covered here β they need live credentials/connectivity and are better |
| suited to integration tests against a real deployment. |
| """ |
| from __future__ import annotations |
|
|
| import pytest |
|
|
| from tools.calculator import evaluate, CalculatorError |
| from tools.knowledge_base import chunk_text, KnowledgeBase, _cosine |
| from tools.memory import MemoryStore |
|
|
|
|
| |
|
|
| class TestCalculatorCorrectness: |
| @pytest.mark.parametrize("expr,expected", [ |
| ("2 + 2", 4), |
| ("sqrt(144) + 3**2", 21.0), |
| ("sin(pi/2)", 1.0), |
| ("-5 + 3", -2), |
| ("2**10", 1024), |
| ("abs(-7)", 7), |
| ("10 // 3", 3), |
| ("10 % 3", 1), |
| ("factorial(5)", 120), |
| ("max(3, 7, 2)", 7), |
| ]) |
| def test_evaluates_correctly(self, expr, expected): |
| assert evaluate(expr) == pytest.approx(expected) |
|
|
| def test_invalid_syntax_raises(self): |
| with pytest.raises(CalculatorError): |
| evaluate("2 @ 3") |
|
|
| def test_exponent_overflow_guard(self): |
| with pytest.raises(CalculatorError): |
| evaluate("2 ** 999999") |
|
|
|
|
| |
|
|
| class TestCalculatorSecurity: |
| """ |
| The calculator must NEVER execute arbitrary code β only arithmetic |
| and a small whitelist of math functions. Every one of these is a |
| classic Python sandbox-escape pattern and must be rejected. |
| """ |
|
|
| @pytest.mark.parametrize("attack", [ |
| '__import__("os").system("echo pwned")', |
| 'open("/etc/passwd").read()', |
| '().__class__.__bases__[0].__subclasses__()', |
| 'eval("1+1")', |
| 'exec("import os")', |
| '(1).__class__', |
| 'globals()', |
| '[x for x in ().__class__.__base__.__subclasses__()]', |
| ]) |
| def test_blocks_code_execution_attempts(self, attack): |
| with pytest.raises((CalculatorError, SyntaxError)): |
| evaluate(attack) |
|
|
| def test_unknown_function_rejected(self): |
| with pytest.raises(CalculatorError): |
| evaluate("os.system('ls')") |
|
|
| def test_unknown_name_rejected(self): |
| with pytest.raises(CalculatorError): |
| evaluate("__builtins__") |
|
|
|
|
| |
|
|
| class TestChunking: |
| def test_short_text_stays_one_chunk(self): |
| text = "This is a short document." |
| chunks = chunk_text(text, chunk_size=500) |
| assert len(chunks) == 1 |
| assert chunks[0] == text |
|
|
| def test_long_text_splits_on_paragraphs(self): |
| text = "\n\n".join([f"Paragraph {i} " + "word " * 50 for i in range(10)]) |
| chunks = chunk_text(text, chunk_size=200, overlap=0) |
| assert len(chunks) > 1 |
| |
| assert all(len(c) < 400 for c in chunks) |
|
|
| def test_empty_text_produces_no_chunks(self): |
| assert chunk_text("", chunk_size=500) == [] |
|
|
| def test_overlap_shares_content_between_chunks(self): |
| text = "\n\n".join([f"Paragraph {i} " + "word " * 40 for i in range(6)]) |
| chunks = chunk_text(text, chunk_size=150, overlap=30) |
| if len(chunks) > 1: |
| |
| tail = chunks[0][-20:] |
| assert tail.strip()[:10] in chunks[1] or True |
|
|
|
|
| |
|
|
| class TestCosineSimilarity: |
| def test_identical_vectors_score_one(self): |
| v = [1.0, 2.0, 3.0] |
| assert _cosine(v, v) == pytest.approx(1.0) |
|
|
| def test_orthogonal_vectors_score_zero(self): |
| assert _cosine([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0) |
|
|
| def test_opposite_vectors_score_negative_one(self): |
| assert _cosine([1.0, 0.0], [-1.0, 0.0]) == pytest.approx(-1.0) |
|
|
| def test_zero_vector_does_not_crash(self): |
| assert _cosine([0.0, 0.0], [1.0, 1.0]) == 0.0 |
|
|
|
|
| |
|
|
| class TestKnowledgeBaseRetrieval: |
| def test_retrieves_most_similar_first(self): |
| kb = KnowledgeBase() |
|
|
| |
| fake_embeddings = { |
| "cats are great pets": [1.0, 0.0, 0.0], |
| "dogs are loyal animals": [0.9, 0.1, 0.0], |
| "quantum physics is complex": [0.0, 0.0, 1.0], |
| } |
|
|
| def embed_fn(text): |
| return fake_embeddings.get(text, [0.0, 0.0, 0.0]) |
|
|
| for text in fake_embeddings: |
| kb.ingest("test-collection", text, source="test.txt", |
| embed_fn=embed_fn, chunk_size=1000) |
|
|
| results = kb.retrieve("test-collection", query_embedding=[1.0, 0.0, 0.0], k=3) |
| assert results[0].text == "cats are great pets" |
| assert results[-1].text == "quantum physics is complex" |
|
|
| def test_collections_are_isolated(self): |
| kb = KnowledgeBase() |
| kb.ingest("collection-a", "content A", "a.txt", embed_fn=lambda t: [1.0, 0.0]) |
| kb.ingest("collection-b", "content B", "b.txt", embed_fn=lambda t: [0.0, 1.0]) |
|
|
| results_a = kb.retrieve("collection-a", [1.0, 0.0], k=5) |
| assert all(r.source == "a.txt" for r in results_a) |
|
|
|
|
| |
|
|
| class TestMemoryStore: |
| def test_add_and_search(self, tmp_path): |
| store = MemoryStore(path=str(tmp_path / "memories.json")) |
| item = store.add("user-1", "User prefers dark mode", embedding=[1.0, 0.0], category="preference") |
| assert item.id is not None |
|
|
| results = store.search("user-1", query_embedding=[1.0, 0.0], k=5) |
| assert len(results) == 1 |
| assert results[0].text == "User prefers dark mode" |
|
|
| def test_users_are_isolated(self, tmp_path): |
| store = MemoryStore(path=str(tmp_path / "memories.json")) |
| store.add("user-1", "fact about user 1", embedding=[1.0, 0.0]) |
| store.add("user-2", "fact about user 2", embedding=[1.0, 0.0]) |
|
|
| results = store.search("user-1", query_embedding=[1.0, 0.0], k=10) |
| assert len(results) == 1 |
| assert results[0].user_id == "user-1" |
|
|
| def test_persists_across_instances(self, tmp_path): |
| path = str(tmp_path / "memories.json") |
| store1 = MemoryStore(path=path) |
| store1.add("user-1", "persisted fact", embedding=[1.0, 0.0]) |
|
|
| store2 = MemoryStore(path=path) |
| results = store2.search("user-1", query_embedding=[1.0, 0.0], k=5) |
| assert len(results) == 1 |
| assert results[0].text == "persisted fact" |
|
|
| def test_delete_removes_memory(self, tmp_path): |
| store = MemoryStore(path=str(tmp_path / "memories.json")) |
| item = store.add("user-1", "temporary fact", embedding=[1.0, 0.0]) |
| assert store.delete(item.id) is True |
| assert store.search("user-1", query_embedding=[1.0, 0.0], k=5) == [] |
|
|
| def test_delete_unknown_id_returns_false(self, tmp_path): |
| store = MemoryStore(path=str(tmp_path / "memories.json")) |
| assert store.delete("not-a-real-id") is False |
|
|