"""Tests for the penpot_tree module."""
import re
from unittest.mock import MagicMock, patch
import pytest
from anytree import Node, RenderTree
from penpot_mcp.tools.penpot_tree import (
build_tree,
convert_node_to_dict,
export_tree_to_dot,
find_object_in_tree,
find_page_containing_object,
get_object_subtree,
get_object_subtree_with_fields,
print_tree,
)
@pytest.fixture
def sample_penpot_data():
"""Create sample Penpot file data for testing."""
return {
'components': {
'comp1': {'name': 'Button', 'annotation': 'Primary button'},
'comp2': {'name': 'Card', 'annotation': None}
},
'pagesIndex': {
'page1': {
'name': 'Home Page',
'objects': {
'00000000-0000-0000-0000-000000000000': {
'type': 'frame',
'name': 'Root Frame',
},
'obj1': {
'type': 'frame',
'name': 'Header',
'parentId': '00000000-0000-0000-0000-000000000000'
},
'obj2': {
'type': 'text',
'name': 'Title',
'parentId': 'obj1'
},
'obj3': {
'type': 'frame',
'name': 'Button Instance',
'parentId': 'obj1',
'componentId': 'comp1'
}
}
},
'page2': {
'name': 'About Page',
'objects': {
'00000000-0000-0000-0000-000000000000': {
'type': 'frame',
'name': 'Root Frame',
},
'obj4': {
'type': 'frame',
'name': 'Content',
'parentId': '00000000-0000-0000-0000-000000000000'
},
'obj5': {
'type': 'image',
'name': 'Logo',
'parentId': 'obj4'
}
}
}
}
}
@pytest.fixture
def sample_tree(sample_penpot_data):
"""Create a sample tree from the sample data."""
return build_tree(sample_penpot_data)
def test_build_tree(sample_penpot_data, sample_tree):
"""Test building a tree from Penpot file data."""
# Check that the root is created
assert sample_tree.name.startswith("SYNTHETIC-ROOT")
# Check components section
components_node = None
for child in sample_tree.children:
if "components (section)" in child.name:
components_node = child
break
assert components_node is not None
assert len(components_node.children) == 2
# Check pages are created
page_nodes = [child for child in sample_tree.children if "(page)" in child.name]
assert len(page_nodes) == 2
# Check objects within pages
for page_node in page_nodes:
if "Home Page" in page_node.name:
# Check that objects are created under the page
assert len(page_node.descendants) == 4 # Root frame + 3 objects
# Check parent-child relationships
for node in RenderTree(page_node):
if hasattr(node[2], 'obj_id') and node[2].obj_id == 'obj2':
assert node[2].parent.obj_id == 'obj1'
elif hasattr(node[2], 'obj_id') and node[2].obj_id == 'obj3':
assert node[2].parent.obj_id == 'obj1'
assert hasattr(node[2], 'componentRef')
assert node[2].componentRef == 'comp1'
assert hasattr(node[2], 'componentAnnotation')
assert node[2].componentAnnotation == 'Primary button'
def test_print_tree(sample_tree, capsys):
"""Test printing the tree to console."""
print_tree(sample_tree)
captured = capsys.readouterr()
# Check that all pages and components are in the output
assert "Home Page" in captured.out
assert "About Page" in captured.out
assert "comp1 (component) - Button" in captured.out
assert "comp2 (component) - Card" in captured.out
# Check that object types and names are displayed
assert "(frame) - Header" in captured.out
assert "(text) - Title" in captured.out
# Check that component references are shown
assert "refs component: comp1" in captured.out
assert "Note: Primary button" in captured.out
def test_print_tree_with_filter(sample_tree, capsys):
"""Test printing the tree with a filter applied."""
print_tree(sample_tree, filter_pattern="title")
captured = capsys.readouterr()
# Check that only the matching node and its ancestors are shown
assert "Title" in captured.out
assert "Header" in captured.out
assert "Home Page" in captured.out
assert "MATCH" in captured.out
# Check that non-matching nodes are not included
assert "Logo" not in captured.out
assert "About Page" not in captured.out
@patch('anytree.exporter.DotExporter.to_picture')
def test_export_tree_to_dot(mock_to_picture, sample_tree):
"""Test exporting the tree to a DOT file."""
result = export_tree_to_dot(sample_tree, "test_output.png")
# Check that the exporter was called
assert mock_to_picture.called
assert result is True
@patch('anytree.exporter.DotExporter.to_picture', side_effect=Exception("Test exception"))
def test_export_tree_to_dot_exception(mock_to_picture, sample_tree, capsys):
"""Test handling exceptions when exporting the tree."""
result = export_tree_to_dot(sample_tree, "test_output.png")
# Check that the function returns False on error
assert result is False
# Check that an error message is displayed
captured = capsys.readouterr()
assert "Warning: Could not export" in captured.out
assert "Make sure Graphviz is installed" in captured.out
def test_find_page_containing_object(sample_penpot_data):
"""Test finding which page contains a specific object."""
# Test finding an object that exists
page_id = find_page_containing_object(sample_penpot_data, 'obj2')
assert page_id == 'page1'
# Test finding an object in another page
page_id = find_page_containing_object(sample_penpot_data, 'obj5')
assert page_id == 'page2'
# Test finding an object that doesn't exist
page_id = find_page_containing_object(sample_penpot_data, 'nonexistent')
assert page_id is None
def test_find_object_in_tree(sample_tree):
"""Test finding an object in the tree by its ID."""
# Test finding an object that exists
obj_dict = find_object_in_tree(sample_tree, 'obj3')
assert obj_dict is not None
assert obj_dict['id'] == 'obj3'
assert obj_dict['type'] == 'frame'
assert obj_dict['name'] == 'Button Instance'
assert 'componentRef' in obj_dict
assert obj_dict['componentRef'] == 'comp1'
# Test finding an object that doesn't exist
obj_dict = find_object_in_tree(sample_tree, 'nonexistent')
assert obj_dict is None
def test_convert_node_to_dict():
"""Test converting a Node to a dictionary."""
# Create a test node with children and attributes
root = Node("root")
root.obj_id = "root_id"
root.obj_type = "frame"
root.obj_name = "Root Frame"
child1 = Node("child1", parent=root)
child1.obj_id = "child1_id"
child1.obj_type = "text"
child1.obj_name = "Child 1"
child2 = Node("child2", parent=root)
child2.obj_id = "child2_id"
child2.obj_type = "frame"
child2.obj_name = "Child 2"
child2.componentRef = "comp1"
child2.componentAnnotation = "Test component"
# Convert to dictionary
result = convert_node_to_dict(root)
# Check the result
assert result['id'] == 'root_id'
assert result['type'] == 'frame'
assert result['name'] == 'Root Frame'
assert len(result['children']) == 2
# Check children
child_ids = [child['id'] for child in result['children']]
assert 'child1_id' in child_ids
assert 'child2_id' in child_ids
# Check component reference
for child in result['children']:
if child['id'] == 'child2_id':
assert 'componentRef' in child
assert child['componentRef'] == 'comp1'
assert 'componentAnnotation' in child
assert child['componentAnnotation'] == 'Test component'
def test_get_object_subtree(sample_penpot_data):
"""Test getting a simplified tree for an object."""
file_data = {'data': sample_penpot_data}
# Test getting a subtree for an existing object
result = get_object_subtree(file_data, 'obj1')
assert 'error' not in result
assert 'tree' in result
assert result['tree']['id'] == 'obj1'
assert result['tree']['name'] == 'Header'
assert result['page_id'] == 'page1'
# Test getting a subtree for a non-existent object
result = get_object_subtree(file_data, 'nonexistent')
assert 'error' in result
assert 'not found' in result['error']
def test_circular_reference_handling(sample_penpot_data):
"""Test handling of circular references in the tree structure."""
# Create a circular reference
sample_penpot_data['pagesIndex']['page1']['objects']['obj6'] = {
'type': 'frame',
'name': 'Circular Parent',
'parentId': 'obj7'
}
sample_penpot_data['pagesIndex']['page1']['objects']['obj7'] = {
'type': 'frame',
'name': 'Circular Child',
'parentId': 'obj6'
}
# Build tree with circular reference
tree = build_tree(sample_penpot_data)
# The tree should be built without errors
# Check that the circular reference objects are attached to the page
page_node = None
for child in tree.children:
if "(page)" in child.name and "Home Page" in child.name:
page_node = child
break
assert page_node is not None
# Find the circular reference objects
circular_nodes = []
for node in RenderTree(page_node):
if hasattr(node[2], 'obj_id') and node[2].obj_id in ['obj6', 'obj7']:
circular_nodes.append(node[2])
# Check that the circular reference was resolved by attaching to parent
assert len(circular_nodes) == 2
def test_get_object_subtree_with_fields(sample_penpot_data):
"""Test getting a filtered subtree for an object with specific fields."""
file_data = {'data': sample_penpot_data}
# Test with no field filtering (include all fields)
result = get_object_subtree_with_fields(file_data, 'obj1')
assert 'error' not in result
assert 'tree' in result
assert result['tree']['id'] == 'obj1'
assert result['tree']['name'] == 'Header'
assert result['tree']['type'] == 'frame'
assert 'parentId' in result['tree']
assert len(result['tree']['children']) == 2
# Test with field filtering
result = get_object_subtree_with_fields(file_data, 'obj1', include_fields=['name', 'type'])
assert 'error' not in result
assert 'tree' in result
assert result['tree']['id'] == 'obj1' # id is always included
assert result['tree']['name'] == 'Header'
assert result['tree']['type'] == 'frame'
assert 'parentId' not in result['tree'] # should be filtered out
assert len(result['tree']['children']) == 2
# Test with depth limiting (depth=0, only the object itself)
result = get_object_subtree_with_fields(file_data, 'obj1', depth=0)
assert 'error' not in result
assert 'tree' in result
assert result['tree']['id'] == 'obj1'
assert 'children' not in result['tree'] # No children at depth 0
# Test for an object that doesn't exist
result = get_object_subtree_with_fields(file_data, 'nonexistent')
assert 'error' in result
assert 'not found' in result['error']
def test_get_object_subtree_with_fields_deep_hierarchy():
"""Test getting a filtered subtree for an object with multiple levels of nesting."""
# Create a more complex nested structure for testing depth parameter
file_data = {
'data': {
'components': {
'comp1': {
'id': 'comp1',
'name': 'Button',
'path': '/Components/Button',
'modifiedAt': '2023-01-01T12:00:00Z',
'mainInstanceId': 'main-button-instance',
'mainInstancePage': 'page1',
'annotation': 'Primary button'
},
'comp2': {
'id': 'comp2',
'name': 'Card',
'path': '/Components/Card',
'modifiedAt': '2023-01-02T12:00:00Z',
'mainInstanceId': 'main-card-instance',
'mainInstancePage': 'page1',
'annotation': 'Content card'
}
},
'colors': {
'color1': {
'path': '/Colors/Primary',
'color': '#3366FF',
'name': 'Primary Blue',
'modifiedAt': '2023-01-01T10:00:00Z',
'opacity': 1,
'id': 'color1'
},
'color2': {
'path': '/Colors/Secondary',
'color': '#FF6633',
'name': 'Secondary Orange',
'modifiedAt': '2023-01-01T10:30:00Z',
'opacity': 1,
'id': 'color2'
}
},
'typographies': {
'typo1': {
'lineHeight': '1.5',
'path': '/Typography/Heading',
'fontStyle': 'normal',
'textTransform': 'none',
'fontId': 'font1',
'fontSize': '24px',
'fontWeight': '600',
'name': 'Heading',
'modifiedAt': '2023-01-01T11:00:00Z',
'fontVariantId': 'var1',
'id': 'typo1',
'letterSpacing': '0',
'fontFamily': 'Inter'
}
},
'pagesIndex': {
'page1': {
'id': 'page1',
'name': 'Complex Page',
'options': {
'background': '#FFFFFF',
'grids': []
},
'objects': {
# Root frame (level 0)
'00000000-0000-0000-0000-000000000000': {
'id': '00000000-0000-0000-0000-000000000000',
'type': 'frame',
'name': 'Root Frame',
'width': 1920,
'height': 1080,
'x': 0,
'y': 0,
'rotation': 0,
'selrect': {
'x': 0,
'y': 0,
'width': 1920,
'height': 1080,
'x1': 0,
'y1': 0,
'x2': 1920,
'y2': 1080
},
'fills': [
{
'fillColor': '#FFFFFF',
'fillOpacity': 1
}
],
'layout': 'flex',
'layoutFlexDir': 'column',
'layoutAlignItems': 'center',
'layoutJustifyContent': 'start'
},
# Main container (level 1)
'main-container': {
'id': 'main-container',
'type': 'frame',
'name': 'Main Container',
'parentId': '00000000-0000-0000-0000-000000000000',
'width': 1200,
'height': 800,
'x': 360,
'y': 140,
'rotation': 0,
'selrect': {
'x': 360,
'y': 140,
'width': 1200,
'height': 800,
'x1': 360,
'y1': 140,
'x2': 1560,
'y2': 940
},
'fills': [
{
'fillColor': '#F5F5F5',
'fillOpacity': 1
}
],
'strokes': [
{
'strokeStyle': 'solid',
'strokeAlignment': 'center',
'strokeWidth': 1,
'strokeColor': '#E0E0E0',
'strokeOpacity': 1
}
],
'layout': 'flex',
'layoutFlexDir': 'column',
'layoutAlignItems': 'stretch',
'layoutJustifyContent': 'start',
'layoutGap': {
'row-gap': '0px',
'column-gap': '0px'
},
'layoutPadding': {
'padding-top': '0px',
'padding-right': '0px',
'padding-bottom': '0px',
'padding-left': '0px'
},
'constraintsH': 'center',
'constraintsV': 'center'
},
# Header section (level 2)
'header-section': {
'id': 'header-section',
'type': 'frame',
'name': 'Header Section',
'parentId': 'main-container',
'width': 1200,
'height': 100,
'x': 0,
'y': 0,
'rotation': 0,
'fills': [
{
'fillColor': '#FFFFFF',
'fillOpacity': 1
}
],
'strokes': [
{
'strokeStyle': 'solid',
'strokeAlignment': 'bottom',
'strokeWidth': 1,
'strokeColor': '#EEEEEE',
'strokeOpacity': 1
}
],
'layout': 'flex',
'layoutFlexDir': 'row',
'layoutAlignItems': 'center',
'layoutJustifyContent': 'space-between',
'layoutPadding': {
'padding-top': '20px',
'padding-right': '30px',
'padding-bottom': '20px',
'padding-left': '30px'
},
'constraintsH': 'stretch',
'constraintsV': 'top'
},
# Logo in header (level 3)
'logo': {
'id': 'logo',
'type': 'frame',
'name': 'Logo',
'parentId': 'header-section',
'width': 60,
'height': 60,
'x': 30,
'y': 20,
'rotation': 0,
'fills': [
{
'fillColor': '#3366FF',
'fillOpacity': 1
}
],
'r1': 8,
'r2': 8,
'r3': 8,
'r4': 8,
'constraintsH': 'left',
'constraintsV': 'center'
},
# Navigation menu (level 3)
'nav-menu': {
'id': 'nav-menu',
'type': 'frame',
'name': 'Navigation Menu',
'parentId': 'header-section',
'width': 600,
'height': 60,
'x': 300,
'y': 20,
'rotation': 0,
'layout': 'flex',
'layoutFlexDir': 'row',
'layoutAlignItems': 'center',
'layoutJustifyContent': 'center',
'layoutGap': {
'row-gap': '0px',
'column-gap': '20px'
},
'constraintsH': 'center',
'constraintsV': 'center'
},
# Menu items (level 4)
'menu-item-1': {
'id': 'menu-item-1',
'type': 'text',
'name': 'Home',
'parentId': 'nav-menu',
'width': 100,
'height': 40,
'x': 0,
'y': 10,
'rotation': 0,
'content': {
'type': 'root',
'children': [
{
'type': 'paragraph',
'children': [
{
'type': 'text',
'text': 'Home'
}
]
}
]
},
'fills': [
{
'fillColor': '#333333',
'fillOpacity': 1
}
],
'appliedTokens': {
'typography': 'typo1'
},
'constraintsH': 'start',
'constraintsV': 'center'
},
'menu-item-2': {
'id': 'menu-item-2',
'type': 'text',
'name': 'Products',
'parentId': 'nav-menu',
'width': 100,
'height': 40,
'x': 120,
'y': 10,
'rotation': 0,
'content': {
'type': 'root',
'children': [
{
'type': 'paragraph',
'children': [
{
'type': 'text',
'text': 'Products'
}
]
}
]
},
'fills': [
{
'fillColor': '#333333',
'fillOpacity': 1
}
]
},
'menu-item-3': {
'id': 'menu-item-3',
'type': 'text',
'name': 'About',
'parentId': 'nav-menu',
'width': 100,
'height': 40,
'x': 240,
'y': 10,
'rotation': 0,
'content': {
'type': 'root',
'children': [
{
'type': 'paragraph',
'children': [
{
'type': 'text',
'text': 'About'
}
]
}
]
},
'fills': [
{
'fillColor': '#333333',
'fillOpacity': 1
}
]
},
# Content section (level 2)
'content-section': {
'id': 'content-section',
'type': 'frame',
'name': 'Content Section',
'parentId': 'main-container',
'width': 1200,
'height': 700,
'x': 0,
'y': 100,
'rotation': 0,
'layout': 'flex',
'layoutFlexDir': 'column',
'layoutAlignItems': 'stretch',
'layoutJustifyContent': 'start',
'layoutGap': {
'row-gap': '0px',
'column-gap': '0px'
},
'constraintsH': 'stretch',
'constraintsV': 'top'
},
# Hero (level 3)
'hero': {
'id': 'hero',
'type': 'frame',
'name': 'Hero Section',
'parentId': 'content-section',
'width': 1200,
'height': 400,
'x': 0,
'y': 0,
'rotation': 0,
'fills': [
{
'fillColor': '#F0F7FF',
'fillOpacity': 1
}
],
'layout': 'flex',
'layoutFlexDir': 'column',
'layoutAlignItems': 'center',
'layoutJustifyContent': 'center',
'layoutPadding': {
'padding-top': '40px',
'padding-right': '40px',
'padding-bottom': '40px',
'padding-left': '40px'
},
'constraintsH': 'stretch',
'constraintsV': 'top'
},
# Hero title (level 4)
'hero-title': {
'id': 'hero-title',
'type': 'text',
'name': 'Welcome Title',
'parentId': 'hero',
'width': 600,
'height': 80,
'x': 300,
'y': 140,
'rotation': 0,
'content': {
'type': 'root',
'children': [
{
'type': 'paragraph',
'children': [
{
'type': 'text',
'text': 'Welcome to our Platform'
}
]
}
]
},
'fills': [
{
'fillColor': '#333333',
'fillOpacity': 1
}
],
'appliedTokens': {
'typography': 'typo1'
},
'constraintsH': 'center',
'constraintsV': 'center'
},
# Cards container (level 3)
'cards-container': {
'id': 'cards-container',
'type': 'frame',
'name': 'Cards Container',
'parentId': 'content-section',
'width': 1200,
'height': 300,
'x': 0,
'y': 400,
'rotation': 0,
'layout': 'flex',
'layoutFlexDir': 'row',
'layoutAlignItems': 'center',
'layoutJustifyContent': 'space-around',
'layoutPadding': {
'padding-top': '25px',
'padding-right': '25px',
'padding-bottom': '25px',
'padding-left': '25px'
},
'constraintsH': 'stretch',
'constraintsV': 'top'
},
# Card instances (level 4)
'card-1': {
'id': 'card-1',
'type': 'frame',
'name': 'Card 1',
'parentId': 'cards-container',
'width': 300,
'height': 250,
'x': 50,
'y': 25,
'rotation': 0,
'componentId': 'comp2',
'fills': [
{
'fillColor': '#FFFFFF',
'fillOpacity': 1
}
],
'strokes': [
{
'strokeStyle': 'solid',
'strokeAlignment': 'center',
'strokeWidth': 1,
'strokeColor': '#EEEEEE',
'strokeOpacity': 1
}
],
'r1': 8,
'r2': 8,
'r3': 8,
'r4': 8,
'layout': 'flex',
'layoutFlexDir': 'column',
'layoutAlignItems': 'center',
'layoutJustifyContent': 'start',
'layoutPadding': {
'padding-top': '20px',
'padding-right': '20px',
'padding-bottom': '20px',
'padding-left': '20px'
},
'constraintsH': 'center',
'constraintsV': 'center'
},
'card-2': {
'id': 'card-2',
'type': 'frame',
'name': 'Card 2',
'parentId': 'cards-container',
'width': 300,
'height': 250,
'x': 450,
'y': 25,
'rotation': 0,
'componentId': 'comp2',
'fills': [
{
'fillColor': '#FFFFFF',
'fillOpacity': 1
}
],
'strokes': [
{
'strokeStyle': 'solid',
'strokeAlignment': 'center',
'strokeWidth': 1,
'strokeColor': '#EEEEEE',
'strokeOpacity': 1
}
],
'r1': 8,
'r2': 8,
'r3': 8,
'r4': 8
},
'card-3': {
'id': 'card-3',
'type': 'frame',
'name': 'Card 3',
'parentId': 'cards-container',
'width': 300,
'height': 250,
'x': 850,
'y': 25,
'rotation': 0,
'componentId': 'comp2',
'fills': [
{
'fillColor': '#FFFFFF',
'fillOpacity': 1
}
],
'strokes': [
{
'strokeStyle': 'solid',
'strokeAlignment': 'center',
'strokeWidth': 1,
'strokeColor': '#EEEEEE',
'strokeOpacity': 1
}
],
'r1': 8,
'r2': 8,
'r3': 8,
'r4': 8
}
}
}
},
'id': 'file1',
'pages': ['page1'],
'tokensLib': {
'sets': {
'S-colors': {
'name': 'Colors',
'description': 'Color tokens',
'modifiedAt': '2023-01-01T09:00:00Z',
'tokens': {
'primary': {
'name': 'Primary',
'type': 'color',
'value': '#3366FF',
'description': 'Primary color',
'modifiedAt': '2023-01-01T09:00:00Z'
},
'secondary': {
'name': 'Secondary',
'type': 'color',
'value': '#FF6633',
'description': 'Secondary color',
'modifiedAt': '2023-01-01T09:00:00Z'
}
}
}
},
'themes': {
'default': {
'light': {
'name': 'Light',
'group': 'Default',
'description': 'Light theme',
'isSource': True,
'id': 'theme1',
'modifiedAt': '2023-01-01T09:30:00Z',
'sets': ['S-colors']
}
}
},
'activeThemes': ['light']
}
}
}
# Test 1: Full tree at maximum depth (default)
result = get_object_subtree_with_fields(file_data, 'main-container')
assert 'error' not in result
assert result['tree']['id'] == 'main-container'
assert result['tree']['name'] == 'Main Container'
assert result['tree']['type'] == 'frame'
# Verify first level children exist (header and content sections)
children_names = [child['name'] for child in result['tree']['children']]
assert 'Header Section' in children_names
assert 'Content Section' in children_names
# Verify second level children exist (deep nesting)
header_section = next(child for child in result['tree']['children'] if child['name'] == 'Header Section')
logo_in_header = next((child for child in header_section['children'] if child['name'] == 'Logo'), None)
assert logo_in_header is not None
nav_menu = next((child for child in header_section['children'] if child['name'] == 'Navigation Menu'), None)
assert nav_menu is not None
# Check if level 4 elements (menu items) exist
menu_items = [child for child in nav_menu['children']]
assert len(menu_items) == 3
menu_item_names = [item['name'] for item in menu_items]
assert 'Home' in menu_item_names
assert 'Products' in menu_item_names
assert 'About' in menu_item_names
# Test 2: Depth = 1 (main container and its immediate children only)
result = get_object_subtree_with_fields(file_data, 'main-container', depth=1)
assert 'error' not in result
assert result['tree']['id'] == 'main-container'
assert 'children' in result['tree']
# Should have header and content sections but no deeper elements
children_names = [child['name'] for child in result['tree']['children']]
assert 'Header Section' in children_names
assert 'Content Section' in children_names
# Verify no grandchildren are included
header_section = next(child for child in result['tree']['children'] if child['name'] == 'Header Section')
assert 'children' not in header_section
# Test 3: Depth = 2 (main container, its children, and grandchildren)
result = get_object_subtree_with_fields(file_data, 'main-container', depth=2)
assert 'error' not in result
# Should have header and content sections
header_section = next(child for child in result['tree']['children'] if child['name'] == 'Header Section')
content_section = next(child for child in result['tree']['children'] if child['name'] == 'Content Section')
# Header section should have logo and nav menu but no menu items
assert 'children' in header_section
nav_menu = next((child for child in header_section['children'] if child['name'] == 'Navigation Menu'), None)
assert nav_menu is not None
assert 'children' not in nav_menu
# Test 4: Field filtering with selective depth
result = get_object_subtree_with_fields(
file_data,
'main-container',
include_fields=['name', 'type'],
depth=2
)
assert 'error' not in result
# Main container should have only specified fields plus id
assert set(result['tree'].keys()) == {'id', 'name', 'type', 'children'}
assert 'width' not in result['tree']
assert 'height' not in result['tree']
# Children should also have only the specified fields
header_section = next(child for child in result['tree']['children'] if child['name'] == 'Header Section')
assert set(header_section.keys()) == {'id', 'name', 'type', 'children'}
# Test 5: Testing component references
result = get_object_subtree_with_fields(file_data, 'cards-container')
assert 'error' not in result
# Find the first card
card = next(child for child in result['tree']['children'] if child['name'] == 'Card 1')
assert 'componentId' in card
assert card['componentId'] == 'comp2' # References the Card component
# Test 6: Test layout properties in objects
result = get_object_subtree_with_fields(file_data, 'main-container', include_fields=['layout', 'layoutFlexDir', 'layoutAlignItems', 'layoutJustifyContent'])
assert 'error' not in result
assert result['tree']['layout'] == 'flex'
assert result['tree']['layoutFlexDir'] == 'column'
assert result['tree']['layoutAlignItems'] == 'stretch'
assert result['tree']['layoutJustifyContent'] == 'start'
# Test 7: Test text content structure
result = get_object_subtree_with_fields(file_data, 'hero-title', include_fields=['content'])
assert 'error' not in result
assert result['tree']['content']['type'] == 'root'
assert len(result['tree']['content']['children']) == 1
assert result['tree']['content']['children'][0]['type'] == 'paragraph'
assert result['tree']['content']['children'][0]['children'][0]['text'] == 'Welcome to our Platform'
# Test 8: Test applied tokens
result = get_object_subtree_with_fields(file_data, 'hero-title', include_fields=['appliedTokens'])
assert 'error' not in result
assert 'appliedTokens' in result['tree']
assert result['tree']['appliedTokens']['typography'] == 'typo1'
def test_get_object_subtree_with_fields_root_frame():
"""Test getting a filtered subtree starting from the root frame."""
# Use same complex nested structure from the previous test
file_data = {
'data': {
'pagesIndex': {
'page1': {
'name': 'Complex Page',
'objects': {
# Root frame (level 0)
'00000000-0000-0000-0000-000000000000': {
'type': 'frame',
'name': 'Root Frame',
'width': 1920,
'height': 1080
},
# Main container (level 1)
'main-container': {
'type': 'frame',
'name': 'Main Container',
'parentId': '00000000-0000-0000-0000-000000000000'
}
}
}
}
}
}
# Test getting the root frame
result = get_object_subtree_with_fields(file_data, '00000000-0000-0000-0000-000000000000')
assert 'error' not in result
assert result['tree']['id'] == '00000000-0000-0000-0000-000000000000'
assert result['tree']['type'] == 'frame'
assert 'children' in result['tree']
assert len(result['tree']['children']) == 1
assert result['tree']['children'][0]['name'] == 'Main Container'
def test_get_object_subtree_with_fields_circular_reference():
"""Test handling of circular references in object tree."""
file_data = {
'data': {
'pagesIndex': {
'page1': {
'name': 'Test Page',
'objects': {
# Object A references B as parent
'object-a': {
'type': 'frame',
'name': 'Object A',
'parentId': 'object-b'
},
# Object B references A as parent (circular)
'object-b': {
'type': 'frame',
'name': 'Object B',
'parentId': 'object-a'
},
# Object C references itself as parent
'object-c': {
'type': 'frame',
'name': 'Object C',
'parentId': 'object-c'
}
}
}
}
}
}
# Test getting object A - should handle circular reference with B
result = get_object_subtree_with_fields(file_data, 'object-a')
assert 'error' not in result
assert result['tree']['id'] == 'object-a'
assert 'children' in result['tree']
# Check that object-b appears as a child
assert len(result['tree']['children']) == 1
assert result['tree']['children'][0]['id'] == 'object-b'
# The circular reference appears when object-a appears again as a child of object-b
assert 'children' in result['tree']['children'][0]
assert len(result['tree']['children'][0]['children']) == 1
assert result['tree']['children'][0]['children'][0]['id'] == 'object-a'
assert result['tree']['children'][0]['children'][0]['_circular_reference'] == True
# Test getting object C - should handle self-reference
result = get_object_subtree_with_fields(file_data, 'object-c')
assert 'error' not in result
assert result['tree']['id'] == 'object-c'
assert 'children' in result['tree']
# Check that object-c appears as its own child with circular reference marker
assert len(result['tree']['children']) == 1
assert result['tree']['children'][0]['id'] == 'object-c'
assert result['tree']['children'][0]['_circular_reference'] == True