Add atomic writes, magic string constants, unit tests, type hints, and fix navigation
- save_json() now writes to a temp file then uses os.replace() for atomic writes - Replace hardcoded "batch_data", "history_tree", "prompt_history", "sequence_number" strings with constants (KEY_BATCH_DATA, etc.) across all modules - Add 29 unit tests for history_tree, utils, and json_loader - Add type hints to public functions in utils.py, json_loader.py, history_tree.py - Remove ALLOWED_BASE_DIR restriction that blocked navigating outside app CWD - Fix path text input not updating on navigation by using session state key - Add unpin button (❌) for removing pinned folders Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to sys.path so tests can import project modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
1
tests/pytest.ini
Normal file
1
tests/pytest.ini
Normal file
@@ -0,0 +1 @@
|
||||
[pytest]
|
||||
67
tests/test_history_tree.py
Normal file
67
tests/test_history_tree.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import pytest
|
||||
from history_tree import HistoryTree
|
||||
|
||||
|
||||
def test_commit_creates_node_with_correct_parent():
|
||||
tree = HistoryTree({})
|
||||
id1 = tree.commit({"a": 1}, note="first")
|
||||
id2 = tree.commit({"b": 2}, note="second")
|
||||
|
||||
assert tree.nodes[id1]["parent"] is None
|
||||
assert tree.nodes[id2]["parent"] == id1
|
||||
|
||||
|
||||
def test_checkout_returns_correct_data():
|
||||
tree = HistoryTree({})
|
||||
id1 = tree.commit({"val": 42}, note="snap")
|
||||
result = tree.checkout(id1)
|
||||
assert result == {"val": 42}
|
||||
|
||||
|
||||
def test_checkout_nonexistent_returns_none():
|
||||
tree = HistoryTree({})
|
||||
assert tree.checkout("nonexistent") is None
|
||||
|
||||
|
||||
def test_cycle_detection_raises():
|
||||
tree = HistoryTree({})
|
||||
id1 = tree.commit({"a": 1})
|
||||
# Manually introduce a cycle
|
||||
tree.nodes[id1]["parent"] = id1
|
||||
with pytest.raises(ValueError, match="Cycle detected"):
|
||||
tree.commit({"b": 2})
|
||||
|
||||
|
||||
def test_branch_creation_on_detached_head():
|
||||
tree = HistoryTree({})
|
||||
id1 = tree.commit({"a": 1})
|
||||
id2 = tree.commit({"b": 2})
|
||||
# Detach head by checking out a non-tip node
|
||||
tree.checkout(id1)
|
||||
# head_id is now id1, which is no longer a branch tip (main points to id2)
|
||||
id3 = tree.commit({"c": 3})
|
||||
# A new branch should have been created
|
||||
assert len(tree.branches) == 2
|
||||
assert tree.nodes[id3]["parent"] == id1
|
||||
|
||||
|
||||
def test_legacy_migration():
|
||||
legacy = {
|
||||
"prompt_history": [
|
||||
{"note": "Entry A", "seed": 1},
|
||||
{"note": "Entry B", "seed": 2},
|
||||
]
|
||||
}
|
||||
tree = HistoryTree(legacy)
|
||||
assert len(tree.nodes) == 2
|
||||
assert tree.head_id is not None
|
||||
assert tree.branches["main"] == tree.head_id
|
||||
|
||||
|
||||
def test_to_dict_roundtrip():
|
||||
tree = HistoryTree({})
|
||||
tree.commit({"x": 1}, note="test")
|
||||
d = tree.to_dict()
|
||||
tree2 = HistoryTree(d)
|
||||
assert tree2.head_id == tree.head_id
|
||||
assert tree2.nodes == tree.nodes
|
||||
68
tests/test_json_loader.py
Normal file
68
tests/test_json_loader.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from json_loader import to_float, to_int, get_batch_item, read_json_data
|
||||
|
||||
|
||||
class TestToFloat:
|
||||
def test_valid(self):
|
||||
assert to_float("3.14") == 3.14
|
||||
assert to_float(5) == 5.0
|
||||
|
||||
def test_invalid(self):
|
||||
assert to_float("abc") == 0.0
|
||||
|
||||
def test_none(self):
|
||||
assert to_float(None) == 0.0
|
||||
|
||||
|
||||
class TestToInt:
|
||||
def test_valid(self):
|
||||
assert to_int("7") == 7
|
||||
assert to_int(3.9) == 3
|
||||
|
||||
def test_invalid(self):
|
||||
assert to_int("xyz") == 0
|
||||
|
||||
def test_none(self):
|
||||
assert to_int(None) == 0
|
||||
|
||||
|
||||
class TestGetBatchItem:
|
||||
def test_valid_index(self):
|
||||
data = {"batch_data": [{"a": 1}, {"a": 2}, {"a": 3}]}
|
||||
assert get_batch_item(data, 2) == {"a": 2}
|
||||
|
||||
def test_clamp_high(self):
|
||||
data = {"batch_data": [{"a": 1}, {"a": 2}]}
|
||||
assert get_batch_item(data, 99) == {"a": 2}
|
||||
|
||||
def test_clamp_low(self):
|
||||
data = {"batch_data": [{"a": 1}, {"a": 2}]}
|
||||
assert get_batch_item(data, 0) == {"a": 1}
|
||||
|
||||
def test_no_batch_data(self):
|
||||
data = {"key": "val"}
|
||||
assert get_batch_item(data, 1) == data
|
||||
|
||||
|
||||
class TestReadJsonData:
|
||||
def test_missing_file(self, tmp_path):
|
||||
assert read_json_data(str(tmp_path / "nope.json")) == {}
|
||||
|
||||
def test_invalid_json(self, tmp_path):
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("{broken")
|
||||
assert read_json_data(str(p)) == {}
|
||||
|
||||
def test_non_dict_json(self, tmp_path):
|
||||
p = tmp_path / "list.json"
|
||||
p.write_text(json.dumps([1, 2, 3]))
|
||||
assert read_json_data(str(p)) == {}
|
||||
|
||||
def test_valid(self, tmp_path):
|
||||
p = tmp_path / "ok.json"
|
||||
p.write_text(json.dumps({"key": "val"}))
|
||||
assert read_json_data(str(p)) == {"key": "val"}
|
||||
68
tests/test_utils.py
Normal file
68
tests/test_utils.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Mock streamlit before importing utils
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
sys.modules.setdefault("streamlit", MagicMock())
|
||||
|
||||
from utils import load_json, save_json, get_file_mtime, ALLOWED_BASE_DIR, DEFAULTS
|
||||
|
||||
|
||||
def test_load_json_valid(tmp_path):
|
||||
p = tmp_path / "test.json"
|
||||
data = {"key": "value"}
|
||||
p.write_text(json.dumps(data))
|
||||
result, mtime = load_json(p)
|
||||
assert result == data
|
||||
assert mtime > 0
|
||||
|
||||
|
||||
def test_load_json_missing(tmp_path):
|
||||
p = tmp_path / "nope.json"
|
||||
result, mtime = load_json(p)
|
||||
assert result == DEFAULTS.copy()
|
||||
assert mtime == 0
|
||||
|
||||
|
||||
def test_load_json_invalid(tmp_path):
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("{not valid json")
|
||||
result, mtime = load_json(p)
|
||||
assert result == DEFAULTS.copy()
|
||||
assert mtime == 0
|
||||
|
||||
|
||||
def test_save_json_atomic(tmp_path):
|
||||
p = tmp_path / "out.json"
|
||||
data = {"hello": "world"}
|
||||
save_json(p, data)
|
||||
assert p.exists()
|
||||
assert not p.with_suffix(".json.tmp").exists()
|
||||
assert json.loads(p.read_text()) == data
|
||||
|
||||
|
||||
def test_save_json_overwrites(tmp_path):
|
||||
p = tmp_path / "out.json"
|
||||
save_json(p, {"a": 1})
|
||||
save_json(p, {"b": 2})
|
||||
assert json.loads(p.read_text()) == {"b": 2}
|
||||
|
||||
|
||||
def test_get_file_mtime_existing(tmp_path):
|
||||
p = tmp_path / "f.txt"
|
||||
p.write_text("x")
|
||||
assert get_file_mtime(p) > 0
|
||||
|
||||
|
||||
def test_get_file_mtime_missing(tmp_path):
|
||||
assert get_file_mtime(tmp_path / "missing.txt") == 0
|
||||
|
||||
|
||||
def test_allowed_base_dir_is_set():
|
||||
assert ALLOWED_BASE_DIR is not None
|
||||
assert isinstance(ALLOWED_BASE_DIR, Path)
|
||||
Reference in New Issue
Block a user