import asyncio from pathlib import Path import zipfile from docx import Document import app.main as main from app.main import OUTPUT_DIR, ROOT_DIR, analyze_saved_docx, app class FakeUploadFile: def __init__(self, filename: str, content: bytes) -> None: self.filename = filename self._content = content async def read(self) -> bytes: return self._content def _write_skill_collection_zip(path: Path) -> None: with zipfile.ZipFile(path, "w") as archive: archive.writestr( "index.md", "| Skill | Description | Use When |\n" "| --- | --- | --- |\n" "| [demo-skill](demo-skill/SKILL.md) | 示例技能 | 上传合集测试 |\n", ) archive.writestr( "demo-skill/SKILL.md", "---\n" "name: demo-skill\n" "description: 示例技能\n" "---\n" "# Demo Skill\n", ) def test_index_template_contains_upload_ui() -> None: html = (ROOT_DIR / "app" / "templates" / "index.html").read_text(encoding="utf-8") js = (ROOT_DIR / "app" / "static" / "app.js").read_text(encoding="utf-8") assert "DOCX 规范分析" in html assert 'type="file"' in html assert "analysis-progress" in html assert "analysis-status" in html assert "下载 Markdown 报告" in html assert "download-md" in js assert "pollTask" in js assert "skill_collection" in html assert "skill-upload-form" in html assert "/skill-collections/upload" in js assert "预留后续版本:单个技能集合内的 skill 筛选功能" in html assert not any(route.path == "/skills" for route in app.routes) def test_skill_collection_options_discover_added_directory(tmp_path: Path, monkeypatch) -> None: skills_root = tmp_path / "skills" collection = skills_root / "interesting_physics_skills" (collection / "demo-skill").mkdir(parents=True) (collection / "index.md").write_text( "| Skill | Description | Use When |\n" "| --- | --- | --- |\n" "| [demo-skill](demo-skill/SKILL.md) | 示例技能 | 后台新增合集 |\n", encoding="utf-8", ) (collection / "demo-skill" / "SKILL.md").write_text( "---\nname: demo-skill\ndescription: 示例技能\n---\n# Demo\n", encoding="utf-8", ) monkeypatch.setattr(main, "SKILL_ROOT", skills_root) options = main._skill_collection_options() assert [option["slug"] for option in options] == ["interesting_physics_skills"] assert options[0]["skill_count"] == 1 def test_upload_skill_collection_zip_extracts_and_lists(tmp_path: Path, monkeypatch) -> None: skills_root = tmp_path / "skills" monkeypatch.setattr(main, "SKILL_ROOT", skills_root) archive_path = tmp_path / "uploaded_skills.zip" _write_skill_collection_zip(archive_path) upload = FakeUploadFile("uploaded_skills.zip", archive_path.read_bytes()) payload = asyncio.run(main.upload_skill_collection(upload)) assert payload["collection"]["slug"] == "uploaded_skills" assert payload["collection"]["skill_count"] == 1 assert (skills_root / "uploaded_skills" / "index.md").exists() assert any(collection["slug"] == "uploaded_skills" for collection in payload["collections"]) def test_upload_skill_collection_rejects_non_zip(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(main, "SKILL_ROOT", tmp_path / "skills") upload = FakeUploadFile("uploaded_skills.txt", b"not zip") try: asyncio.run(main.upload_skill_collection(upload)) except main.HTTPException as exc: assert exc.status_code == 400 assert "zip" in exc.detail else: raise AssertionError("non-zip upload should fail") def test_install_skill_collection_zip_rejects_unsafe_paths(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(main, "SKILL_ROOT", tmp_path / "skills") archive_path = tmp_path / "unsafe.zip" with zipfile.ZipFile(archive_path, "w") as archive: archive.writestr("../index.md", "bad") upload = FakeUploadFile("unsafe.zip", archive_path.read_bytes()) try: asyncio.run(main.upload_skill_collection(upload)) except main.HTTPException as exc: assert exc.status_code == 400 assert "非法路径" in exc.detail else: raise AssertionError("unsafe zip should fail") def test_analyze_saved_docx_reports_progress(tmp_path: Path) -> None: updates: list[tuple[int, str]] = [] docx_path = tmp_path / "progress.docx" document = Document() document.add_heading("软件需求规格说明", level=1) document.add_paragraph("能力需求、接口需求、合格性规定。") document.save(docx_path) payload = analyze_saved_docx( docx_path, provider="deepseek", use_model=False, progress_callback=lambda progress, message: updates.append((progress, message)), ) assert updates[0][0] == 5 assert updates[-1] == (100, "分析完成") assert any("技能" in message for _, message in updates) assert payload["downloads"]["markdown"].endswith(".md") def test_analyze_saved_docx_creates_downloadable_report(tmp_path: Path) -> None: docx_path = tmp_path / "upload.docx" document = Document() document.add_heading("软件需求规格说明", level=1) document.add_paragraph("能力需求、接口需求、合格性规定。") document.save(docx_path) payload = analyze_saved_docx(docx_path, provider="deepseek", use_model=False) assert payload["source_filename"] == "upload.docx" assert "docx" not in payload["downloads"] assert payload["downloads"]["markdown"].endswith(".md") assert (OUTPUT_DIR / Path(payload["downloads"]["markdown"]).name).exists() def test_analyze_saved_docx_uses_selected_collection(tmp_path: Path) -> None: docx_path = tmp_path / "upload.docx" document = Document() document.add_heading("软件需求规格说明", level=1) document.add_paragraph("能力需求、接口需求、合格性规定。") document.save(docx_path) payload = analyze_saved_docx( docx_path, provider="deepseek", use_model=False, skill_collection="GJB438B-2009_prd_skills", ) assert payload["matched_skills"]