import asyncio import copy import json import uuid from unittest.mock import AsyncMock, patch from llmai.shared import AssistantToolCall # type: ignore[import-not-found] from constants.presentation import MAX_NUMBER_OF_SLIDES, MAX_OUTLINE_CONTENT_WORDS from models.sql.presentation import PresentationModel, PresentationVersion from models.sql.slide import SlideModel from services.chat.memory_layer import PresentationChatMemoryLayer from services.chat.tools import ChatTools from utils.outline_limits import count_outline_words def _run(coro): return asyncio.run(coro) def _slide_ui(): return { "id": "intro", "description": "Intro slide layout for chat ui testing.", "components": [ { "id": "hero", "description": "Hero title component for testing.", "position": {"x": 0, "y": 0}, "size": {"width": 100, "height": 40}, "elements": [ { "type": "text", "decorative": False, "name": "Title", "max_length": 100, "min_length": 1, "runs": [ { "text": "Old title", "font": {"size": 20, "family": "Inter"}, } ], } ], }, { "id": "body", "description": "Body list component for testing.", "position": {"x": 0, "y": 50}, "size": {"width": 100, "height": 60}, "elements": [ { "type": "text-list", "decorative": False, "name": "Bullets", "max_items": 6, "min_items": 1, "max_item_length": 80, "min_item_length": 1, "items": [[{"text": "First point"}]], } ], }, ], } def _slide(): return SlideModel( id=uuid.uuid4(), presentation=uuid.uuid4(), layout_group="template-v2-x", layout="intro", index=0, content={}, properties=None, ui=_slide_ui(), ) class _FakeSlideSession: def __init__(self, slide: SlideModel): self.slide = slide self.presentation = PresentationModel( id=slide.presentation, version=PresentationVersion.V1_STANDARD, content="deck", n_slides=1, language="English", theme={ "id": "test-theme", "name": "Test Theme", "data": { "colors": { "background_text": "#111827", "stroke": "#E5E7EB", "graph_0": "#123456", "graph_1": "#234567", "graph_2": "#345678", } }, }, ) self.commit_count = 0 self.added: list = [] async def scalar(self, *_args, **_kwargs): return self.slide async def get(self, model, key): if model is PresentationModel and key == self.presentation.id: return self.presentation return None def add(self, obj): self.added.append(obj) async def commit(self): self.commit_count += 1 async def refresh(self, _obj): return None def _tools(slide: SlideModel) -> tuple[ChatTools, _FakeSlideSession]: session = _FakeSlideSession(slide) memory = PresentationChatMemoryLayer(session, slide.presentation) return ChatTools(memory), session def _call(tools: ChatTools, name: str, arguments: dict): return _run( tools.execute_tool_call( AssistantToolCall(id="call_1", name=name, arguments=json.dumps(arguments)) ) ) def _set_reusable_table_layout(session: _FakeSlideSession): session.presentation.layout = { "name": "template-v2-test", "layouts": [ { "id": "comparison", "description": "Comparison slide with reusable blocks.", "components": [ { "id": "table_card", "description": "Styled comparison table block.", "position": {"x": 120, "y": 140}, "size": {"width": 900, "height": 360}, "elements": [{"type": "table", "name": "Comparison table"}], }, ], } ], } def test_get_slide_elements_reports_editable_layout(): slide = _slide() tools, _ = _tools(slide) result = _call(tools, "getSlideAtIndex", {"index": 0, "includeFullContent": True}) assert result["ok"] is True payload = result["result"]["slide"]["ui_summary"] assert payload["editable"] is True assert payload["component_count"] == 2 assert payload["editable_count"] == 2 paths = {element["path"] for element in payload["elements"]} assert "components[0].elements[0]" in paths title = next( element for element in payload["elements"] if element["path"] == "components[0].elements[0]" ) assert title["style"] == {"font": {"size": 20, "family": "Inter"}} def test_get_available_blocks_returns_matching_block_summary_only(): slide = _slide() tools, session = _tools(slide) session.presentation.layout = { "name": "template-v2-test", "layouts": [ { "id": "comparison", "description": "Comparison slide with reusable blocks.", "components": [ { "id": "title_block", "description": "Simple title block.", "position": {"x": 80, "y": 40}, "size": {"width": 600, "height": 80}, "elements": [{"type": "text", "name": "Title"}], }, { "id": "table_card", "description": "Styled comparison table block.", "position": {"x": 120, "y": 140}, "size": {"width": 900, "height": 360}, "elements": [{"type": "table", "name": "Comparison table"}], }, ], } ], } result = _call( tools, "getAvailableBlocks", { "query": "table", "layoutId": None, "elementType": "table", "blockId": None, "includeFullContent": False, "maxResults": 10, }, ) assert result["ok"] is True payload = result["result"] assert payload["found"] is True assert payload["count"] == 1 block = payload["blocks"][0] assert block["block_id"] == "layout:comparison:table_card" assert block["layout_id"] == "comparison" assert block["component_id"] == "table_card" assert block["element_types"] == ["table"] assert block["element_names"] == ["Comparison table"] assert "component" not in block def test_get_available_blocks_matches_title_element_names(): slide = _slide() tools, session = _tools(slide) session.presentation.layout = { "name": "template-v2-test", "layouts": [ { "id": "metrics", "description": "Metrics slide with reusable components.", "components": [ { "id": "masthead", "description": "Styled text component.", "position": {"x": 72, "y": 48}, "size": {"width": 720, "height": 96}, "elements": [{"type": "text", "name": "Title"}], }, { "id": "body_copy", "description": "Styled body copy component.", "position": {"x": 72, "y": 160}, "size": {"width": 720, "height": 160}, "elements": [{"type": "text", "name": "Body"}], }, ], } ], } result = _call( tools, "getAvailableBlocks", { "query": "title", "layoutId": None, "elementType": "text", "blockId": None, "includeFullContent": False, "maxResults": 10, }, ) assert result["ok"] is True payload = result["result"] assert payload["found"] is True assert payload["count"] == 1 block = payload["blocks"][0] assert block["block_id"] == "layout:metrics:masthead" assert block["element_types"] == ["text"] assert block["element_names"] == ["Title"] def test_get_available_blocks_can_return_exact_block_json(): slide = _slide() tools, session = _tools(slide) _set_reusable_table_layout(session) result = _call( tools, "getAvailableBlocks", { "query": None, "layoutId": None, "elementType": None, "blockId": "layout:comparison:table_card", "includeFullContent": True, "maxResults": 1, }, ) assert result["ok"] is True block = result["result"]["blocks"][0] assert block["component"]["id"] == "table_card" assert block["component"]["elements"][0]["type"] == "table" def test_add_slide_element_requires_reusable_table_block_when_available(): slide = _slide() tools, session = _tools(slide) _set_reusable_table_layout(session) table = { "type": "table", "name": "Plain table", "columns": ["Metric", "Value"], "rows": [["PM2.5", "39%"]], } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(table)}, ) assert result["ok"] is False assert "Reusable block available for table insertion" in result["error"] assert "getAvailableBlocks" in result["recovery"]["guidance"][0] assert len(slide.ui["components"]) == 2 assert session.commit_count == 0 def test_add_slide_component_requires_source_block_for_table_when_available(): slide = _slide() tools, session = _tools(slide) _set_reusable_table_layout(session) component = { "id": "plain_table", "description": "Plain primitive table component.", "position": {"x": 100, "y": 100}, "size": {"width": 900, "height": 300}, "elements": [ { "type": "table", "name": "Plain table", "columns": ["Metric", "Value"], "rows": [["PM2.5", "39%"]], } ], } result = _call( tools, "addComponent", {"index": 0, "component": json.dumps(component)}, ) assert result["ok"] is False assert "sourceBlockId='layout:comparison:table_card'" in result["error"] assert len(slide.ui["components"]) == 2 assert session.commit_count == 0 def test_add_slide_component_accepts_source_block_for_table(): slide = _slide() tools, session = _tools(slide) _set_reusable_table_layout(session) component = { "id": "table_card", "description": "Styled comparison table block.", "position": {"x": 120, "y": 140}, "size": {"width": 900, "height": 360}, "elements": [ { "type": "table", "name": "Comparison table", "columns": ["Metric", "Value"], "rows": [["PM2.5", "39%"]], } ], } result = _call( tools, "addComponent", { "index": 0, "component": json.dumps(component), "sourceBlockId": "layout:comparison:table_card", }, ) assert result["ok"] is True assert result["result"]["added"] is True assert slide.ui["components"][-1]["id"] == "table_card" assert session.commit_count == 1 def test_update_slide_element_edits_ui_text(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[0].elements[0]", "text": "New title", }, ) assert result["ok"] is True assert result["result"]["updated"] is True element = slide.ui["components"][0]["elements"][0] assert element["runs"][0]["text"] == "New title" # The renderer reads the flattened top-level `text` in preference to `runs`, # so it must be kept in sync or the edit is invisible / gets reverted. assert element["text"] == "New title" assert session.commit_count == 1 def test_update_slide_element_accepts_stringified_null_optionals(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[0].elements[0]", "text": "New title", "items": "null", "tableCell": "null", "chart": "null", "table": "null", "element": "null", "position": "null", "size": "null", }, ) assert result["ok"] is True assert result["result"]["updated"] is True assert slide.ui["components"][0]["elements"][0]["text"] == "New title" assert session.commit_count == 1 def _slide_with_image(): slide = _slide() slide.ui["components"].append( { "id": "visual", "description": "Hero image component.", "position": {"x": 0, "y": 120}, "size": {"width": 100, "height": 80}, "elements": [ { "type": "image", "decorative": False, "name": "Hero image", "data": "/static/images/placeholder.jpg", "is_icon": False, } ], } ) return slide def test_update_slide_element_moves_image_without_content_fields(): slide = _slide_with_image() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "text": None, "items": None, "tableCell": None, "chart": None, "table": None, "position": {"x": 12, "y": 34}, "size": None, }, ) image = slide.ui["components"][2]["elements"][0] assert result["ok"] is True assert result["result"]["updated"] is True assert image["position"] == {"x": 12.0, "y": 34.0} assert image["data"] == "/static/images/placeholder.jpg" assert session.commit_count == 1 def test_update_slide_element_sets_image_url_from_text(): slide = _slide_with_image() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "text": "/app_data/images/glacier.png", }, ) image = slide.ui["components"][2]["elements"][0] assert result["ok"] is True assert image["data"].endswith("/app_data/images/glacier.png") assert session.commit_count == 1 @patch.object(PresentationChatMemoryLayer, "generate_image", new_callable=AsyncMock) def test_update_slide_element_generates_image_from_prompt(mock_generate_image): mock_generate_image.return_value = "/app_data/images/generated.png" slide = _slide_with_image() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "text": "Melting glacier aerial photo", }, ) image = slide.ui["components"][2]["elements"][0] assert result["ok"] is True assert image["data"] == "/app_data/images/generated.png" assert image["prompt"] == "Melting glacier aerial photo" mock_generate_image.assert_awaited_once_with("Melting glacier aerial photo") assert session.commit_count == 1 def test_update_slide_element_edits_ui_size(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[0].elements[0]", "size": {"width": 80, "height": 24}, }, ) assert result["ok"] is True assert result["result"]["updated"] is True assert slide.ui["components"][0]["elements"][0]["size"] == { "width": 80.0, "height": 24.0, } assert session.commit_count == 1 def test_update_slide_element_applies_toolbar_style_patch(): slide = _slide() slide.ui["components"].append( { "id": "shape-block", "description": "Shape block.", "position": {"x": 0, "y": 120}, "size": {"width": 100, "height": 80}, "elements": [ { "type": "rectangle", "position": {"x": 0, "y": 0}, "size": {"width": 100, "height": 80}, "fill": {"color": "#FFFFFF", "opacity": 1}, "stroke": {"color": "#111111", "width": 0}, } ], } ) tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "element": json.dumps( { "fill": {"color": "#FF0000", "opacity": 0.5}, "stroke": {"width": 2}, } ), }, ) element = slide.ui["components"][2]["elements"][0] assert result["ok"] is True assert result["result"]["updated"] is True assert element["fill"] == {"color": "#FF0000", "opacity": 0.5} assert element["stroke"] == {"color": "#111111", "width": 2} assert session.commit_count == 1 def test_update_slide_element_applies_text_font_patch_to_runs(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[0].elements[0]", "font": { "family": "Roboto", "size": 36, "color": "#FF0000", "bold": True, }, "alignment": {"horizontal": "center", "vertical": "middle"}, }, ) element = slide.ui["components"][0]["elements"][0] assert result["ok"] is True assert result["result"]["updated"] is True assert element["font"] == { "family": "Roboto", "size": 36.0, "color": "#FF0000", "bold": True, } assert element["alignment"] == {"horizontal": "center", "vertical": "middle"} assert element["runs"][0]["text"] == "Old title" assert element["runs"][0]["font"] == { "size": 36.0, "family": "Roboto", "color": "#FF0000", "bold": True, } assert session.commit_count == 1 def test_update_slide_element_raw_font_patch_updates_text_runs(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[0].elements[0]", "element": json.dumps( { "font": { "size": 44, "color": "#00AAFF", } } ), }, ) element = slide.ui["components"][0]["elements"][0] assert result["ok"] is True assert element["font"] == {"size": 44, "color": "#00AAFF"} assert element["runs"][0]["font"] == { "size": 44, "family": "Inter", "color": "#00AAFF", } assert session.commit_count == 1 def test_update_slide_element_accepts_common_text_style_aliases(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[0].elements[0]", "fontSize": 28, "fontColor": "#22C55E", "fontFamily": "Inter Tight", "textAlign": "right", }, ) element = slide.ui["components"][0]["elements"][0] assert result["ok"] is True assert element["font"] == { "size": 28.0, "color": "#22C55E", "family": "Inter Tight", } assert element["alignment"] == {"horizontal": "right"} assert element["runs"][0]["font"] == { "size": 28.0, "family": "Inter Tight", "color": "#22C55E", } assert session.commit_count == 1 def test_update_slide_element_text_list_color_updates_item_runs(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[1].elements[0]", "color": "#7C3AED", "fontSize": 22, }, ) element = slide.ui["components"][1]["elements"][0] assert result["ok"] is True assert element["font"] == {"size": 22.0, "color": "#7C3AED"} assert element["items"][0][0]["font"] == {"size": 22.0, "color": "#7C3AED"} assert session.commit_count == 1 def test_get_slide_elements_reports_visible_flex_and_resizes_it(): slide = _slide() slide.ui["components"].append( { "id": "cards", "description": "Visible card row.", "elements": [ { "type": "flex", "name": "Cards", "direction": "row", "min_children": 1, "max_children": 3, "position": {"x": 10, "y": 20}, "size": {"width": 300, "height": 120}, "children": [ { "type": "text", "decorative": False, "name": "Card title", "min_length": 1, "max_length": 80, "runs": [{"text": "Old card"}], } ], } ], } ) tools, session = _tools(slide) elements = _call( tools, "getSlideAtIndex", {"index": 0, "includeFullContent": True}, )["result"]["slide"]["ui_summary"]["elements"] by_path = {element["path"]: element for element in elements} flex = by_path["components[2].elements[0]"] child = by_path["components[2].elements[0].children[0]"] assert flex["type"] == "flex" assert flex["content_editable"] is False assert flex["geometry_editable"] is True assert child["type"] == "text" assert child["content_editable"] is True result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "size": {"width": 280, "height": 100}, }, ) assert result["ok"] is True assert slide.ui["components"][2]["elements"][0]["size"] == { "width": 280.0, "height": 100.0, } assert session.commit_count == 1 def test_update_slide_element_updates_new_chart_model_fields(): slide = _slide() slide.ui["components"].append( { "id": "chart-block", "description": "Chart block.", "elements": [ { "type": "chart", "decorative": False, "name": "Emissions chart", "chart_type": "bar", "categories": ["CO2"], "series": [{"name": "2024", "values": [36.4]}], } ], } ) tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "chart": { "chartType": "line", "title": "GHG Emissions 2024-2025", "titleColor": "#102030", "categories": ["CO2", "CH4", "N2O"], "series": [ {"name": "2024 Gt", "values": [36.4, 2.1, 0.8]}, {"name": "2025 Gt", "values": [36.7, 2.3, 0.84]}, ], "dataLabels": "top", "legend": True, "xAxisTitle": "Gas", "yAxisTitle": "Emissions (Gt)", }, }, ) chart = slide.ui["components"][2]["elements"][0] assert result["ok"] is True assert chart["chart_type"] == "line" assert chart["title"] == "GHG Emissions 2024-2025" assert chart["title_color"] == "#102030" assert chart["series"][0]["values"] == [36.4, 2.1, 0.8] assert chart["data_labels"] == "top" assert chart["legend"] is True assert chart["x_axis_title"] == "Gas" assert chart["y_axis_title"] == "Emissions (Gt)" assert chart["colors"][:3] == ["#123456", "#234567", "#345678"] assert session.commit_count == 1 def test_update_slide_element_accepts_whole_table_payload(): slide = _slide() slide.ui["components"].append( { "id": "table-block", "description": "Table block.", "elements": [ { "type": "table", "decorative": False, "name": "Emissions table", "columns": [ {"runs": [{"text": "Old metric", "font": {"size": 12}}]}, {"runs": [{"text": "Old value", "font": {"size": 12}}]}, ], "rows": [[{"runs": [{"text": "Old"}]}, {"runs": [{"text": "0"}]}]], "min_columns": 2, "max_columns": 3, "min_rows": 1, "max_rows": 4, } ], } ) tools, session = _tools(slide) result = _call( tools, "updateElement", { "index": 0, "elementPath": "components[2].elements[0]", "table": { "headers": ["Metric", "2024 Gt", "2025 Gt"], "rows": [ ["CO2", "36.4", "36.7"], ["CH4", "2.1", "2.3"], ["N2O", "0.8", "0.84"], ], }, }, ) table = slide.ui["components"][2]["elements"][0] assert result["ok"] is True assert [cell["runs"][0]["text"] for cell in table["columns"]] == [ "Metric", "2024 Gt", "2025 Gt", ] assert table["rows"][2][2]["runs"][0]["text"] == "0.84" assert table["columns"][0]["runs"][0]["font"] == {"size": 12} assert session.commit_count == 1 def test_update_slide_component_edits_ui_size(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateComponent", { "index": 0, "componentId": "hero", "size": {"width": 70, "height": 30}, }, ) assert result["ok"] is True assert result["result"]["updated"] is True assert slide.ui["components"][0]["size"] == { "width": 70.0, "height": 30.0, } assert session.commit_count == 1 def test_update_component_resize_scales_standalone_chart_contents(): slide = _slide() slide.ui["components"] = [ { "id": "line-chart", "description": "Line Chart", "position": {"x": 371, "y": 155}, "size": {"width": 538, "height": 410}, "elements": [ { "type": "chart", "position": {"x": 0, "y": 0}, "size": {"width": 538, "height": 410}, "chart_type": "line", "categories": ["Q1", "Q2"], "series": [{"name": "Revenue", "values": [10, 20]}], } ], }, { "id": "thank-you", "description": "Thank You heading", "position": {"x": 400, "y": 60}, "size": {"width": 480, "height": 80}, "elements": [{"type": "text", "runs": [{"text": "Thank You"}]}], }, ] untouched_heading = json.loads(json.dumps(slide.ui["components"][1])) tools, session = _tools(slide) result = _call( tools, "updateComponent", { "index": 0, "componentId": "line-chart", "action": "update", "position": {"x": 0, "y": 0}, "size": {"width": 1280, "height": 720}, }, ) chart_component = slide.ui["components"][0] chart = chart_component["elements"][0] assert result["ok"] is True assert chart_component["position"] == {"x": 0.0, "y": 0.0} assert chart_component["size"] == {"width": 1280.0, "height": 720.0} assert chart["position"] == {"x": 0.0, "y": 0.0} assert chart["size"] == {"width": 1280.0, "height": 720.0} assert slide.ui["components"][1] == untouched_heading assert session.commit_count == 1 def test_update_component_groups_components(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateComponent", { "index": 0, "componentId": "hero", "action": "group", "componentIds": ["hero", "body"], }, ) component = slide.ui["components"][0] assert result["ok"] is True assert result["result"]["updated"] is True assert result["result"]["action"] == "grouped" assert component["id"] == "hero" assert component["position"] == {"x": 0.0, "y": 0.0} assert component["size"] == {"width": 100.0, "height": 110.0} assert len(component["elements"]) == 2 assert [item["id"] for item in slide.ui["components"]] == ["hero"] assert session.commit_count == 1 def test_update_component_ungroups_component(): slide = _slide() slide.ui["components"] = [ { "id": "combo", "description": "Two element group.", "position": {"x": 10, "y": 20}, "size": {"width": 200, "height": 100}, "elements": [ { "type": "text", "name": "Heading", "position": {"x": 0, "y": 0}, "size": {"width": 120, "height": 30}, "runs": [{"text": "Heading"}], }, { "type": "text", "name": "Body", "position": {"x": 0, "y": 40}, "size": {"width": 180, "height": 40}, "runs": [{"text": "Body"}], }, ], } ] tools, session = _tools(slide) result = _call( tools, "updateComponent", {"index": 0, "componentId": "combo", "action": "ungroup"}, ) assert result["ok"] is True assert result["result"]["updated"] is True assert result["result"]["action"] == "ungrouped" assert result["result"]["created_component_ids"] == ["combo_part_1", "combo_part_2"] assert [item["id"] for item in slide.ui["components"]] == [ "combo_part_1", "combo_part_2", ] assert slide.ui["components"][1]["position"] == {"x": 10.0, "y": 60.0} assert session.commit_count == 1 def test_update_component_ungroups_container_child(): slide = _slide() slide.ui["components"] = [ { "id": "card", "description": "Container card.", "position": {"x": 20, "y": 30}, "size": {"width": 240, "height": 120}, "elements": [ { "type": "container", "fill": {"color": "#FFFFFF"}, "padding": {"left": 12, "top": 8, "right": 12, "bottom": 8}, "position": {"x": 0, "y": 0}, "size": {"width": 240, "height": 120}, "child": { "type": "text", "name": "Card title", "runs": [{"text": "Nested title"}], }, } ], } ] tools, session = _tools(slide) result = _call( tools, "updateComponent", {"index": 0, "componentId": "card", "action": "ungroup"}, ) assert result["ok"] is True assert result["result"]["updated"] is True assert result["result"]["created_component_ids"] == [ "card_part_1", ] assert slide.ui["components"][0]["position"] == {"x": 32.0, "y": 38.0} assert slide.ui["components"][0]["size"] == {"width": 216.0, "height": 104.0} assert slide.ui["components"][0]["elements"][0]["type"] == "text" assert slide.ui["components"][0]["elements"][0]["runs"] == [ {"text": "Nested title"} ] assert session.commit_count == 1 def test_update_component_ungroups_one_level_for_flex_group_children(): slide = _slide() slide.ui["components"] = [ { "id": "cards", "description": "Flex with grouped children.", "position": {"x": 10, "y": 20}, "size": {"width": 300, "height": 100}, "elements": [ { "type": "flex", "direction": "row", "gap": 10, "position": {"x": 0, "y": 0}, "size": {"width": 300, "height": 100}, "children": [ { "type": "group", "name": "Left card", "elements": [ { "type": "text", "name": "Left title", "position": {"x": 8, "y": 10}, "size": {"width": 120, "height": 24}, "runs": [{"text": "Left"}], } ], }, { "type": "group", "name": "Right card", "elements": [ { "type": "text", "name": "Right title", "position": {"x": 8, "y": 10}, "size": {"width": 120, "height": 24}, "runs": [{"text": "Right"}], } ], }, ], } ], } ] tools, session = _tools(slide) result = _call( tools, "updateComponent", {"index": 0, "componentId": "cards", "action": "ungroup"}, ) assert result["ok"] is True assert result["result"]["updated"] is True assert result["result"]["created_component_ids"] == [ "cards_part_1", "cards_part_2", ] assert [item["id"] for item in slide.ui["components"]] == [ "cards_part_1", "cards_part_2", ] assert slide.ui["components"][0]["position"] == {"x": 10.0, "y": 20.0} assert slide.ui["components"][0]["size"] == {"width": 145.0, "height": 100.0} assert slide.ui["components"][1]["position"] == {"x": 165.0, "y": 20.0} assert slide.ui["components"][1]["elements"][0]["type"] == "group" assert slide.ui["components"][1]["elements"][0]["elements"] == [ { "type": "text", "name": "Right title", "position": {"x": 8, "y": 10}, "size": {"width": 120, "height": 24}, "runs": [{"text": "Right"}], } ] assert session.commit_count == 1 def test_update_component_ungroups_grid_immediate_children_only(): slide = _slide() feature_item = { "type": "flex", "size": {"width": 310, "height": 100.25}, "direction": "row", "gap": 16, "children": [ { "type": "container", "size": {"width": 48, "height": 48}, "fill": {"color": "#9333EA", "opacity": 1}, "child": { "type": "image", "size": {"width": 24, "height": 24}, "data": "/static/icons/bold/messenger-logo-bold.svg", "name": "feature_icon", }, }, { "type": "flex", "size": {"width": 246, "height": 96.25}, "direction": "column", "gap": 4, "children": [ { "type": "text", "size": {"width": 246, "height": 28}, "runs": [{"text": "Fine PM2.5"}], "name": "feature_heading", }, { "type": "text", "size": {"width": 246, "height": 68.25}, "runs": [{"text": "Tiny particles enter lungs."}], "name": "feature_body", }, ], }, ], "name": "feature_item", } slide.ui["components"] = [ { "id": "feature_grid", "description": "Two-by-two grid of feature items.", "position": {"x": 96, "y": 291.75}, "size": {"width": 674, "height": 256.5}, "elements": [ { "type": "grid", "position": {"x": 0, "y": 0}, "size": {"width": 674, "height": 256.5}, "columns": 2, "rows": 2, "column_gap": 54, "row_gap": 56, "children": [copy.deepcopy(feature_item) for _ in range(4)], "name": "feature_item_grid", } ], } ] tools, session = _tools(slide) result = _call( tools, "updateComponent", {"index": 0, "componentId": "feature_grid", "action": "ungroup"}, ) assert result["ok"] is True assert result["result"]["created_component_ids"] == [ "feature_grid_part_1", "feature_grid_part_2", "feature_grid_part_3", "feature_grid_part_4", ] assert [component["position"] for component in slide.ui["components"]] == [ {"x": 96.0, "y": 291.75}, {"x": 460.0, "y": 291.75}, {"x": 96.0, "y": 448.0}, {"x": 460.0, "y": 448.0}, ] assert [component["size"] for component in slide.ui["components"]] == [ {"width": 310.0, "height": 100.25}, {"width": 310.0, "height": 100.25}, {"width": 310.0, "height": 100.25}, {"width": 310.0, "height": 100.25}, ] assert slide.ui["components"][0]["elements"][0]["type"] == "flex" assert slide.ui["components"][0]["elements"][0]["children"][0]["type"] == "container" assert ( slide.ui["components"][0]["elements"][0]["children"][1]["children"][0]["runs"] == [{"text": "Fine PM2.5"}] ) assert session.commit_count == 1 def test_update_component_duplicates_component(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateComponent", {"index": 0, "componentId": "hero", "action": "duplicate"}, ) assert result["ok"] is True assert result["result"]["updated"] is True assert result["result"]["action"] == "duplicated" assert result["result"]["component_id"] == "hero_copy" assert [item["id"] for item in slide.ui["components"]] == [ "hero", "hero_copy", "body", ] assert slide.ui["components"][1]["position"] == {"x": 16.0, "y": 16.0} assert session.commit_count == 1 def test_update_component_reorders_layer(): slide = _slide() tools, session = _tools(slide) result = _call( tools, "updateComponent", {"index": 0, "componentId": "hero", "action": "bringToFront"}, ) assert result["ok"] is True assert result["result"]["updated"] is True assert result["result"]["action"] == "bring-to-front" assert [item["id"] for item in slide.ui["components"]] == ["body", "hero"] assert result["result"]["component_index"] == 1 assert session.commit_count == 1 def test_delete_slide_component_removes_block_from_ui(): slide = _slide() tools, _ = _tools(slide) result = _call(tools, "deleteComponent", {"index": 0, "componentId": "body"}) assert result["ok"] is True assert result["result"]["deleted"] is True assert [c["id"] for c in slide.ui["components"]] == ["hero"] def test_delete_slide_element_removes_indexed_element(): slide = _slide() tools, _ = _tools(slide) result = _call( tools, "deleteElement", {"index": 0, "elementPath": "components[1].elements[0]"}, ) assert result["ok"] is True assert result["result"]["deleted"] is True assert slide.ui["components"][1]["elements"] == [] def test_add_slide_component_appends_block(): slide = _slide() tools, _ = _tools(slide) component = { "id": "note", "description": "A short callout note component.", "position": {"x": 0, "y": 80}, "size": {"width": 100, "height": 20}, "elements": [ { "type": "text", "decorative": False, "name": "Note", "runs": [{"text": "Added note", "font": {"size": 14}}], } ], } result = _call( tools, "addComponent", {"index": 0, "component": json.dumps(component)}, ) assert result["ok"] is True assert result["result"]["added"] is True assert [c["id"] for c in slide.ui["components"]] == ["hero", "body", "note"] def test_add_slide_component_clamps_to_visible_stage(): slide = _slide() tools, _ = _tools(slide) component = { "id": "offscreen", "description": "Bad geometry component.", "position": {"x": 1400, "y": -40}, "size": {"width": 2000, "height": 900}, "elements": [ { "type": "text", "name": "Offscreen", "runs": [{"text": "Visible now"}], } ], } result = _call( tools, "addComponent", {"index": 0, "component": json.dumps(component)}, ) added = slide.ui["components"][-1] assert result["ok"] is True assert added["position"] == {"x": 0.0, "y": 0.0} assert added["size"] == {"width": 1280.0, "height": 720.0} def test_add_slide_component_expands_tiny_chart_block(): slide = _slide() tools, _ = _tools(slide) component = { "id": "tiny-chart", "description": "A chart block with bad assistant geometry.", "position": {"x": 0.25, "y": 0.2}, "size": {"width": 0.6, "height": 0.5}, "elements": [ { "type": "chart", "decorative": False, "name": "Model chart", "position": {"x": 0, "y": 0}, "size": {"width": 0.6, "height": 0.5}, "chart_type": "bar", "categories": ["GPT OSS 20B", "GPT OSS 120B"], "series": [{"name": "Score", "values": [20, 120]}], } ], } result = _call( tools, "addComponent", {"index": 0, "component": json.dumps(component)}, ) chart_component = slide.ui["components"][-1] chart = chart_component["elements"][0] assert result["ok"] is True assert chart_component["position"] == {"x": 128.0, "y": 108.0} assert chart_component["size"] == {"width": 1024.0, "height": 460.0} assert chart["position"] == {"x": 0, "y": 0} assert chart["size"] == {"width": 1024.0, "height": 460.0} assert chart["colors"][:3] == ["#123456", "#234567", "#345678"] assert chart["color"] == "#123456" assert chart["axis_color"] == "#111827" assert chart["grid_color"] == "#E5E7EB" def test_add_slide_element_converts_data_only_chart_on_first_insert(): slide = _slide() tools, session = _tools(slide) chart = { "type": "chart", "decorative": False, "name": "Goals chart", "title": "Goals", "position": {"x": 128, "y": 108}, "size": {"width": 1024, "height": 460}, "chart_type": "bar", "data": [ {"label": "Messi", "value": "600 goals"}, {"label": "Ronaldo", "value": 800}, ], } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(chart)}, ) added_chart = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert added_chart["categories"] == ["Messi", "Ronaldo"] assert added_chart["series"] == [{"name": "Goals", "values": [600, 800]}] assert [ {key: item[key] for key in ("label", "value")} for item in added_chart["data"] ] == [ {"label": "Messi", "value": 600}, {"label": "Ronaldo", "value": 800}, ] assert session.commit_count == 1 def test_add_slide_element_accepts_object_payload_for_json_string_field(): slide = _slide() tools, session = _tools(slide) chart = { "type": "chart", "decorative": False, "name": "Goals chart", "title": "Goals", "position": {"x": 128, "y": 108}, "size": {"width": 1024, "height": 460}, "chart_type": "bar", "data": [ {"label": "Messi", "value": 600}, {"label": "Ronaldo", "value": 800}, ], } result = _run( tools.execute_tool_call( AssistantToolCall( id="call_1", name="addElement", arguments=json.dumps({"index": 0, "element": chart}), ) ) ) added_chart = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert result["repair"]["applied"] is True assert "Converted element from object" in result["repair"]["notes"][0] assert added_chart["categories"] == ["Messi", "Ronaldo"] assert added_chart["series"] == [{"name": "Goals", "values": [600, 800]}] assert session.commit_count == 1 def test_add_slide_element_repairs_fenced_jsonish_element_payload(): slide = _slide() tools, session = _tools(slide) chart = """```json {type:'chart', decorative:false, name:'Goals chart', title:'Goals', position:{x:128, y:108}, size:{width:1024, height:460}, chart_type:'bar', data:[{label:'Messi', value:600}, {label:'Ronaldo', value:800}]} ```""" result = _call( tools, "addElement", {"index": 0, "element": chart}, ) added_chart = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert result["repair"]["applied"] is True assert any( "Repaired JSON string field element" in note for note in result["repair"]["notes"] ) assert added_chart["categories"] == ["Messi", "Ronaldo"] assert added_chart["series"] == [{"name": "Goals", "values": [600, 800]}] assert session.commit_count == 1 @patch.object(PresentationChatMemoryLayer, "generate_image", new_callable=AsyncMock) def test_add_slide_element_uses_generated_asset_for_blank_image_insert(mock_generate_image): mock_generate_image.return_value = "/app_data/images/nepal-flag.png" slide = _slide() tools, session = _tools(slide) tools.set_turn_context("User message: Can you add an image with a Nepali flag") generated = _call( tools, "generateAssets", {"assets": [{"kind": "image", "prompt": "Nepal flag"}]}, ) image = { "type": "image", "decorative": False, "name": "Nepal flag", "position": {"x": 128, "y": 120}, "size": {"width": 420, "height": 280}, } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(image)}, ) added_image = slide.ui["components"][-1]["elements"][0] assert generated["ok"] is True assert result["ok"] is True assert result["repair"]["applied"] is True assert added_image["data"] == "/app_data/images/nepal-flag.png" assert added_image["prompt"] == "Nepal flag" assert added_image["is_icon"] is False assert session.commit_count == 1 def test_add_slide_element_normalizes_image_url_alias_on_insert(): slide = _slide() tools, session = _tools(slide) image = { "type": "image", "decorative": False, "name": "Nepal flag", "image_url": "/app_data/images/nepal-flag.png", "position": {"x": 128, "y": 120}, "size": {"width": 420, "height": 280}, } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(image)}, ) added_image = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert added_image["data"] == "/app_data/images/nepal-flag.png" assert added_image["is_icon"] is False assert session.commit_count == 1 @patch.object(PresentationChatMemoryLayer, "generate_image", new_callable=AsyncMock) def test_add_slide_element_replaces_prompt_like_image_data_with_generated_asset( mock_generate_image, ): mock_generate_image.return_value = "/app_data/images/nepal-flag.png" slide = _slide() tools, session = _tools(slide) tools.set_turn_context("User message: Can you add an image with a Nepali flag") _call( tools, "generateAssets", {"assets": [{"kind": "image", "prompt": "Nepal flag"}]}, ) image = { "type": "image", "decorative": False, "name": "Nepal flag", "data": "Nepali flag image", "position": {"x": 128, "y": 120}, "size": {"width": 420, "height": 280}, } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(image)}, ) added_image = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert result["repair"]["applied"] is True assert added_image["data"] == "/app_data/images/nepal-flag.png" assert session.commit_count == 1 def test_add_slide_element_rejects_blank_image_insert(): slide = _slide() tools, session = _tools(slide) image = { "type": "image", "decorative": False, "name": "Blank image", "position": {"x": 128, "y": 120}, "size": {"width": 420, "height": 280}, } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(image)}, ) assert result["ok"] is False assert "Image elements must include" in result["error"] assert result["recovery"]["retryable"] is True assert "data" in result["recovery"]["expected"]["image"] assert len(slide.ui["components"]) == 2 assert session.commit_count == 0 def test_add_slide_element_rejects_blank_chart_insert(): slide = _slide() tools, session = _tools(slide) chart = { "type": "chart", "decorative": False, "name": "Blank chart", "position": {"x": 128, "y": 108}, "size": {"width": 1024, "height": 460}, "chart_type": "bar", } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(chart)}, ) assert result["ok"] is False assert "numeric data" in result["error"] assert result["recovery"]["retryable"] is True assert "categories" in result["recovery"]["expected"]["chart"] assert len(slide.ui["components"]) == 2 assert session.commit_count == 0 def test_add_slide_element_repairs_blank_chart_from_user_message_data(): slide = _slide() tools, session = _tools(slide) tools.set_turn_context( "UI context: the currently selected slide is slide 16 " "(zero-based index 15).\n" "User message: Add a chart with no.of goals the messi and ronaldo " "have in their lifetime.\n" "messi 600 goals and ronaldo 800 goals" ) chart = { "type": "chart", "decorative": False, "name": "Blank chart", "position": {"x": 128, "y": 108}, "size": {"width": 1024, "height": 460}, "chart_type": "bar", } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(chart)}, ) added_chart = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert added_chart["title"] == "Goals" assert added_chart["categories"] == ["Messi", "Ronaldo"] assert added_chart["series"] == [{"name": "Goals", "values": [600, 800]}] assert session.commit_count == 1 def test_add_slide_element_repairs_blank_table_from_user_message_data(): slide = _slide() tools, session = _tools(slide) tools.set_turn_context( "User message: Add a table with first row with Name, Age, Department. " "Data: Ghanshyam, 30, QA; Sudeep, 33, AI" ) table = { "type": "table", "decorative": False, "name": "People table", "position": {"x": 128, "y": 120}, "size": {"width": 1024, "height": 410}, } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(table)}, ) added_table = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert [cell["runs"][0]["text"] for cell in added_table["columns"]] == [ "Name", "Age", "Department", ] assert [[cell["runs"][0]["text"] for cell in row] for row in added_table["rows"]] == [ ["Ghanshyam", "30", "QA"], ["Sudeep", "33", "AI"], ] assert session.commit_count == 1 def test_add_slide_element_rejects_blank_table_insert(): slide = _slide() tools, session = _tools(slide) table = { "type": "table", "decorative": False, "name": "Blank table", "position": {"x": 128, "y": 120}, "size": {"width": 1024, "height": 410}, } result = _call( tools, "addElement", {"index": 0, "element": json.dumps(table)}, ) assert result["ok"] is False assert "Table elements must include" in result["error"] assert result["recovery"]["retryable"] is True assert "columns" in result["recovery"]["expected"]["table"] assert len(slide.ui["components"]) == 2 assert session.commit_count == 0 def test_add_slide_component_expands_tiny_table_block(): slide = _slide() tools, _ = _tools(slide) cell = {"runs": [{"text": "Metric", "font": {"size": 12}}]} component = { "id": "tiny-table", "description": "A table block with bad assistant geometry.", "position": {"x": 0.25, "y": 0.2}, "size": {"width": 0.6, "height": 0.5}, "elements": [ { "type": "table", "decorative": False, "name": "Metrics table", "position": {"x": 0, "y": 0}, "size": {"width": 0.6, "height": 0.5}, "columns": [cell, {"runs": [{"text": "Value"}]}], "rows": [[{"runs": [{"text": "Temperature rise"}]}, {"runs": [{"text": "0.2 C"}]}]], "min_columns": 2, "max_columns": 2, "min_rows": 1, "max_rows": 3, } ], } result = _call( tools, "addComponent", {"index": 0, "component": json.dumps(component)}, ) table_component = slide.ui["components"][-1] table = table_component["elements"][0] assert result["ok"] is True assert table_component["position"] == {"x": 128.0, "y": 120.0} assert table_component["size"] == {"width": 1024.0, "height": 410.0} assert table["position"] == {"x": 0, "y": 0} assert table["size"] == {"width": 1024.0, "height": 410.0} def test_add_slide_component_normalizes_table_cell_content_aliases(): slide = _slide() tools, _ = _tools(slide) component = { "id": "people-table", "description": "A table with people and departments.", "position": {"x": 128, "y": 120}, "size": {"width": 1024, "height": 410}, "elements": [ { "type": "table", "decorative": False, "name": "People table", "position": {"x": 0, "y": 0}, "size": {"width": 1024, "height": 410}, "headers": [ {"content": "Name"}, {"value": "Age"}, {"text": {"text": "Department"}}, ], "rows": [ [ {"content": "Ghanshyam"}, {"value": 30}, {"label": "QA"}, ], [ {"text": "Sudeep"}, {"content": 33}, {"data": "AI"}, ], ], "min_columns": 1, "max_columns": 6, "min_rows": 1, "max_rows": 10, } ], } result = _call( tools, "addComponent", {"index": 0, "component": json.dumps(component)}, ) table = slide.ui["components"][-1]["elements"][0] assert result["ok"] is True assert [cell["runs"][0]["text"] for cell in table["columns"]] == [ "Name", "Age", "Department", ] assert [[cell["runs"][0]["text"] for cell in row] for row in table["rows"]] == [ ["Ghanshyam", "30", "QA"], ["Sudeep", "33", "AI"], ] elements = _call( tools, "getSlideAtIndex", {"index": 0, "includeFullContent": True}, )["result"]["slide"]["ui_summary"]["elements"] added_table = next(element for element in elements if element["name"] == "People table") assert added_table["content"] == { "columns": ["Name", "Age", "Department"], "rows": [["Ghanshyam", "30", "QA"], ["Sudeep", "33", "AI"]], } def test_ui_tool_reports_non_ui_slide(): slide = _slide() slide.ui = None tools, _ = _tools(slide) result = _call(tools, "getSlideAtIndex", {"index": 0, "includeFullContent": True}) assert result["ok"] is True assert "ui_summary" not in result["result"]["slide"] def _template_v2_presentation(presentation_id: uuid.UUID) -> PresentationModel: return PresentationModel( id=presentation_id, version=PresentationVersion.V1_STANDARD, content="deck", n_slides=0, language="English", layout={ "layouts": [ { "id": "thanks", "description": "Thank you slide layout for chat-created slides.", "components": [ { "id": "hero", "description": "Hero title component for chat save tests.", "position": {"x": 0, "y": 0}, "size": {"width": 100, "height": 40}, "elements": [ { "type": "text", "decorative": False, "name": "Title", "max_length": 100, "min_length": 1, "runs": [ { "text": "Old title", "font": {"size": 20, "family": "Inter"}, } ], } ], } ], } ] }, ) class _FakeSaveSlideSession: def __init__(self, presentation: PresentationModel): self.presentation = presentation self.slides: list[SlideModel] = [] self.added: list = [] self.added_all: list = [] self.commit_count = 0 async def get(self, model, key): if model is PresentationModel and key == self.presentation.id: return self.presentation return None async def scalars(self, *_args, **_kwargs): return list(self.slides) async def scalar(self, *_args, **_kwargs): return self.slides[0] if self.slides else None def add(self, obj): self.added.append(obj) if isinstance(obj, SlideModel) and obj not in self.slides: self.slides.append(obj) def add_all(self, values): self.added_all.extend(values) async def commit(self): self.commit_count += 1 async def refresh(self, _obj): return None async def delete(self, obj): if isinstance(obj, SlideModel) and obj in self.slides: self.slides.remove(obj) def test_save_slide_for_template_v2_payload_persists_renderable_ui(): presentation_id = uuid.uuid4() presentation = _template_v2_presentation(presentation_id) session = _FakeSaveSlideSession(presentation) memory = PresentationChatMemoryLayer(session, presentation_id) with patch.object( memory, "_get_presentation_icon_weight", new=AsyncMock(return_value="regular"), ), patch( "services.chat.memory_layer.get_images_directory", return_value="/tmp", ), patch( "services.chat.memory_layer.MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit", new=AsyncMock(), ): result = _run( memory.save_slide( content={"hero": {"Title": "Thank You"}}, layout_id="thanks", index=0, replace_old_slide_at_index=False, ) ) assert result["saved"] is True assert len(session.slides) == 1 saved_slide = session.slides[0] assert saved_slide.layout_group == "template-v2" assert saved_slide.layout == "thanks" assert saved_slide.ui["id"] == "thanks" title_element = saved_slide.ui["components"][0]["elements"][0] assert title_element["runs"][0]["text"] == "Thank You" assert title_element["text"] == "Thank You" def test_chat_add_outline_refuses_more_than_max_slides(): presentation_id = uuid.uuid4() presentation = _template_v2_presentation(presentation_id) presentation.outlines = { "slides": [ {"content": f"## Slide {index}"} for index in range(MAX_NUMBER_OF_SLIDES) ] } session = _FakeSaveSlideSession(presentation) memory = PresentationChatMemoryLayer(session, presentation_id) result = _run(memory.add_outline(content="## Extra", index=None)) assert result["saved"] is False assert result["slide_count"] == MAX_NUMBER_OF_SLIDES assert result["max_slide_count"] == MAX_NUMBER_OF_SLIDES assert session.commit_count == 0 def test_chat_update_outline_trims_content_to_word_limit(): presentation_id = uuid.uuid4() presentation = _template_v2_presentation(presentation_id) presentation.outlines = {"slides": [{"content": "## Existing"}]} session = _FakeSaveSlideSession(presentation) memory = PresentationChatMemoryLayer(session, presentation_id) content = " ".join( f"word{i}" for i in range(MAX_OUTLINE_CONTENT_WORDS + 4) ) with patch( "services.chat.memory_layer.MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines", new=AsyncMock(), ): result = _run(memory.update_outline(index=0, content=content)) assert result["saved"] is True saved_content = presentation.outlines["slides"][0]["content"] assert count_outline_words(saved_content) == MAX_OUTLINE_CONTENT_WORDS assert f"word{MAX_OUTLINE_CONTENT_WORDS - 1}" in saved_content assert f"word{MAX_OUTLINE_CONTENT_WORDS}" not in saved_content def test_chat_add_blank_slide_refuses_more_than_max_slides(): presentation_id = uuid.uuid4() presentation = _template_v2_presentation(presentation_id) session = _FakeSaveSlideSession(presentation) session.slides = [ SlideModel( presentation=presentation_id, layout_group="template-v2", layout="thanks", index=index, content={}, properties=None, ui={}, ) for index in range(MAX_NUMBER_OF_SLIDES) ] memory = PresentationChatMemoryLayer(session, presentation_id) result = _run(memory.add_blank_slide(index=None)) assert result["added"] is False assert result["slide_count"] == MAX_NUMBER_OF_SLIDES assert result["max_slide_count"] == MAX_NUMBER_OF_SLIDES assert session.commit_count == 0 def test_chat_delete_final_slide_replaces_it_with_blank_fallback(): presentation_id = uuid.uuid4() presentation = _template_v2_presentation(presentation_id) presentation.n_slides = 1 source_slide = SlideModel( id=uuid.uuid4(), presentation=presentation_id, layout_group="template-v2", layout="thanks", index=0, content={"hero": {"Title": "Thanks"}}, properties=None, ui={"id": "thanks", "components": []}, ) session = _FakeSaveSlideSession(presentation) session.slides = [source_slide] memory = PresentationChatMemoryLayer(session, presentation_id) result = _run(memory.delete_slide(index=0)) assert result["deleted"] is True assert result["blank_fallback"] is True assert result["deleted_slide_id"] == str(source_slide.id) assert len(session.slides) == 1 fallback_slide = session.slides[0] assert fallback_slide.id != source_slide.id assert fallback_slide.index == 0 assert fallback_slide.layout_group == "template-v2" assert fallback_slide.layout == "__blank_slide__" assert fallback_slide.content == {} assert fallback_slide.speaker_note == "" assert fallback_slide.ui["id"] == "__blank_slide__" assert fallback_slide.ui["components"] == [] assert presentation.n_slides == 1 assert session.commit_count == 1 def test_chat_save_slide_refuses_new_slide_at_max_slides(): presentation_id = uuid.uuid4() presentation = _template_v2_presentation(presentation_id) session = _FakeSaveSlideSession(presentation) session.slides = [ SlideModel( presentation=presentation_id, layout_group="template-v2", layout="thanks", index=index, content={}, properties=None, ui={}, ) for index in range(MAX_NUMBER_OF_SLIDES) ] memory = PresentationChatMemoryLayer(session, presentation_id) with patch.object( memory, "_get_presentation_icon_weight", new=AsyncMock(return_value="regular"), ), patch( "services.chat.memory_layer.get_images_directory", return_value="/tmp", ): result = _run( memory.save_slide( content={"hero": {"Title": "Extra"}}, layout_id="thanks", index=MAX_NUMBER_OF_SLIDES, replace_old_slide_at_index=False, ) ) assert result["saved"] is False assert result["slide_count"] == MAX_NUMBER_OF_SLIDES assert result["max_slide_count"] == MAX_NUMBER_OF_SLIDES assert session.commit_count == 0