import asyncio import json from pathlib import Path from types import SimpleNamespace def test_config_service_defaults_enable_per_slide_creative_guidance(): from landppt.services.config_service import ConfigService service = ConfigService(env_file=".env.example") assert service.config_schema["enable_per_slide_creative_guidance"]["default"] == "true" def test_db_config_service_defaults_enable_per_slide_creative_guidance(): from landppt.services.db_config_service import DatabaseConfigService service = DatabaseConfigService() assert service.config_schema["enable_per_slide_creative_guidance"]["default"] == "true" def test_tavily_base_url_exists_in_config_schemas(): from landppt.services.config_service import ConfigService from landppt.services.db_config_service import DatabaseConfigService env_service = ConfigService(env_file=".env.example") db_service = DatabaseConfigService() assert env_service.config_schema["tavily_base_url"]["type"] == "url" assert env_service.config_schema["tavily_base_url"]["category"] == "generation_params" assert env_service.config_schema["tavily_base_url"]["default"] == "https://api.tavily.com" assert db_service.config_schema["tavily_base_url"]["type"] == "url" assert db_service.config_schema["tavily_base_url"]["category"] == "generation_params" assert db_service.config_schema["tavily_base_url"]["default"] == "https://api.tavily.com" def test_db_config_service_resolves_blank_tavily_base_url_to_official_default(): from landppt.services.db_config_service import DatabaseConfigService service = DatabaseConfigService() resolved = service._resolve_config_values( { "tavily_base_url": { "value": "", "type": "url", "category": "generation_params", "is_user_override": True, }, }, {}, ) assert resolved["tavily_base_url"] == "https://api.tavily.com" def test_apryse_standard_pptx_export_is_admin_only_in_config_schema(): from landppt.services.config_service import ConfigService from landppt.services.db_config_service import DatabaseConfigService env_service = ConfigService(env_file=".env.example") db_service = DatabaseConfigService() assert env_service.config_schema["enable_apryse_pptx_export"]["default"] == "false" assert env_service.config_schema["enable_apryse_pptx_export"]["admin_only"] is True assert env_service.config_schema["apryse_license_key"]["admin_only"] is True assert db_service.config_schema["enable_apryse_pptx_export"]["default"] == "false" assert db_service.config_schema["enable_apryse_pptx_export"]["admin_only"] is True assert db_service.config_schema["apryse_license_key"]["admin_only"] is True def test_db_config_service_filters_and_resolves_admin_only_apryse_config(monkeypatch): from landppt.services.db_config_service import DatabaseConfigService service = DatabaseConfigService() public_schema = service.get_config_schema(include_admin_only=False) resolved = service._resolve_config_values( { "apryse_license_key": {"value": "user-license", "type": "password", "category": "generation_params"}, "enable_apryse_pptx_export": {"value": "false", "type": "boolean", "category": "generation_params"}, }, { "apryse_license_key": {"value": "system-license", "type": "password", "category": "generation_params"}, "enable_apryse_pptx_export": {"value": "true", "type": "boolean", "category": "generation_params"}, }, ) async def fake_get_all_config(user_id=None): return { "apryse_license_key": "system-license", "enable_apryse_pptx_export": True, "openai_model": "gpt-4.1", } monkeypatch.setattr(service, "get_all_config", fake_get_all_config) filtered = asyncio.run(service.get_all_config_for_user(user_id=1, is_admin=False)) assert "apryse_license_key" not in public_schema assert "enable_apryse_pptx_export" not in public_schema assert resolved["apryse_license_key"] == "system-license" assert resolved["enable_apryse_pptx_export"] is True assert "apryse_license_key" not in filtered assert "enable_apryse_pptx_export" not in filtered assert filtered["openai_model"] == "gpt-4.1" def test_db_config_service_resolves_system_scope_admin_only_config_from_merged_rows(): from landppt.services.db_config_service import DatabaseConfigService service = DatabaseConfigService() resolved = service._resolve_config_values( { "apryse_license_key": { "value": "system-license", "type": "password", "category": "generation_params", "is_user_override": False, }, "enable_apryse_pptx_export": { "value": "true", "type": "boolean", "category": "generation_params", "is_user_override": False, }, }, {}, ) assert resolved["apryse_license_key"] == "system-license" assert resolved["enable_apryse_pptx_export"] is True def test_db_config_service_ignores_user_override_for_admin_only_when_no_system_default(): from landppt.services.db_config_service import DatabaseConfigService service = DatabaseConfigService() resolved = service._resolve_config_values( { "apryse_license_key": { "value": "legacy-user-license", "type": "password", "category": "generation_params", "is_user_override": True, }, "enable_apryse_pptx_export": { "value": "true", "type": "boolean", "category": "generation_params", "is_user_override": True, }, }, {}, ) assert resolved["apryse_license_key"] in (None, "") assert resolved["enable_apryse_pptx_export"] is False def test_db_config_service_migrates_legacy_system_default_to_true(monkeypatch): import landppt.database.database as database_mod import landppt.database.repositories as repo_mod from landppt.services.db_config_service import DatabaseConfigService captured = {"set_calls": []} class FakeSession: async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False async def commit(self): return None class FakeRepo: def __init__(self, session): self.session = session async def get_all_configs(self, user_id=None): return { "enable_per_slide_creative_guidance": {"value": "false"}, } async def set_config(self, **kwargs): captured["set_calls"].append(kwargs) monkeypatch.setattr(database_mod, "AsyncSessionLocal", lambda: FakeSession()) monkeypatch.setattr(repo_mod, "UserConfigRepository", FakeRepo) service = DatabaseConfigService() updated = asyncio.run(service.initialize_system_defaults()) assert updated >= 1 assert any( call["key"] == "enable_per_slide_creative_guidance" and call["value"] == "true" for call in captured["set_calls"] ) def test_creative_design_service_uses_slide_prompt_when_per_slide_guidance_enabled(tmp_path): from landppt.services.slide.creative_design_service import CreativeDesignService class _DummyService: def __init__(self): self.user_id = 1 self.cache_dirs = {"style_genes": tmp_path} self._cached_style_genes = {} self._cached_global_constitutions = {} self._cached_page_creative_briefs = {} self._cached_slide_creative_guides = {} self._style_genes_ready_events = {} self._global_constitution_ready_events = {} self._page_creative_brief_ready_events = {} self._slide_creative_guide_ready_events = {} async def _get_user_generation_config(self): return {"enable_per_slide_creative_guidance": True} owner = _DummyService() service = CreativeDesignService(owner) async def fake_style_genes(project_id, template_html, page_number): return "STYLE-GENES" async def fake_constitution(*args, **kwargs): return "GLOBAL-CONSTITUTION" async def fail_page_briefs(*args, **kwargs): raise AssertionError("page-type briefs should not be used when per-slide guidance is enabled") async def fake_slide_guide(*args, **kwargs): return "SLIDE-GUIDE" service._get_or_extract_style_genes = fake_style_genes service._get_or_generate_global_constitution = fake_constitution service._get_or_generate_page_creative_briefs = fail_page_briefs service._get_or_generate_slide_creative_guide = fake_slide_guide result = asyncio.run( service._get_creative_design_inputs( project_id="proj-1", template_html="
", slide_data={"title": "增长机会"}, page_number=2, total_pages=5, confirmed_requirements={"topic": "年度复盘"}, all_slides=[{"title": "封面"}, {"title": "增长机会"}], ) ) assert result == ("STYLE-GENES", "GLOBAL-CONSTITUTION", "SLIDE-GUIDE") def test_creative_design_service_uses_page_type_briefs_when_per_slide_guidance_disabled(tmp_path): from landppt.services.slide.creative_design_service import CreativeDesignService class _DummyService: def __init__(self): self.user_id = 1 self.cache_dirs = {"style_genes": tmp_path} self._cached_style_genes = {} self._cached_global_constitutions = {} self._cached_page_creative_briefs = {} self._cached_slide_creative_guides = {} self._style_genes_ready_events = {} self._global_constitution_ready_events = {} self._page_creative_brief_ready_events = {} self._slide_creative_guide_ready_events = {} async def _get_user_generation_config(self): return {"enable_per_slide_creative_guidance": False} owner = _DummyService() service = CreativeDesignService(owner) async def fake_style_genes(project_id, template_html, page_number): return "STYLE-GENES" async def fake_constitution(*args, **kwargs): return "GLOBAL-CONSTITUTION" async def fake_page_briefs(*args, **kwargs): return [ {"page": 1, "creative_brief": "封面指导"}, {"page": 2, "creative_brief": "PAGE-BRIEF"}, ] async def fail_slide_guide(*args, **kwargs): raise AssertionError("slide-level prompt should not be used when per-slide guidance is disabled") service._get_or_extract_style_genes = fake_style_genes service._get_or_generate_global_constitution = fake_constitution service._get_or_generate_page_creative_briefs = fake_page_briefs service._get_or_generate_slide_creative_guide = fail_slide_guide result = asyncio.run( service._get_creative_design_inputs( project_id="proj-1", template_html="", slide_data={"title": "增长机会"}, page_number=2, total_pages=5, confirmed_requirements={"topic": "年度复盘"}, all_slides=[{"title": "封面"}, {"title": "增长机会"}], ) ) assert result == ("STYLE-GENES", "GLOBAL-CONSTITUTION", "PAGE-BRIEF") def test_creative_design_service_persists_slide_creative_guide_cache(tmp_path, monkeypatch): from landppt.services.slide.creative_design_service import CreativeDesignService class _DummyService: def __init__(self): self.user_id = 1 self.cache_dirs = {"style_genes": tmp_path} self._cached_slide_creative_guides = {} self._slide_creative_guide_ready_events = {} owner = _DummyService() service = CreativeDesignService(owner) recorded = [] async def fake_generate(*args, **kwargs): recorded.append((args, kwargs)) return "REALTIME-GUIDE" monkeypatch.setattr(service, "_generate_slide_creative_guide", fake_generate) guide = asyncio.run( service._get_or_generate_slide_creative_guide( project_id="proj-cache", slide_data={"title": "当前页"}, page_number=3, total_pages=8, confirmed_requirements={"topic": "项目汇报"}, all_slides=[{"title": "封面"}, {"title": "目录"}, {"title": "当前页"}], template_html="", ) ) cache_file = Path(tmp_path) / "proj-cache_slide_3_creative_guide.json" cache_data = json.loads(cache_file.read_text(encoding="utf-8")) assert guide == "REALTIME-GUIDE" assert len(recorded) == 1 assert owner._cached_slide_creative_guides["proj-cache:3"] == "REALTIME-GUIDE" assert cache_data["creative_guide"] == "REALTIME-GUIDE" def test_creative_guidance_runtime_pipeline_wires_per_slide_prompt_and_cache_cleanup(): root = Path(__file__).resolve().parents[1] service_text = (root / "src/landppt/services/slide/creative_design_service.py").read_text(encoding="utf-8") assert 'enable_per_slide_creative_guidance' in service_text assert 'get_slide_design_guide_prompt(' in service_text assert '_get_or_generate_slide_creative_guide(' in service_text assert '_cached_slide_creative_guides' in service_text assert '_slide_creative_guide_ready_events' in service_text assert '_slide_*_creative_guide.json' in service_text