from __future__ import annotations import asyncio import uuid from dataclasses import dataclass from datetime import datetime, timezone from typing import Any import pytest from fastapi import HTTPException from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sql.template import TemplateModel from models.sql.template_create_info import TemplateCreateInfoModel from templates import handler as template_handler from templates.layout_code_validation import ( LayoutCodeValidationError, ValidatedLayoutCode, ) @dataclass class _ScalarResult: value: Any def scalars(self): return self def all(self): return [] class _TemplateSession: def __init__( self, *, template_info: TemplateCreateInfoModel | None = None, template: TemplateModel | None = None, layout: PresentationLayoutCodeModel | None = None, ) -> None: self.template_info = template_info self.template = template self.layout = layout self.added: list[Any] = [] self.added_all: list[Any] = [] self.execute_count = 0 self.commit_count = 0 async def get(self, model: Any, key: Any): if model is TemplateCreateInfoModel: return self.template_info if model is TemplateModel: return self.template return None async def scalar(self, *_args: Any, **_kwargs: Any): return self.layout async def execute(self, *_args: Any, **_kwargs: Any): self.execute_count += 1 return _ScalarResult(self.layout) def add(self, obj: Any) -> None: self.added.append(obj) def add_all(self, objs: list[Any]) -> None: self.added_all.extend(list(objs)) async def commit(self) -> None: self.commit_count += 1 async def refresh(self, _obj: Any) -> None: if getattr(_obj, "created_at", None) is None: _obj.created_at = datetime.now(timezone.utc) return None def _validated(code: str = "validated-code") -> ValidatedLayoutCode: return ValidatedLayoutCode( layout_code=code, layout_id="validated-layout", layout_name="Validated Layout", layout_description="Validated layout description", schema_json_value={"type": "object"}, ) async def _invalid_validator(_code: str): raise LayoutCodeValidationError("Unexpected token", line=2, column=5) async def _valid_validator(code: str): return _validated(f"normalized:{code}") def test_save_template_rejects_invalid_layout_before_commit(monkeypatch): template_info_id = uuid.uuid4() session = _TemplateSession( template_info=TemplateCreateInfoModel( id=template_info_id, fonts={}, pptx_url="/tmp/test.pptx", slide_htmls=["
"], slide_image_urls=["/tmp/test.png"], ) ) monkeypatch.setattr(template_handler, "validate_layout_code", _invalid_validator) request = template_handler.SaveTemplateRequest( template_info_id=template_info_id, name="Template", layouts=[ template_handler.SaveTemplateLayoutData( layout_id="one", layout_name="One", layout_code="broken", ) ], ) with pytest.raises(HTTPException) as exc_info: asyncio.run(template_handler.save_template(request, session)) assert exc_info.value.status_code == 400 assert session.commit_count == 0 assert session.added == [] assert session.added_all == [] def test_update_template_rejects_invalid_layout_before_delete(monkeypatch): template_id = uuid.uuid4() session = _TemplateSession(template=TemplateModel(id=template_id, name="Template")) monkeypatch.setattr(template_handler, "validate_layout_code", _invalid_validator) request = template_handler.UpdateTemplateRequest( id=template_id, layouts=[ template_handler.SaveTemplateLayoutData( layout_id="one", layout_name="One", layout_code="broken", ) ], ) with pytest.raises(HTTPException) as exc_info: asyncio.run(template_handler.update_template(request, session)) assert exc_info.value.status_code == 400 assert session.execute_count == 0 assert session.commit_count == 0 assert session.added_all == [] def test_save_slide_layout_rejects_invalid_code_without_replacing_existing( monkeypatch, ): template_id = uuid.uuid4() layout = PresentationLayoutCodeModel( presentation=template_id, layout_id="existing", layout_name="Existing", layout_code="previous-valid-code", ) session = _TemplateSession( template=TemplateModel(id=template_id, name="Template"), layout=layout, ) monkeypatch.setattr(template_handler, "validate_layout_code", _invalid_validator) request = template_handler.SaveSlideLayoutRequest( template_id=template_id, layout_id="existing", layout_code="broken", ) with pytest.raises(HTTPException) as exc_info: asyncio.run(template_handler.save_slide_layout(request, session)) assert exc_info.value.status_code == 400 assert layout.layout_code == "previous-valid-code" assert session.commit_count == 0 def test_save_template_persists_normalized_validated_code(monkeypatch): template_info_id = uuid.uuid4() session = _TemplateSession( template_info=TemplateCreateInfoModel( id=template_info_id, fonts={"Inter": "/fonts/Inter.ttf"}, pptx_url="/tmp/test.pptx", slide_htmls=[""], slide_image_urls=["/tmp/test.png"], ) ) monkeypatch.setattr(template_handler, "validate_layout_code", _valid_validator) request = template_handler.SaveTemplateRequest( template_info_id=template_info_id, name="Template", layouts=[ template_handler.SaveTemplateLayoutData( layout_id="one", layout_name="One", layout_code="raw-code", ) ], ) response = asyncio.run(template_handler.save_template(request, session)) assert response.name == "Template" assert session.commit_count == 1 assert len(session.added_all) == 1 saved_layout = session.added_all[0] assert saved_layout.layout_code == "normalized:raw-code" assert saved_layout.layout_id == "validated-layout" assert saved_layout.layout_name == "Validated Layout" def test_generated_layout_retries_until_repair_succeeds(monkeypatch): calls: list[str] = [] async def retry_call(user_text: str) -> str: calls.append(user_text) if len(calls) < 3: return "still-bad-code" return "fixed-code" async def validate(code: str) -> ValidatedLayoutCode: if code == "bad-code": raise LayoutCodeValidationError("Bad JSX", line=3, column=9) if code == "still-bad-code": raise LayoutCodeValidationError("Missing Schema", line=1, column=1) return _validated(code) monkeypatch.setattr(template_handler, "validate_layout_code", validate) result = asyncio.run( template_handler._validate_provider_layout_code_or_retry( code="bad-code", retry_call=retry_call, original_user_text="original prompt", normalize_code=lambda value: value, ) ) assert result.layout_code == "fixed-code" assert len(calls) == 3 assert "Bad JSX at 3:9" in calls[0] assert "bad-code" in calls[0] assert "Missing Schema at 1:1" in calls[2] assert "still-bad-code" in calls[2] def test_generated_layout_returns_502_when_repair_is_still_invalid(monkeypatch): calls: list[str] = [] async def retry_call(user_text: str) -> str: calls.append(user_text) return "still-bad-code" async def validate(_code: str) -> ValidatedLayoutCode: raise LayoutCodeValidationError("Missing Schema", line=1, column=1) monkeypatch.setattr(template_handler, "validate_layout_code", validate) with pytest.raises(HTTPException) as exc_info: asyncio.run( template_handler._validate_provider_layout_code_or_retry( code="bad-code", retry_call=retry_call, original_user_text="original prompt", normalize_code=lambda value: value, ) ) assert exc_info.value.status_code == 502 assert len(calls) == template_handler.LAYOUT_CODE_REPAIR_ATTEMPTS assert "invalid layout code after 3 repair attempts" in str(exc_info.value.detail)