mirror of
https://github.com/xr843/Master-skill.git
synced 2026-05-10 05:16:25 +00:00
test: add basic test suite for fojin_bridge, skill_writer, verify_sources (P1)
31 tests across 3 modules; all HTTP calls mocked, no real API dependencies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user