diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e0db6f2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +"""Shared pytest fixtures for Buddha-skill tests.""" + +import sys +import os +from pathlib import Path + +# Add tools/ to path so tests can import modules +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "tools")) diff --git a/tests/test_fojin_bridge.py b/tests/test_fojin_bridge.py new file mode 100644 index 0000000..3ab2c48 --- /dev/null +++ b/tests/test_fojin_bridge.py @@ -0,0 +1,89 @@ +"""Tests for fojin_bridge.py — uses mocked HTTP, no real API calls.""" + +import pytest +from unittest.mock import MagicMock, patch +from fojin_bridge import FojinBridge, FojinUnavailableError, create_bridge +import requests + + +@pytest.fixture +def bridge(): + return FojinBridge(mode="api", base_url="https://fojin.app") + + +def test_bridge_init_defaults(): + b = FojinBridge() + assert b.mode == "api" + assert b.base_url == "https://fojin.app" + + +def test_bridge_init_strips_trailing_slash(): + b = FojinBridge(base_url="https://fojin.app/") + assert b.base_url == "https://fojin.app" + + +def test_search_texts_basic(bridge): + mock_response = MagicMock() + mock_response.json.return_value = {"total": 1, "results": [{"id": 1}]} + mock_response.raise_for_status = MagicMock() + with patch.object(bridge.session, "get", return_value=mock_response) as mock_get: + result = bridge.search_texts("般若") + assert result["total"] == 1 + mock_get.assert_called_once() + call_args = mock_get.call_args + assert "q" in call_args.kwargs["params"] + assert call_args.kwargs["params"]["q"] == "般若" + + +def test_search_texts_with_filters(bridge): + mock_response = MagicMock() + mock_response.json.return_value = {"total": 0, "results": []} + mock_response.raise_for_status = MagicMock() + with patch.object(bridge.session, "get", return_value=mock_response) as mock_get: + bridge.search_texts("禅", sources="cbeta", lang="lzh", page=2, size=50) + params = mock_get.call_args.kwargs["params"] + assert params["sources"] == "cbeta" + assert params["lang"] == "lzh" + assert params["page"] == 2 + assert params["size"] == 50 + + +def test_get_text_content(bridge): + mock_response = MagicMock() + mock_response.json.return_value = {"content": "test content", "juan_num": 1} + mock_response.raise_for_status = MagicMock() + with patch.object(bridge.session, "get", return_value=mock_response): + result = bridge.get_text_content(123, 1) + assert result["content"] == "test content" + + +def test_search_kg_entities(bridge): + mock_response = MagicMock() + mock_response.json.return_value = {"total": 1, "results": [{"id": 456, "name_zh": "玄奘"}]} + mock_response.raise_for_status = MagicMock() + with patch.object(bridge.session, "get", return_value=mock_response): + result = bridge.search_kg_entities("玄奘", entity_type="person") + assert result["results"][0]["name_zh"] == "玄奘" + + +def test_fojin_unavailable_on_connection_error(bridge): + with patch.object(bridge.session, "get", side_effect=requests.ConnectionError("test")): + with pytest.raises(FojinUnavailableError): + bridge.search_texts("test") + + +def test_fojin_unavailable_on_timeout(bridge): + with patch.object(bridge.session, "get", side_effect=requests.Timeout("test")): + with pytest.raises(FojinUnavailableError): + bridge.get_text(123) + + +def test_test_connection_returns_false_on_failure(bridge): + with patch.object(bridge.session, "get", side_effect=requests.ConnectionError("test")): + assert bridge.test_connection() is False + + +def test_create_bridge_from_env(monkeypatch): + monkeypatch.setenv("FOJIN_URL", "https://custom.fojin.test") + b = create_bridge() + assert b.base_url == "https://custom.fojin.test" diff --git a/tests/test_skill_writer.py b/tests/test_skill_writer.py new file mode 100644 index 0000000..2771fa5 --- /dev/null +++ b/tests/test_skill_writer.py @@ -0,0 +1,134 @@ +"""Tests for skill_writer.py — uses tmp_path fixture.""" + +import json +import os +import pytest +from skill_writer import slugify, create_teacher, list_teachers, update_teacher, DISCLAIMER + + +def test_slugify_english(): + # With pypinyin installed, English chars are passed through as-is (no lowercasing); + # without pypinyin the fallback lowercases. Either result must be alphanumeric+hyphen. + result = slugify("Hello World") + assert all(c.isalnum() or c == "-" for c in result) + assert len(result) > 0 + + +def test_slugify_chinese(): + # Should use pypinyin if available, otherwise lowercase fallback + result = slugify("印光大师") + assert "-" in result or result.isalnum() + assert result.islower() + + +def test_slugify_strips_punctuation(): + # Punctuation is removed; case depends on pypinyin presence + result = slugify("Master!@#$") + assert all(c.isalnum() or c == "-" for c in result) + assert "master" in result.lower() + + +def test_create_teacher_writes_files(tmp_path): + teacher_dir = create_teacher( + base_dir=str(tmp_path), + name="测试法师", + tradition="汉传", + school="测试宗", + era="1900-2000", + languages=["zh"], + teaching_content="# 教义\n测试教义内容", + voice_content="# 风格\n测试风格内容", + ) + assert os.path.exists(os.path.join(teacher_dir, "teaching.md")) + assert os.path.exists(os.path.join(teacher_dir, "voice.md")) + assert os.path.exists(os.path.join(teacher_dir, "SKILL.md")) + assert os.path.exists(os.path.join(teacher_dir, "meta.json")) + assert os.path.exists(os.path.join(teacher_dir, "versions")) + + +def test_create_teacher_meta_content(tmp_path): + teacher_dir = create_teacher( + base_dir=str(tmp_path), + name="测试法师", + tradition="汉传", + school="测试宗", + era="1900-2000", + languages=["zh"], + teaching_content="教义", + voice_content="风格", + sources=[{"type": "cbeta", "id": "T01n0001"}], + ) + with open(os.path.join(teacher_dir, "meta.json"), encoding="utf-8") as f: + meta = json.load(f) + assert meta["name"] == "测试法师" + assert meta["tradition"] == "汉传" + assert meta["version"] == "1.0.0" + assert meta["disclaimer"] == DISCLAIMER + assert len(meta["sources"]) == 1 + + +def test_create_teacher_skill_md_includes_content(tmp_path): + teacher_dir = create_teacher( + base_dir=str(tmp_path), + name="测试法师", + tradition="汉传", + school="测试宗", + era="1900-2000", + languages=["zh"], + teaching_content="UNIQUE_TEACHING_MARKER", + voice_content="UNIQUE_VOICE_MARKER", + ) + with open(os.path.join(teacher_dir, "SKILL.md"), encoding="utf-8") as f: + content = f.read() + assert "UNIQUE_TEACHING_MARKER" in content + assert "UNIQUE_VOICE_MARKER" in content + assert "master_" in content # frontmatter + + +def test_list_teachers_empty(tmp_path): + assert list_teachers(str(tmp_path)) == [] + + +def test_list_teachers_finds_created(tmp_path): + create_teacher( + base_dir=str(tmp_path), + name="法师一", tradition="汉传", school="宗A", + era="1900", languages=["zh"], + teaching_content="a", voice_content="b", + ) + create_teacher( + base_dir=str(tmp_path), + name="法师二", tradition="汉传", school="宗B", + era="1950", languages=["zh"], + teaching_content="a", voice_content="b", + ) + teachers = list_teachers(str(tmp_path)) + assert len(teachers) == 2 + names = {t["name"] for t in teachers} + assert names == {"法师一", "法师二"} + + +def test_update_teacher_bumps_version(tmp_path): + teacher_dir = create_teacher( + base_dir=str(tmp_path), + name="测试", tradition="汉传", school="宗", + era="1900", languages=["zh"], + teaching_content="原教义", voice_content="原风格", + ) + new_version = update_teacher(teacher_dir, teaching_patch="补充教义") + assert new_version == "1.1.0" + with open(os.path.join(teacher_dir, "teaching.md"), encoding="utf-8") as f: + content = f.read() + assert "原教义" in content + assert "补充教义" in content + + +def test_update_teacher_archives_version(tmp_path): + teacher_dir = create_teacher( + base_dir=str(tmp_path), + name="测试", tradition="汉传", school="宗", + era="1900", languages=["zh"], + teaching_content="v1", voice_content="v1", + ) + update_teacher(teacher_dir, teaching_patch="update") + assert os.path.exists(os.path.join(teacher_dir, "versions", "v1.0.0")) diff --git a/tests/test_verify_sources.py b/tests/test_verify_sources.py new file mode 100644 index 0000000..8e4b6a2 --- /dev/null +++ b/tests/test_verify_sources.py @@ -0,0 +1,66 @@ +"""Tests for verify_sources.py — pure logic, no API calls.""" + +import pytest +from verify_sources import full_to_short_cbeta, FULL_CBETA_RE, FOJIN_URL_RE + + +def test_verify_sources_module_imports(): + """Verify verify_sources.py can be imported without errors.""" + import verify_sources + assert callable(getattr(verify_sources, "main", None)) + + +def test_full_to_short_cbeta_t_series(): + assert full_to_short_cbeta("T08n0235") == "T0235" + + +def test_full_to_short_cbeta_x_series(): + assert full_to_short_cbeta("X62n1182") == "X1182" + + +def test_full_to_short_cbeta_j_series(): + assert full_to_short_cbeta("J36n0348") == "J0348" + + +def test_full_to_short_cbeta_strips_volume_number(): + # Volume number (middle digits) must be dropped + assert full_to_short_cbeta("T34n1718") == "T1718" + assert full_to_short_cbeta("T01n0001") == "T0001" + + +def test_full_to_short_cbeta_invalid_returns_none(): + assert full_to_short_cbeta("invalid") is None + assert full_to_short_cbeta("") is None + assert full_to_short_cbeta("123") is None + + +def test_cbeta_id_format_recognition(): + """CBETA IDs follow format like T48n2008, X62n1182, J36n0348.""" + valid_ids = ["T48n2008", "X62n1182", "J36n0348", "T01n0001"] + for cbeta_id in valid_ids: + assert FULL_CBETA_RE.match(cbeta_id), f"{cbeta_id} should match FULL_CBETA_RE" + + +def test_cbeta_id_rejects_invalid(): + invalid_ids = ["T48", "n2008", "abc123", "t48n2008"] # lowercase prefix is invalid + for cbeta_id in invalid_ids: + assert not FULL_CBETA_RE.match(cbeta_id), f"{cbeta_id} should not match FULL_CBETA_RE" + + +def test_fojin_url_re_matches_cbeta_url(): + line = "See https://fojin.app/texts/T08n0235 for reference" + m = FOJIN_URL_RE.search(line) + assert m is not None + assert m.group(2) == "T08n0235" + + +def test_fojin_url_re_matches_numeric_id(): + line = "Link: https://fojin.app/texts/12345" + m = FOJIN_URL_RE.search(line) + assert m is not None + assert m.group(2) == "12345" + + +def test_fojin_url_re_no_match_on_unrelated_url(): + line = "Visit https://example.com/texts/something" + assert FOJIN_URL_RE.search(line) is None