""" tests/test_skill_writer.py pytest 测试套件 · skill_writer.py """ import json import logging import pytest from pathlib import Path from tools.skill_writer import ( backup_crush, init_crush, list_crushes, list_versions, rollback_crush, update_meta, SIGNAL_SCORE_MIN, SIGNAL_SCORE_MAX, ) @pytest.fixture def tmp_base(tmp_path: Path) -> Path: return tmp_path / "crushes" # --------------------------------------------------------------------------- # init_crush # --------------------------------------------------------------------------- class TestInitCrush: def test_creates_directory_structure(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) crush_dir = tmp_base / "xiaomei" assert crush_dir.is_dir() assert (crush_dir / "profile.md").exists() assert (crush_dir / "state.md").exists() assert (crush_dir / "events.jsonl").exists() assert (crush_dir / "strategy.md").exists() assert (crush_dir / "meta.json").exists() assert (crush_dir / "memories" / "chats").is_dir() assert (crush_dir / "memories" / "social").is_dir() assert (crush_dir / "memories" / "photos").is_dir() assert (crush_dir / "versions").is_dir() assert (crush_dir / "snapshots").is_dir() def test_meta_json_defaults(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["slug"] == "xiaomei" assert meta["version"] == "v1" assert meta["mode"] == "hybrid" assert meta["signal_score"] is None assert meta["current_stage"] == "未知" assert meta["nickname"] == "[待填写]" assert meta["event_count"] == 0 assert meta["last_snapshot"] is None def test_profile_md_has_yaml_frontmatter(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) content = (tmp_base / "xiaomei" / "profile.md").read_text(encoding="utf-8") assert content.startswith("---\n") assert "slug: xiaomei" in content assert "personality_type:" in content assert "created_at:" in content def test_state_md_has_yaml_frontmatter(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) content = (tmp_base / "xiaomei" / "state.md").read_text(encoding="utf-8") assert content.startswith("---\n") assert "current_stage:" in content assert "signal_score:" in content assert "milestones_done:" in content def test_events_jsonl_is_empty_on_init(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) content = (tmp_base / "xiaomei" / "events.jsonl").read_text(encoding="utf-8") assert content == "" def test_does_not_overwrite_existing_profile(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) profile_path = tmp_base / "xiaomei" / "profile.md" profile_path.write_text("custom content", encoding="utf-8") init_crush("xiaomei", tmp_base) assert profile_path.read_text(encoding="utf-8") == "custom content" def test_does_not_overwrite_existing_state(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) state_path = tmp_base / "xiaomei" / "state.md" state_path.write_text("custom state", encoding="utf-8") init_crush("xiaomei", tmp_base) assert state_path.read_text(encoding="utf-8") == "custom state" def test_does_not_overwrite_existing_events(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) events_path = tmp_base / "xiaomei" / "events.jsonl" events_path.write_text('{"type":"test"}\n', encoding="utf-8") init_crush("xiaomei", tmp_base) assert events_path.read_text(encoding="utf-8") == '{"type":"test"}\n' def test_idempotent_on_repeated_calls(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) init_crush("xiaomei", tmp_base) assert (tmp_base / "xiaomei" / "meta.json").exists() # --------------------------------------------------------------------------- # list_crushes # --------------------------------------------------------------------------- class TestListCrushes: def test_nonexistent_base_dir(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: with caplog.at_level(logging.INFO): list_crushes(tmp_base / "nonexistent") assert "还没有任何心上人档案" in caplog.text def test_empty_base_dir(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: tmp_base.mkdir() with caplog.at_level(logging.INFO): list_crushes(tmp_base) assert "还没有任何心上人档案" in caplog.text def test_lists_existing_crushes(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: init_crush("xiaomei", tmp_base) with caplog.at_level(logging.INFO): list_crushes(tmp_base) assert "xiaomei" in caplog.text def test_shows_score_as_not_evaluated_when_none( self, tmp_base: Path, caplog: pytest.LogCaptureFixture ) -> None: init_crush("xiaomei", tmp_base) with caplog.at_level(logging.INFO): list_crushes(tmp_base) assert "未评估" in caplog.text def test_shows_score_when_set(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, signal_score=18) with caplog.at_level(logging.INFO): list_crushes(tmp_base) assert "18/25" in caplog.text # --------------------------------------------------------------------------- # backup_crush # --------------------------------------------------------------------------- class TestBackupCrush: def test_backup_creates_version_directory(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) version_name = backup_crush("xiaomei", tmp_base) assert version_name != "" assert (tmp_base / "xiaomei" / "versions" / version_name).is_dir() def test_backup_copies_core_files(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) version_name = backup_crush("xiaomei", tmp_base) version_dir = tmp_base / "xiaomei" / "versions" / version_name assert (version_dir / "profile.md").exists() assert (version_dir / "state.md").exists() assert (version_dir / "strategy.md").exists() assert (version_dir / "meta.json").exists() def test_backup_increments_version_number(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) backup_crush("xiaomei", tmp_base) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["version"] == "v2" def test_backup_nonexistent_slug_returns_empty(self, tmp_base: Path) -> None: result = backup_crush("nobody", tmp_base) assert result == "" # --------------------------------------------------------------------------- # rollback_crush # --------------------------------------------------------------------------- class TestRollbackCrush: def test_rollback_restores_profile(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) profile_path = tmp_base / "xiaomei" / "profile.md" original_content = profile_path.read_text(encoding="utf-8") backup_crush("xiaomei", tmp_base) profile_path.write_text("modified content", encoding="utf-8") rollback_crush("xiaomei", "v1", tmp_base) assert profile_path.read_text(encoding="utf-8") == original_content def test_rollback_creates_backup_before_restoring(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) backup_crush("xiaomei", tmp_base) rollback_crush("xiaomei", "v1", tmp_base) versions_dir = tmp_base / "xiaomei" / "versions" assert len(list(versions_dir.iterdir())) == 2 def test_rollback_nonexistent_version( self, tmp_base: Path, caplog: pytest.LogCaptureFixture ) -> None: init_crush("xiaomei", tmp_base) backup_crush("xiaomei", tmp_base) with caplog.at_level(logging.ERROR): rollback_crush("xiaomei", "v99", tmp_base) assert "不存在" in caplog.text def test_rollback_no_version_history( self, tmp_base: Path, caplog: pytest.LogCaptureFixture ) -> None: init_crush("xiaomei", tmp_base) with caplog.at_level(logging.ERROR): rollback_crush("xiaomei", "v1", tmp_base) assert "没有找到版本历史" in caplog.text # --------------------------------------------------------------------------- # list_versions # --------------------------------------------------------------------------- class TestListVersions: def test_no_versions(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: init_crush("xiaomei", tmp_base) with caplog.at_level(logging.INFO): list_versions("xiaomei", tmp_base) assert "没有版本历史" in caplog.text def test_lists_existing_versions(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: init_crush("xiaomei", tmp_base) backup_crush("xiaomei", tmp_base) with caplog.at_level(logging.INFO): list_versions("xiaomei", tmp_base) assert "v1_" in caplog.text # --------------------------------------------------------------------------- # update_meta # --------------------------------------------------------------------------- class TestUpdateMeta: def test_updates_stage(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, current_stage="暧昧期") meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["current_stage"] == "暧昧期" def test_updates_mode(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, mode="sweet") meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["mode"] == "sweet" def test_valid_score_is_saved(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, signal_score=18) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["signal_score"] == 18 def test_zero_score_is_saved(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, signal_score=0) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["signal_score"] == 0 def test_negative_score_within_range_is_saved(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, signal_score=-5) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["signal_score"] == -5 def test_score_above_max_is_rejected(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, signal_score=SIGNAL_SCORE_MAX + 1) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["signal_score"] is None def test_score_below_min_is_rejected(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, signal_score=SIGNAL_SCORE_MIN - 1) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["signal_score"] is None def test_nonexistent_slug_is_noop( self, tmp_base: Path, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level(logging.ERROR): update_meta("nobody", tmp_base, current_stage="暧昧期") assert "不存在" in caplog.text def test_does_not_lose_existing_fields(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) update_meta("xiaomei", tmp_base, current_stage="升温期") update_meta("xiaomei", tmp_base, signal_score=12) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["current_stage"] == "升温期" assert meta["signal_score"] == 12 assert meta["slug"] == "xiaomei" # --------------------------------------------------------------------------- # init_crush time tracking (Task 5) # --------------------------------------------------------------------------- class TestInitCrushTimeTracking: def test_creates_interactions_jsonl(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) interactions_path = tmp_base / "xiaomei" / "interactions.jsonl" assert interactions_path.exists() assert interactions_path.read_text(encoding="utf-8") == "" def test_meta_json_has_time_tracking_fields(self, tmp_base: Path) -> None: init_crush("xiaomei", tmp_base) meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) assert meta["interaction_count"] == 0 assert meta["last_interaction"] is None assert meta["consecutive_days"] == 0