2182 lines
67 KiB
Python
2182 lines
67 KiB
Python
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
|