mirror of
https://github.com/sligter/LandPPT.git
synced 2026-08-28 23:31:06 +08:00
afded66546
- Introduced a new option to include or exclude page numbers in generated PPT slides. - Updated multiple services (CreativeDesignService, LayoutRepairService, SlideDocumentService, etc.) to respect the new `include_page_numbers` flag. - Added utility functions in `prompt_utils.py` to handle page number guidance filtering based on user preferences. - Modified HTML templates to conditionally render page numbers based on the new setting. - Enhanced tests to verify the correct behavior of page number inclusion/exclusion in prompts and generated content.
667 lines
36 KiB
Python
667 lines
36 KiB
Python
import ast
|
||
import re
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from landppt.services.slide.creative_design_service import CreativeDesignService
|
||
from landppt.services.template.global_master_template_service import GlobalMasterTemplateService
|
||
from landppt.services.slide.layout_repair_service import LayoutRepairService
|
||
from landppt.services.slide.slide_html_cleanup_service import SlideHtmlCleanupService
|
||
from landppt.services.slide.slide_generation_service import SlideGenerationService
|
||
from landppt.services.template.template_selection_service import TemplateSelectionService
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def _read(relative_path: str) -> str:
|
||
return (ROOT / relative_path).read_text(encoding="utf-8")
|
||
|
||
|
||
def _direct_class_methods(relative_path: str, class_name: str) -> set[str]:
|
||
tree = ast.parse(_read(relative_path))
|
||
top_level_funcs = [
|
||
node.name for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||
]
|
||
assert top_level_funcs == []
|
||
|
||
class_node = next(
|
||
node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name
|
||
)
|
||
return {
|
||
node.name for node in class_node.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||
}
|
||
|
||
|
||
def _load_class_method(relative_path: str, class_name: str, method_name: str):
|
||
tree = ast.parse(_read(relative_path))
|
||
class_node = next(
|
||
node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name
|
||
)
|
||
method_node = next(
|
||
node
|
||
for node in class_node.body
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == method_name
|
||
)
|
||
module = ast.Module(body=[method_node], type_ignores=[])
|
||
ast.fix_missing_locations(module)
|
||
|
||
class _Logger:
|
||
def debug(self, *args, **kwargs):
|
||
return None
|
||
|
||
def warning(self, *args, **kwargs):
|
||
return None
|
||
|
||
def error(self, *args, **kwargs):
|
||
return None
|
||
|
||
namespace = {"logger": _Logger(), "re": re}
|
||
exec(compile(module, relative_path, "exec"), namespace)
|
||
return namespace[method_name]
|
||
|
||
|
||
def test_enhanced_ppt_service_delegates_extracted_service_logic():
|
||
service_text = _read("src/landppt/services/enhanced_ppt_service.py")
|
||
creative_text = _read("src/landppt/services/slide/creative_design_service.py")
|
||
layout_text = _read("src/landppt/services/slide/layout_repair_service.py")
|
||
outline_text = _read("src/landppt/services/outline/project_outline_workflow_service.py")
|
||
outline_generation_text = _read("src/landppt/services/outline/project_outline_generation_service.py")
|
||
outline_creation_text = _read("src/landppt/services/outline/project_outline_creation_service.py")
|
||
outline_research_text = _read("src/landppt/services/outline/project_outline_research_service.py")
|
||
runtime_text = _read("src/landppt/services/runtime/runtime_support_service.py")
|
||
slide_authoring_text = _read("src/landppt/services/slide/slide_authoring_service.py")
|
||
slide_stream_text = _read("src/landppt/services/slide/slide_streaming_service.py")
|
||
slide_generation_text = _read("src/landppt/services/slide/slide_generation_service.py")
|
||
template_text = _read("src/landppt/services/template/template_selection_service.py")
|
||
|
||
assert "from .slide.creative_design_service import CreativeDesignService" in service_text
|
||
assert "from .slide.layout_repair_service import LayoutRepairService" in service_text
|
||
assert "from .outline.project_outline_workflow_service import ProjectOutlineWorkflowService" in service_text
|
||
assert "from .runtime.runtime_support_service import RuntimeSupportService" in service_text
|
||
assert "from .slide.slide_authoring_service import SlideAuthoringService" in service_text
|
||
assert "from .slide.slide_generation_service import SlideGenerationService" in service_text
|
||
assert "from .template.template_selection_service import TemplateSelectionService" in service_text
|
||
|
||
assert "import asyncio" in slide_generation_text
|
||
assert "self.creative_design = CreativeDesignService(self)" in service_text
|
||
assert "self.runtime_support = RuntimeSupportService(self)" in service_text
|
||
assert "self.project_outline_workflow = ProjectOutlineWorkflowService(self)" in service_text
|
||
assert "self.slide_authoring = SlideAuthoringService(self)" in service_text
|
||
assert "self.slide_generation = SlideGenerationService(self)" in service_text
|
||
assert "self.layout_repair = LayoutRepairService(self)" in service_text
|
||
assert "self.template_selection = TemplateSelectionService(self)" in service_text
|
||
assert "self.enhanced_research_service = None" in service_text
|
||
assert "self.enhanced_report_generator = None" in service_text
|
||
assert "self._initialize_research_services()" in service_text
|
||
assert "self.runtime_support._initialize_image_service()" in service_text
|
||
|
||
assert "async for chunk in self.slide_generation._generate_slides_streaming_impl(project_id):" in service_text
|
||
assert "return await self.creative_design._generate_slide_with_template(" in service_text
|
||
assert "return await self.creative_design._get_creative_design_inputs(" in service_text
|
||
assert "return LayoutRepairService._should_skip_layout_repair(inspection_report)" in service_text
|
||
assert "return await self.layout_repair._apply_auto_layout_repair(" in service_text
|
||
assert "return await self.template_selection.get_selected_global_template(project_id, user_id=user_id)" in service_text
|
||
assert "return self.creative_design.clear_cached_style_genes(project_id)" in service_text
|
||
assert "return self.runtime_support._build_execution_context(role, current_ai_config)" in service_text
|
||
assert "return self.runtime_support.iter_research_stream_payloads(event)" in service_text
|
||
assert "return await self.project_outline_workflow.create_project_with_workflow(request)" in service_text
|
||
assert "async for item in self.project_outline_workflow.generate_outline_streaming(" in service_text
|
||
assert "force_regenerate=force_regenerate" in service_text
|
||
assert "async for item in self.slide_authoring.generate_slides_streaming(project_id):" in service_text
|
||
assert "return self.slide_authoring._clean_html_response(raw_content)" in service_text
|
||
assert "return await self.slide_authoring.regenerate_slide(project_id, slide_index, request)" in service_text
|
||
assert len(service_text.splitlines()) < 1000
|
||
|
||
for marker in [
|
||
"get_combined_style_genes_and_guide_prompt(",
|
||
"current_time_context = self._build_current_time_prompt_context()",
|
||
"credits_should_bill = False",
|
||
"with tempfile.TemporaryDirectory() as tmp_dir:",
|
||
"conduct_enhanced_research(",
|
||
"self._slides_generation_cancel_key(project_id)",
|
||
]:
|
||
assert marker not in service_text
|
||
|
||
assert "get_combined_style_genes_and_guide_prompt(" in creative_text
|
||
assert "with tempfile.TemporaryDirectory() as tmp_dir:" in layout_text
|
||
assert "conduct_enhanced_research(" in outline_research_text
|
||
assert "def _build_execution_context(" in runtime_text
|
||
assert "self._slides_generation_cancel_key(project_id)" in slide_stream_text
|
||
assert "async def generate_slides_streaming(self, project_id: str):" in slide_authoring_text
|
||
assert "credits_should_bill = False" in slide_generation_text
|
||
assert 'template_name = f"' in template_text
|
||
assert "project_id[:8]" in template_text
|
||
|
||
|
||
def test_split_workflow_runtime_and_slide_services_are_class_based_and_delegated():
|
||
project_wrapper_text = _read("src/landppt/services/outline/project_outline_workflow_service.py")
|
||
project_generation_text = _read("src/landppt/services/outline/project_outline_generation_service.py")
|
||
project_stage_text = _read("src/landppt/services/project_workflow_stage_service.py")
|
||
slide_wrapper_text = _read("src/landppt/services/slide/slide_authoring_service.py")
|
||
slide_html_text = _read("src/landppt/services/slide/slide_html_service.py")
|
||
slide_cleanup_text = _read("src/landppt/services/slide/slide_html_cleanup_service.py")
|
||
slide_stream_text = _read("src/landppt/services/slide/slide_streaming_service.py")
|
||
runtime_wrapper_text = _read("src/landppt/services/runtime/runtime_support_service.py")
|
||
runtime_research_text = _read("src/landppt/services/runtime/runtime_research_service.py")
|
||
runtime_ai_text = _read("src/landppt/services/runtime/runtime_ai_service.py")
|
||
runtime_image_text = _read("src/landppt/services/runtime/runtime_image_service.py")
|
||
|
||
assert "from .project_outline_generation_service import ProjectOutlineGenerationService" in project_wrapper_text
|
||
assert "from ..project_workflow_stage_service import ProjectWorkflowStageService" in project_wrapper_text
|
||
assert "self._outline_generation = ProjectOutlineGenerationService(self)" in project_wrapper_text
|
||
assert "self._workflow_stage = ProjectWorkflowStageService(self)" in project_wrapper_text
|
||
|
||
assert "from .slide_html_service import SlideHtmlService" in slide_wrapper_text
|
||
assert "from .slide_streaming_service import SlideStreamingService" in slide_wrapper_text
|
||
assert "self._html_service = SlideHtmlService(self)" in slide_wrapper_text
|
||
assert "self._streaming_service = SlideStreamingService(self)" in slide_wrapper_text
|
||
assert "return self._html_service._clean_html_response(raw_content)" in slide_wrapper_text
|
||
assert "from .slide_html_cleanup_service import SlideHtmlCleanupService" in slide_html_text
|
||
assert "self._cleanup_service = SlideHtmlCleanupService(self)" in slide_html_text
|
||
|
||
assert "from .runtime_research_service import RuntimeResearchService" in runtime_wrapper_text
|
||
assert "from .runtime_ai_service import RuntimeAIService" in runtime_wrapper_text
|
||
assert "from .runtime_image_service import RuntimeImageService" in runtime_wrapper_text
|
||
assert "self._research_runtime = RuntimeResearchService(self)" in runtime_wrapper_text
|
||
assert "self._ai_runtime = RuntimeAIService(self)" in runtime_wrapper_text
|
||
assert "self._image_runtime = RuntimeImageService(self)" in runtime_wrapper_text
|
||
assert "def _owner(self):" in runtime_image_text
|
||
assert "return self._service._service" in runtime_image_text
|
||
assert "self._owner.image_service = ImageService(image_config)" in runtime_image_text
|
||
assert "def _owner(self):" in runtime_research_text
|
||
assert "self._owner.enhanced_research_service = EnhancedResearchService(user_id=self.user_id)" in runtime_research_text
|
||
assert "self._owner.enhanced_report_generator = EnhancedReportGenerator" in runtime_research_text
|
||
assert "self._owner.research_service = DEEPResearchService(user_id=self.user_id)" in runtime_research_text
|
||
assert "self._owner.report_generator = ResearchReportGenerator" in runtime_research_text
|
||
|
||
assert len(project_wrapper_text.splitlines()) < 250
|
||
assert len(slide_wrapper_text.splitlines()) < 250
|
||
assert len(runtime_wrapper_text.splitlines()) < 220
|
||
|
||
project_methods = _direct_class_methods(
|
||
"src/landppt/services/outline/project_outline_workflow_service.py",
|
||
"ProjectOutlineWorkflowService",
|
||
)
|
||
slide_methods = _direct_class_methods(
|
||
"src/landppt/services/slide/slide_authoring_service.py",
|
||
"SlideAuthoringService",
|
||
)
|
||
runtime_methods = _direct_class_methods(
|
||
"src/landppt/services/runtime/runtime_support_service.py",
|
||
"RuntimeSupportService",
|
||
)
|
||
|
||
assert {"generate_outline_streaming", "_execute_outline_generation", "confirm_project_outline"} <= project_methods
|
||
assert {"generate_slides_streaming", "_generate_fallback_slide_html", "regenerate_slide"} <= slide_methods
|
||
assert {"iter_research_stream_payloads", "_build_execution_context", "_initialize_image_service"} <= runtime_methods
|
||
|
||
assert "async def generate_outline_streaming" in project_generation_text
|
||
assert "async def confirm_project_outline" in project_stage_text
|
||
assert "def _generate_fallback_slide_html" in slide_html_text
|
||
assert "def _clean_html_response" in slide_cleanup_text
|
||
assert "async def generate_slides_streaming" in slide_stream_text
|
||
assert "def iter_research_stream_payloads" in runtime_research_text
|
||
assert "def _build_execution_context" in runtime_ai_text
|
||
assert "def _initialize_image_service" in runtime_image_text
|
||
|
||
|
||
def test_second_level_service_splits_are_facades_and_subservices_are_class_based():
|
||
project_generation_text = _read("src/landppt/services/outline/project_outline_generation_service.py")
|
||
project_creation_text = _read("src/landppt/services/outline/project_outline_creation_service.py")
|
||
project_streaming_text = _read("src/landppt/services/outline/project_outline_streaming_service.py")
|
||
project_validation_text = _read("src/landppt/services/outline/project_outline_validation_service.py")
|
||
project_page_count_text = _read("src/landppt/services/outline/project_outline_page_count_service.py")
|
||
|
||
slide_html_text = _read("src/landppt/services/slide/slide_html_service.py")
|
||
slide_cleanup_text = _read("src/landppt/services/slide/slide_html_cleanup_service.py")
|
||
slide_content_text = _read("src/landppt/services/slide/slide_content_service.py")
|
||
slide_media_text = _read("src/landppt/services/slide/slide_media_service.py")
|
||
slide_validation_text = _read("src/landppt/services/slide/slide_html_validation_service.py")
|
||
slide_document_text = _read("src/landppt/services/slide/slide_document_service.py")
|
||
|
||
runtime_ai_text = _read("src/landppt/services/runtime/runtime_ai_service.py")
|
||
runtime_provider_text = _read("src/landppt/services/runtime/runtime_provider_service.py")
|
||
runtime_config_text = _read("src/landppt/services/runtime/runtime_config_service.py")
|
||
runtime_maintenance_text = _read("src/landppt/services/runtime/runtime_maintenance_service.py")
|
||
|
||
assert "from .project_outline_creation_service import ProjectOutlineCreationService" in project_generation_text
|
||
assert "from .project_outline_streaming_service import ProjectOutlineStreamingService" in project_generation_text
|
||
assert "from .project_outline_validation_service import ProjectOutlineValidationService" in project_generation_text
|
||
assert "from .project_outline_page_count_service import ProjectOutlinePageCountService" in project_generation_text
|
||
assert "self._creation_service = ProjectOutlineCreationService(self)" in project_generation_text
|
||
assert "self._streaming_service = ProjectOutlineStreamingService(self)" in project_generation_text
|
||
assert "self._validation_service = ProjectOutlineValidationService(self)" in project_generation_text
|
||
assert "self._page_count_service = ProjectOutlinePageCountService(self)" in project_generation_text
|
||
|
||
assert "from .slide_content_service import SlideContentService" in slide_html_text
|
||
assert "from .slide_media_service import SlideMediaService" in slide_html_text
|
||
assert "from .slide_html_cleanup_service import SlideHtmlCleanupService" in slide_html_text
|
||
assert "from .slide_html_validation_service import SlideHtmlValidationService" in slide_html_text
|
||
assert "from .slide_document_service import SlideDocumentService" in slide_html_text
|
||
assert "self._content_service = SlideContentService(self)" in slide_html_text
|
||
assert "self._media_service = SlideMediaService(self)" in slide_html_text
|
||
assert "self._cleanup_service = SlideHtmlCleanupService(self)" in slide_html_text
|
||
assert "self._validation_service = SlideHtmlValidationService(self)" in slide_html_text
|
||
assert "self._document_service = SlideDocumentService(self)" in slide_html_text
|
||
assert "def _clean_html_response(self, raw_content: str) -> str:" in slide_cleanup_text
|
||
|
||
assert "from .runtime_provider_service import RuntimeProviderService" in runtime_ai_text
|
||
assert "from .runtime_config_service import RuntimeConfigService" in runtime_ai_text
|
||
assert "from .runtime_maintenance_service import RuntimeMaintenanceService" in runtime_ai_text
|
||
assert "self._provider_service = RuntimeProviderService(self)" in runtime_ai_text
|
||
assert "self._config_service = RuntimeConfigService(self)" in runtime_ai_text
|
||
assert "self._maintenance_service = RuntimeMaintenanceService(self)" in runtime_ai_text
|
||
assert "@property" in runtime_ai_text
|
||
assert "return self._provider_service.ai_provider" in runtime_ai_text
|
||
|
||
assert len(project_generation_text.splitlines()) < 180
|
||
assert len(slide_html_text.splitlines()) < 200
|
||
assert len(slide_cleanup_text.splitlines()) < 120
|
||
assert len(runtime_ai_text.splitlines()) < 160
|
||
|
||
project_generation_methods = _direct_class_methods(
|
||
"src/landppt/services/outline/project_outline_generation_service.py",
|
||
"ProjectOutlineGenerationService",
|
||
)
|
||
slide_html_methods = _direct_class_methods(
|
||
"src/landppt/services/slide/slide_html_service.py",
|
||
"SlideHtmlService",
|
||
)
|
||
runtime_ai_methods = _direct_class_methods(
|
||
"src/landppt/services/runtime/runtime_ai_service.py",
|
||
"RuntimeAIService",
|
||
)
|
||
|
||
assert {"generate_outline_streaming", "_execute_outline_generation", "_validate_outline_structure"} <= project_generation_methods
|
||
assert {"_generate_single_slide_html_with_prompts", "_generate_fallback_slide_html", "_generate_html_with_retry"} <= slide_html_methods
|
||
assert {"ai_provider", "_text_completion_for_role", "_configure_summeryfile_api"} <= runtime_ai_methods
|
||
|
||
assert "async def generate_outline" in project_creation_text
|
||
assert "async def generate_outline_streaming" in project_streaming_text
|
||
assert "async def _validate_and_repair_outline_json" in project_validation_text
|
||
assert "async def _execute_outline_generation" in project_page_count_text
|
||
|
||
assert "async def generate_slides_parallel" in slide_content_text
|
||
assert "async def _generate_single_slide_html_with_prompts" in slide_media_text
|
||
assert "async def _generate_html_with_retry" in slide_validation_text
|
||
assert "def _generate_fallback_slide_html" in slide_document_text
|
||
assert "def _clean_html_response" in slide_cleanup_text
|
||
|
||
assert "@property" in runtime_provider_text
|
||
assert "async def _get_current_ai_config_async" in runtime_config_text
|
||
assert "def get_cache_stats" in runtime_maintenance_text
|
||
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_creation_service.py", "ProjectOutlineCreationService")
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_streaming_service.py", "ProjectOutlineStreamingService")
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_validation_service.py", "ProjectOutlineValidationService")
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_page_count_service.py", "ProjectOutlinePageCountService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_content_service.py", "SlideContentService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_media_service.py", "SlideMediaService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_html_cleanup_service.py", "SlideHtmlCleanupService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_html_validation_service.py", "SlideHtmlValidationService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_document_service.py", "SlideDocumentService")
|
||
_direct_class_methods("src/landppt/services/runtime/runtime_provider_service.py", "RuntimeProviderService")
|
||
_direct_class_methods("src/landppt/services/runtime/runtime_config_service.py", "RuntimeConfigService")
|
||
_direct_class_methods("src/landppt/services/runtime/runtime_maintenance_service.py", "RuntimeMaintenanceService")
|
||
|
||
|
||
def test_third_level_service_splits_keep_creation_validation_and_html_validation_thin():
|
||
project_creation_text = _read("src/landppt/services/outline/project_outline_creation_service.py")
|
||
project_prompt_text = _read("src/landppt/services/outline/project_outline_prompt_service.py")
|
||
project_research_text = _read("src/landppt/services/outline/project_outline_research_service.py")
|
||
|
||
project_validation_text = _read("src/landppt/services/outline/project_outline_validation_service.py")
|
||
project_repair_text = _read("src/landppt/services/outline/project_outline_repair_service.py")
|
||
project_normalization_text = _read("src/landppt/services/outline/project_outline_normalization_service.py")
|
||
|
||
slide_validation_text = _read("src/landppt/services/slide/slide_html_validation_service.py")
|
||
slide_inspection_text = _read("src/landppt/services/slide/slide_html_inspection_service.py")
|
||
slide_recovery_text = _read("src/landppt/services/slide/slide_html_recovery_service.py")
|
||
|
||
assert "from .project_outline_prompt_service import ProjectOutlinePromptService" in project_creation_text
|
||
assert "from .project_outline_research_service import ProjectOutlineResearchService" in project_creation_text
|
||
assert "self._prompt_service = ProjectOutlinePromptService(self)" in project_creation_text
|
||
assert "self._research_service = ProjectOutlineResearchService(self)" in project_creation_text
|
||
|
||
assert "from .project_outline_repair_service import ProjectOutlineRepairService" in project_validation_text
|
||
assert "from .project_outline_normalization_service import ProjectOutlineNormalizationService" in project_validation_text
|
||
assert "self._repair_service = ProjectOutlineRepairService(self)" in project_validation_text
|
||
assert "self._normalization_service = ProjectOutlineNormalizationService(self)" in project_validation_text
|
||
|
||
assert "from .slide_html_inspection_service import SlideHtmlInspectionService" in slide_validation_text
|
||
assert "from .slide_html_recovery_service import SlideHtmlRecoveryService" in slide_validation_text
|
||
assert "self._inspection_service = SlideHtmlInspectionService(self)" in slide_validation_text
|
||
assert "self._recovery_service = SlideHtmlRecoveryService(self)" in slide_validation_text
|
||
|
||
assert len(project_creation_text.splitlines()) < 120
|
||
assert len(project_validation_text.splitlines()) < 120
|
||
assert len(slide_validation_text.splitlines()) < 100
|
||
|
||
project_creation_methods = _direct_class_methods(
|
||
"src/landppt/services/outline/project_outline_creation_service.py",
|
||
"ProjectOutlineCreationService",
|
||
)
|
||
project_validation_methods = _direct_class_methods(
|
||
"src/landppt/services/outline/project_outline_validation_service.py",
|
||
"ProjectOutlineValidationService",
|
||
)
|
||
slide_validation_methods = _direct_class_methods(
|
||
"src/landppt/services/slide/slide_html_validation_service.py",
|
||
"SlideHtmlValidationService",
|
||
)
|
||
|
||
assert {"generate_outline", "_create_outline_prompt", "conduct_research_and_merge_with_files"} <= project_creation_methods
|
||
assert {"_validate_and_repair_outline_json", "_parse_outline_content", "_update_outline_generation_stage"} <= project_validation_methods
|
||
assert {"_validate_html_completeness", "_generate_html_with_retry", "_fix_incomplete_html"} <= slide_validation_methods
|
||
|
||
assert "def _create_outline_prompt" in project_prompt_text
|
||
assert "async def generate_outline" in project_research_text
|
||
assert "async def _validate_and_repair_outline_json" in project_repair_text
|
||
assert "def _parse_outline_content" in project_normalization_text
|
||
assert "def _validate_html_completeness" in slide_inspection_text
|
||
assert "async def _generate_html_with_retry" in slide_recovery_text
|
||
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_prompt_service.py", "ProjectOutlinePromptService")
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_research_service.py", "ProjectOutlineResearchService")
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_repair_service.py", "ProjectOutlineRepairService")
|
||
_direct_class_methods("src/landppt/services/outline/project_outline_normalization_service.py", "ProjectOutlineNormalizationService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_html_inspection_service.py", "SlideHtmlInspectionService")
|
||
_direct_class_methods("src/landppt/services/slide/slide_html_recovery_service.py", "SlideHtmlRecoveryService")
|
||
|
||
|
||
class _DummyProjectManager:
|
||
def __init__(self, project):
|
||
self.project = project
|
||
self.updated = []
|
||
|
||
async def get_project(self, project_id, user_id=None):
|
||
del project_id, user_id
|
||
return self.project
|
||
|
||
async def update_project_metadata(self, project_id, metadata):
|
||
self.updated.append((project_id, dict(metadata)))
|
||
self.project.project_metadata = dict(metadata)
|
||
|
||
|
||
class _DummyGlobalTemplateService:
|
||
def __init__(self):
|
||
self.incremented = []
|
||
self.generated_kwargs = []
|
||
|
||
async def get_template_by_id(self, template_id):
|
||
return {"id": template_id, "template_name": "Global", "is_active": True}
|
||
|
||
async def get_default_template(self):
|
||
return {"id": 9, "template_name": "Default", "is_active": True}
|
||
|
||
async def increment_template_usage(self, template_id):
|
||
self.incremented.append(template_id)
|
||
|
||
async def generate_template_with_ai(self, **kwargs):
|
||
self.generated_kwargs.append(dict(kwargs))
|
||
return {
|
||
"template_name": kwargs["template_name"],
|
||
"html_template": "<!DOCTYPE html><html></html>",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_template_selection_service_updates_project_metadata_and_returns_cached_free_template():
|
||
project = SimpleNamespace(
|
||
project_metadata={"selected_global_template_id": 1},
|
||
outline={"slides": []},
|
||
confirmed_requirements={},
|
||
topic="Quarterly Review",
|
||
scenario="general",
|
||
)
|
||
|
||
owner = SimpleNamespace(
|
||
project_manager=_DummyProjectManager(project),
|
||
global_template_service=_DummyGlobalTemplateService(),
|
||
_free_template_generation_locks={},
|
||
cleared_projects=[],
|
||
)
|
||
|
||
def _clear_cached_style_genes(project_id=None):
|
||
owner.cleared_projects.append(project_id)
|
||
|
||
owner.clear_cached_style_genes = _clear_cached_style_genes
|
||
owner._build_current_time_prompt_context = lambda: "2026-03-28 12:00:00 CST"
|
||
|
||
service = TemplateSelectionService(owner)
|
||
|
||
result = await service.select_free_template_for_project("proj-1", user_id=7)
|
||
|
||
assert result["success"] is True
|
||
assert owner.project_manager.updated[-1][1]["template_mode"] == "free"
|
||
assert owner.project_manager.updated[-1][1]["free_template_status"] == "pending"
|
||
assert owner.cleared_projects == ["proj-1"]
|
||
|
||
project.project_metadata = {
|
||
"template_mode": "free",
|
||
"free_template_name": "AI Free",
|
||
"free_template_html": "<section>ok</section>",
|
||
}
|
||
|
||
selected = await service.get_selected_global_template("proj-1")
|
||
|
||
assert selected["template_name"] == "AI Free"
|
||
assert selected["html_template"] == "<section>ok</section>"
|
||
assert selected["created_by"] == "ai_free"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_template_selection_service_uses_prebuilt_free_template_prompt_once():
|
||
project = SimpleNamespace(
|
||
project_metadata={"template_mode": "free"},
|
||
outline={"slides": [{"title": "封面", "content_points": ["目标", "进展"]}]},
|
||
confirmed_requirements={"target_audience": "管理层"},
|
||
topic="Quarterly Review",
|
||
scenario="general",
|
||
)
|
||
|
||
global_template_service = _DummyGlobalTemplateService()
|
||
owner = SimpleNamespace(
|
||
project_manager=_DummyProjectManager(project),
|
||
global_template_service=global_template_service,
|
||
_free_template_generation_locks={},
|
||
cleared_projects=[],
|
||
)
|
||
owner.clear_cached_style_genes = lambda project_id=None: owner.cleared_projects.append(project_id)
|
||
owner._build_current_time_prompt_context = lambda: "2026-03-28 12:00:00 CST"
|
||
|
||
service = TemplateSelectionService(owner)
|
||
|
||
selected = await service.get_selected_global_template("proj-2")
|
||
|
||
assert selected["template_name"].startswith("自由模板-")
|
||
assert global_template_service.generated_kwargs
|
||
kwargs = global_template_service.generated_kwargs[-1]
|
||
assert kwargs["prompt_is_ready"] is True
|
||
assert kwargs["generation_mode"] == "text_only"
|
||
assert kwargs["prompt"].count("**创意要求**") == 1
|
||
assert kwargs["prompt"].count("**技术要求**") == 1
|
||
assert kwargs["prompt"].count("直接输出完整 HTML 模板") == 1
|
||
|
||
|
||
def test_creative_design_service_clears_owner_caches_and_files(tmp_path):
|
||
cache_dir = tmp_path / "style_genes"
|
||
cache_dir.mkdir()
|
||
for filename in [
|
||
"proj-1_style_genes.json",
|
||
"proj-1_combined_genes_guide.json",
|
||
"proj-1_creative_guide.json",
|
||
]:
|
||
(cache_dir / filename).write_text("{}", encoding="utf-8")
|
||
|
||
owner = SimpleNamespace(
|
||
cache_dirs={"style_genes": cache_dir},
|
||
_cached_style_genes={"proj-1": "genes"},
|
||
_cached_style_genes_and_guide={"proj-1": {"style_genes": "genes", "design_guide": "guide"}},
|
||
_cached_project_creative_guides={"proj-1": {"design_guide": "guide", "source_hash": "hash"}},
|
||
_cached_slide_creative_guides={"proj-1:1": "slide-guide"},
|
||
_style_genes_ready_events={"proj-1": object()},
|
||
_project_creative_guidance_ready_events={"proj-1": object()},
|
||
_slide_creative_guide_ready_events={"proj-1:1": object()},
|
||
)
|
||
|
||
service = CreativeDesignService(owner)
|
||
|
||
summary = service._build_creative_slides_summary(
|
||
[
|
||
{"title": "Intro", "slide_type": "title", "content_points": ["A", "B"]},
|
||
{"title": "Data", "slide_type": "content", "content_points": ["C"]},
|
||
]
|
||
)
|
||
assert "1. Intro" in summary
|
||
assert "2. Data" in summary
|
||
|
||
service.clear_cached_style_genes("proj-1")
|
||
|
||
assert owner._cached_style_genes == {}
|
||
assert owner._cached_style_genes_and_guide == {}
|
||
assert owner._cached_project_creative_guides == {}
|
||
assert owner._cached_slide_creative_guides == {}
|
||
assert owner._style_genes_ready_events == {}
|
||
assert owner._project_creative_guidance_ready_events == {}
|
||
assert owner._slide_creative_guide_ready_events == {}
|
||
assert not any(cache_dir.iterdir())
|
||
assert service.get_cached_style_genes_info()["total_count"] == 0
|
||
|
||
|
||
def test_layout_repair_service_helpers_keep_skip_logic_and_prompt_contract():
|
||
service = LayoutRepairService(SimpleNamespace())
|
||
|
||
low_report = "- severity: low"
|
||
mixed_report = "- severity: medium"
|
||
html = "<html><head></head><body><div>Body</div></body></html>"
|
||
injected_html = service._inject_anti_overflow_css(html)
|
||
|
||
assert service._should_skip_layout_repair(low_report) is True
|
||
assert service._should_skip_layout_repair(mixed_report) is False
|
||
assert "anti-overflow-fix" in injected_html
|
||
assert "text-overflow: unset" in injected_html
|
||
assert '[class*="card"] > *' in injected_html
|
||
assert ".content-layer," in injected_html
|
||
|
||
prompt = service._build_layout_repair_prompt("<html></html>", "- issues: overflow")
|
||
assert "```html" in prompt
|
||
assert "overflow" in prompt
|
||
assert "只有当页码越界、贴边、换行、漂移、被遮挡或安全区被侵占时" in prompt
|
||
assert "不得改回 flex/grid 正文流" in prompt
|
||
assert "inline style 表达了与上述相同的页码独立定位关系" in prompt
|
||
|
||
|
||
def test_template_generation_prompts_require_stable_page_number_anchor():
|
||
owner = SimpleNamespace(_build_current_time_prompt_context=lambda: "2026-03-28 12:00:00 CST")
|
||
service = TemplateSelectionService(owner)
|
||
|
||
prompt = service._build_free_template_prompt(
|
||
project=SimpleNamespace(topic="年度复盘", scenario="general"),
|
||
outline={"slides": [{"title": "封面", "content_points": ["目标", "进展"]}]},
|
||
confirmed={},
|
||
)
|
||
|
||
assert "只输出一个 1280×720 的通用页面模板" in prompt
|
||
assert "标题锚点区、主舞台区和编号锚点区" in prompt
|
||
assert "不要输出 cover/toc/transition/content/title/ending 等多页面类型变体" in prompt
|
||
assert prompt.count("**创意愿景——单页母版**") == 1
|
||
assert prompt.count("**技术要求**") == 1
|
||
|
||
template_prompt = GlobalMasterTemplateService._get_template_annotation_prompt_text()
|
||
assert "只生成一个 1280×720 的通用页面模板" in template_prompt
|
||
assert "使用 inline style 做等价实现同样有效" in template_prompt
|
||
assert "画布根容器负责 `position:relative` 与 1280×720 裁切" in template_prompt
|
||
|
||
creative_prompt = GlobalMasterTemplateService._get_template_generation_creative_prompt_text()
|
||
assert "**创意愿景——单页母版**" in creative_prompt
|
||
assert "不要分别生成页面类型变体" in creative_prompt
|
||
|
||
|
||
def test_slide_generation_service_delegates_owner_attributes():
|
||
owner = SimpleNamespace(example_value="ok")
|
||
service = SlideGenerationService(owner)
|
||
|
||
assert service.example_value == "ok"
|
||
|
||
|
||
def test_slide_html_service_cleans_markdown_wrapped_html_response():
|
||
clean_html_response = _load_class_method(
|
||
"src/landppt/services/slide/slide_html_cleanup_service.py",
|
||
"SlideHtmlCleanupService",
|
||
"_clean_html_response",
|
||
)
|
||
owner = SimpleNamespace(_strip_think_tags=lambda raw: raw.replace("<think>internal</think>", "").strip())
|
||
|
||
cleaned = clean_html_response(
|
||
owner,
|
||
"<think>internal</think>\nHere's the HTML code:\n```html\n<!DOCTYPE html>\n<html><body><div>ok</div></body></html>\n```"
|
||
)
|
||
|
||
assert cleaned.startswith("<!DOCTYPE html>")
|
||
assert cleaned.endswith("</html>")
|
||
assert "internal" not in cleaned
|
||
|
||
|
||
def test_slide_html_cleanup_does_not_warn_for_valid_html_with_error_like_text(caplog):
|
||
owner = SimpleNamespace(_strip_think_tags=lambda raw: raw.strip())
|
||
service = SlideHtmlCleanupService(owner)
|
||
response = """```html
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body>
|
||
<div class="metric">Runtime error rate dropped to 0.2%</div>
|
||
</body>
|
||
</html>
|
||
```"""
|
||
|
||
with caplog.at_level("WARNING", logger="landppt.services.slide.slide_html_cleanup_service"):
|
||
cleaned = service._clean_html_response(response)
|
||
|
||
assert "Runtime error rate dropped" in cleaned
|
||
assert "AI response appears to be an error message instead of HTML" not in caplog.text
|
||
|
||
|
||
def test_slide_html_cleanup_warns_for_plain_error_text(caplog):
|
||
owner = SimpleNamespace(_strip_think_tags=lambda raw: raw.strip())
|
||
service = SlideHtmlCleanupService(owner)
|
||
|
||
with caplog.at_level("WARNING", logger="landppt.services.slide.slide_html_cleanup_service"):
|
||
cleaned = service._clean_html_response("Sorry, I cannot generate HTML for this slide.")
|
||
|
||
assert cleaned == ""
|
||
assert "AI response appears to be an error message instead of HTML" in caplog.text
|
||
|
||
|
||
def _test_template_generation_prompts_require_stable_page_number_anchor_current():
|
||
owner = SimpleNamespace(_build_current_time_prompt_context=lambda: "2026-03-28 12:00:00 CST")
|
||
service = TemplateSelectionService(owner)
|
||
|
||
prompt = service._build_free_template_prompt(
|
||
project=SimpleNamespace(topic="Annual review", scenario="general"),
|
||
outline={"slides": [{"title": "Cover", "content_points": ["Goal", "Progress"]}]},
|
||
confirmed={},
|
||
)
|
||
|
||
assert "只输出一个 1280×720 的通用页面模板" in prompt
|
||
assert "标题锚点区、主舞台区和编号锚点区" in prompt
|
||
assert "编号锚点可省略或极简,但不能挤占正文" in prompt
|
||
assert "不要输出 cover/toc/transition/content/title/ending 等多页面类型变体" in prompt
|
||
|
||
template_prompt = GlobalMasterTemplateService._get_template_annotation_prompt_text()
|
||
assert "只生成一个 1280×720 的通用页面模板" in template_prompt
|
||
assert "标题锚点区、主舞台区、编号锚点区三类职责层" in template_prompt
|
||
assert "类名仅用于说明结构关系,使用 inline style 做等价实现同样有效" in template_prompt
|
||
|
||
creative_prompt = GlobalMasterTemplateService._get_template_generation_creative_prompt_text()
|
||
assert "**创意愿景——单页母版**" in creative_prompt
|
||
assert "不要分别生成页面类型变体" in creative_prompt
|
||
|
||
|
||
test_template_generation_prompts_require_stable_page_number_anchor = (
|
||
_test_template_generation_prompts_require_stable_page_number_anchor_current
|
||
)
|