from __future__ import annotations import io import json import os import unittest import uuid from pathlib import Path from PIL import Image, ImageDraw import src.character_workshop as workshop from src.character_registry import load_user_character_packages from src.model_status import ModelStatus, statuses_markdown PROJECT_ROOT = Path(__file__).resolve().parents[1] TMP_ROOT = PROJECT_ROOT / ".tmp" / "workshop_tests" class CharacterWorkshopTests(unittest.TestCase): def setUp(self) -> None: self.test_root = TMP_ROOT / f"{self._testMethodName}_{uuid.uuid4().hex}" self.test_root.mkdir(parents=True, exist_ok=True) self.old_roots = ( workshop.WORKSHOP_ROOT, workshop.INSTALLED_CHARACTER_ROOT, workshop.INSTALLED_BACKGROUND_ROOT, workshop.INSTALLED_CHARACTER_JSON_ROOT, ) self.old_data_root = os.environ.get("VC_DATA_ROOT") workshop.WORKSHOP_ROOT = self.test_root / "generated" workshop.INSTALLED_CHARACTER_ROOT = self.test_root / "installed" / "assets" / "characters" workshop.INSTALLED_BACKGROUND_ROOT = self.test_root / "installed" / "assets" / "backgrounds" workshop.INSTALLED_CHARACTER_JSON_ROOT = self.test_root / "installed" / "characters" os.environ["VC_DATA_ROOT"] = str(self.test_root / "data") workshop.set_remote_probe_for_tests(self._fake_probe) self.probe_calls = [] def tearDown(self) -> None: ( workshop.WORKSHOP_ROOT, workshop.INSTALLED_CHARACTER_ROOT, workshop.INSTALLED_BACKGROUND_ROOT, workshop.INSTALLED_CHARACTER_JSON_ROOT, ) = self.old_roots if self.old_data_root is None: os.environ.pop("VC_DATA_ROOT", None) else: os.environ["VC_DATA_ROOT"] = self.old_data_root workshop.set_remote_probe_for_tests(None) def test_form_to_installable_character_package(self) -> None: draft = workshop.create_draft_from_form( display_name="工坊角色", description="银白短发的原创角色。", personality="冷静、温柔", scenario="通讯端测试。", first_mes="我在。", tags="测试,工坊", ) state = workshop.create_initial_state(draft) state = workshop.generate_main_candidates(state) self.assertEqual(len(state["main_candidates"]), 4) state = workshop.select_main_candidate(state, 2) self.assertEqual(state["selected_candidate_index"], 2) state = workshop.generate_expression_pack(state) self.assertEqual(set(state["expression_assets"]), set(workshop.EXPRESSIONS)) state = workshop.generate_background(state) self.assertTrue(Path(state["background_asset"]).exists()) state = workshop.matte_and_package_assets(state, mode="fallback") package_dir = Path(state["package_dir"]) self.assertTrue((package_dir / "generated" / "asset_grid.png").exists()) self.assertTrue((package_dir / "generated" / "stage_smoke.html").exists()) state = workshop.install_character_package(state) character_id = state["installed_character_id"] self.assertTrue((workshop.INSTALLED_CHARACTER_JSON_ROOT / f"{character_id}.json").exists()) self.assertTrue((workshop.INSTALLED_BACKGROUND_ROOT / f"{character_id}_background.png").exists()) for expression in workshop.EXPRESSIONS: self.assertTrue((workshop.INSTALLED_CHARACTER_ROOT / character_id / f"{expression}.png").exists()) def test_user_character_loader_reads_installed_json(self) -> None: root = TMP_ROOT / "registry" root.mkdir(parents=True, exist_ok=True) path = root / "loaded_character.json" path.write_text(json.dumps({"id": "loaded_character", "display_name": "已加载角色"}), encoding="utf-8") packages = load_user_character_packages(root) self.assertEqual(packages["loaded_character"]["display_name"], "已加载角色") def test_image_generation_status_markdown(self) -> None: html = statuses_markdown( [ ModelStatus( "image_generation", "sleeping", "已休眠", url="modal_apps/modal_character_spike.py", message="Modal 图像生成服务可能已休眠或正在冷启动,请等待容器启动和模型载入后重试。", ) ] ) self.assertIn("Image Generation", html) self.assertIn("等待容器启动", html) def test_logged_in_runs_are_user_scoped_and_loadable(self) -> None: alice = workshop.get_current_user(_Profile("alice", "Alice")) bob = workshop.get_current_user(_Profile("bob", "Bob")) alice_state = workshop.create_initial_state( workshop.create_draft_from_form( display_name="Alice 角色", description="测试。", personality="冷静", scenario="测试。", first_mes="你好。", ), user=alice, ) bob_state = workshop.create_initial_state( workshop.create_draft_from_form( display_name="Bob 角色", description="测试。", personality="冷静", scenario="测试。", first_mes="你好。", ), user=bob, ) alice_runs = workshop.list_user_workshop_runs(alice) bob_runs = workshop.list_user_workshop_runs(bob) self.assertEqual(len(alice_runs), 1) self.assertEqual(len(bob_runs), 1) self.assertIn("Alice 角色", alice_runs[0][0]) self.assertIn("Bob 角色", bob_runs[0][0]) self.assertNotEqual(alice_state["run_dir"], bob_state["run_dir"]) loaded = workshop.load_workshop_run(alice_runs[0][1], user=alice) self.assertEqual(loaded["character_id"], alice_state["character_id"]) with self.assertRaises(ValueError): workshop.load_workshop_run(alice_runs[0][1], user=bob) def test_resume_main_candidates_from_manifest(self) -> None: user = workshop.get_current_user(_Profile("alice", "Alice")) state = workshop.create_initial_state( workshop.create_draft_from_form( display_name="恢复角色", description="测试。", personality="冷静", scenario="测试。", first_mes="你好。", ), user=user, ) state = workshop.generate_main_candidates(state) loaded = workshop.load_workshop_run(state["run_dir"], user=user) self.assertEqual(len(loaded["main_candidates"]), 4) self.assertEqual(loaded["selected_candidate_index"], 0) def test_partial_expression_pack_only_generates_missing_slots(self) -> None: user = workshop.get_current_user(_Profile("alice", "Alice")) state = workshop.create_initial_state( workshop.create_draft_from_form( display_name="续跑角色", description="测试。", personality="冷静", scenario="测试。", first_mes="你好。", ), user=user, ) run_dir = Path(state["run_dir"]) idle_dir = run_dir / "expressions_raw" / "idle" idle_dir.mkdir(parents=True, exist_ok=True) idle_path = idle_dir / "00.png" idle_path.write_bytes(_png_bytes(0)) manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) manifest["expression_assets_raw"] = {"idle": str(idle_path.relative_to(run_dir))} (run_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") self.probe_calls.clear() state = workshop.load_workshop_run(run_dir, user=user) state = workshop.generate_expression_pack(state) self.assertEqual(set(state["expression_assets"]), set(workshop.EXPRESSIONS)) self.assertEqual(len(self.probe_calls), len(workshop.EXPRESSIONS) - 1) def test_stats_event_summary(self) -> None: user = workshop.get_current_user(_Profile("alice", "Alice")) workshop.record_workshop_event(user, "generate_main_candidates", {"stage": "main_candidates", "success": True, "duration_seconds": 1.2, "character_id": "x"}) workshop.record_workshop_event(user, "generate_expression_pack", {"stage": "assets_ready", "success": False, "failure_reason": "boom", "character_id": "x"}) summary = workshop.summarize_workshop_stats() self.assertEqual(summary["events"], 2) self.assertEqual(summary["users"], 1) self.assertEqual(summary["failures_by_stage"]["assets_ready"], 1) def _fake_probe(self, **kwargs): self.probe_calls.append(dict(kwargs)) batch_size = int(kwargs.get("batch_size", 1)) images = [_png_bytes(index) for index in range(batch_size)] return { "candidate_id": kwargs.get("candidate_id", "qwen_image"), "status": "ok", "image_count": batch_size, "duration_seconds": 0.01, "seed": kwargs.get("seed"), "images": images, } def _png_bytes(index: int) -> bytes: image = Image.new("RGB", (256, 384), (238, 246, 248)) draw = ImageDraw.Draw(image) accent = [(45, 212, 191), (96, 165, 250), (244, 114, 182), (250, 204, 21)][index % 4] draw.ellipse((76, 36, 180, 150), fill=(245, 222, 214)) draw.rectangle((84, 150, 172, 330), fill=(35, 45, 60)) draw.rectangle((112, 166, 144, 310), fill=accent) buffer = io.BytesIO() image.save(buffer, format="PNG") return buffer.getvalue() class _Profile: def __init__(self, username: str, name: str): self.username = username self.name = name if __name__ == "__main__": unittest.main()