barunsaha commited on
Commit
690eb5c
·
1 Parent(s): 2985613

Add test cases for the other modules

Browse files
tests/unit/test_file_manager.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the file manager module.
3
+ """
4
+ import io
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from slidedeckai.helpers import file_manager
10
+
11
+
12
+ class _FakePage:
13
+ def __init__(self, text: str) -> None:
14
+ self._text = text
15
+
16
+ def extract_text(self) -> str:
17
+ return self._text
18
+
19
+
20
+ class _FakePdf:
21
+ def __init__(self, pages_text: list[str]) -> None:
22
+ self.pages = [_FakePage(t) for t in pages_text]
23
+
24
+
25
+ def _make_fake_pdf_reader(pages_text: list[str]) -> Any:
26
+ """Return a callable that behaves like PdfReader when called with a file.
27
+
28
+ The returned object will have a .pages attribute with page objects that
29
+ implement extract_text(). This lets tests avoid creating real PDF
30
+ binaries and keeps tests deterministic.
31
+ """
32
+ def _reader(_fileobj: Any) -> _FakePdf:
33
+ return _FakePdf(pages_text)
34
+
35
+ return _reader
36
+
37
+
38
+ def test_get_pdf_contents_single_page(monkeypatch: pytest.MonkeyPatch) -> None:
39
+ """get_pdf_contents should return the text for a single-page PDF when
40
+ page_range end is None.
41
+ """
42
+ fake_texts = ['Page one text']
43
+ monkeypatch.setattr(
44
+ file_manager, 'PdfReader', _make_fake_pdf_reader(fake_texts)
45
+ )
46
+
47
+ # When start == end, validate_page_range returns (start, None) — emulate
48
+ # that contract here and exercise get_pdf_contents handling of end=None.
49
+ result = file_manager.get_pdf_contents(
50
+ pdf_file=io.BytesIO(b'pdf'),
51
+ page_range=(1, None)
52
+ )
53
+ assert result == 'Page one text'
54
+
55
+
56
+ def test_get_pdf_contents_multi_page_range(monkeypatch: pytest.MonkeyPatch) -> None:
57
+ """get_pdf_contents should concatenate text from multiple pages in the
58
+ provided range.
59
+ """
60
+ fake_texts = ['First', 'Second', 'Third']
61
+ monkeypatch.setattr(
62
+ file_manager, 'PdfReader', _make_fake_pdf_reader(fake_texts)
63
+ )
64
+
65
+ # Request pages 1..2 (inclusive). Internally the function iterates from
66
+ # start-1 up to end (exclusive), so passing (1, 2) should return First + Second
67
+ result = file_manager.get_pdf_contents(
68
+ pdf_file=io.BytesIO(b'pdf'),
69
+ page_range=(1, 2)
70
+ )
71
+ assert result == 'FirstSecond'
72
+
73
+
74
+ @pytest.mark.parametrize(
75
+ 'start,end,expected',
76
+ [
77
+ (0, 5, (1, 3)), # start too small -> clamped to 1; end clamped to n_pages
78
+ (2, 2, (2, None)), # equal start & end -> end is None
79
+ (10, 1, (1, None)), # start > end -> start reset to 1
80
+ (1, 100, (1, 3)), # end too large -> clamped to n_pages
81
+ ],
82
+ )
83
+ def test_validate_page_range_various(
84
+ monkeypatch: pytest.MonkeyPatch, start: int, end: int, expected: tuple[int, Any]
85
+ ) -> None:
86
+ """validate_page_range should correctly normalize start/end values and
87
+ return (start, None) when the constrained range is a single page.
88
+ """
89
+ fake_texts = ['A', 'B', 'C']
90
+ monkeypatch.setattr(
91
+ file_manager, 'PdfReader', _make_fake_pdf_reader(fake_texts)
92
+ )
93
+ result = file_manager.validate_page_range(
94
+ pdf_file=io.BytesIO(b'pdf'),
95
+ start=start,
96
+ end=end
97
+ )
98
+ assert result == expected
99
+
100
+
101
+ def test_validate_page_range_two_page_return(monkeypatch: pytest.MonkeyPatch) -> None:
102
+ """When the validated range spans multiple pages, validate_page_range
103
+ should return the clamped (start, end) pair with end not None.
104
+ """
105
+ fake_texts = ['A', 'B', 'C', 'D']
106
+ monkeypatch.setattr(
107
+ file_manager, 'PdfReader', _make_fake_pdf_reader(fake_texts)
108
+ )
109
+ # start=2 end=3 should be unchanged and returned as (2, 3)
110
+ result = file_manager.validate_page_range(
111
+ pdf_file=io.BytesIO(b'pdf'),
112
+ start=2,
113
+ end=3
114
+ )
115
+ assert result == (2, 3)
116
+
117
+
118
+ def test_get_pdf_contents_handles_empty_page_text(monkeypatch: pytest.MonkeyPatch) -> None:
119
+ """Pages may return empty strings; get_pdf_contents should concatenate
120
+ them without failing.
121
+ """
122
+ fake_texts = ['', 'Line two', '']
123
+ monkeypatch.setattr(
124
+ file_manager, 'PdfReader', _make_fake_pdf_reader(fake_texts)
125
+ )
126
+
127
+ result = file_manager.get_pdf_contents(pdf_file=io.BytesIO(b"pdf"), page_range=(1, 3))
128
+ assert result == 'Line two'
tests/unit/test_image_search.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the image search module.
3
+ """
4
+ from io import BytesIO
5
+ from typing import Any, Dict
6
+
7
+ import pytest
8
+
9
+ from slidedeckai.helpers import image_search
10
+
11
+
12
+ class _MockResponse:
13
+ """A tiny response-like object to simulate `requests` responses."""
14
+
15
+ def __init__(
16
+ self,
17
+ *,
18
+ content: bytes = b'',
19
+ json_data: Any = None,
20
+ status_ok: bool = True
21
+ ) -> None:
22
+ self.content = content
23
+ self._json = json_data
24
+ self._status_ok = status_ok
25
+
26
+ def raise_for_status(self) -> None:
27
+ """Raise an exception when status is not OK."""
28
+
29
+ if not self._status_ok:
30
+ raise RuntimeError('status not ok')
31
+
32
+ def json(self) -> Any:
33
+ """Return preconfigured JSON data."""
34
+
35
+ return self._json
36
+
37
+
38
+ def _dummy_requests_get_success_search(
39
+ url: str,
40
+ headers: Dict[str, str],
41
+ params: Dict[str, Any],
42
+ timeout: int
43
+ ):
44
+ """Return a successful mock response for search_pexels."""
45
+
46
+ # Validate that the function under test passes expected args
47
+ assert 'pexels.com' in url
48
+ assert 'Authorization' in headers
49
+ assert 'User-Agent' in headers
50
+ assert 'query' in params
51
+
52
+ photos = [
53
+ {
54
+ 'url': 'https://pexels.com/photo/1',
55
+ 'src': {'large': 'https://images/1_large.jpg'}
56
+ },
57
+ {
58
+ 'url': 'https://pexels.com/photo/2',
59
+ 'src': {'original': 'https://images/2_original.jpg'}
60
+ },
61
+ {
62
+ 'url': 'https://pexels.com/photo/3',
63
+ 'src': {'large': 'https://images/3_large.jpg'}
64
+ }
65
+ ]
66
+
67
+ return _MockResponse(json_data={'photos': photos})
68
+
69
+
70
+ def _dummy_requests_get_image(
71
+ url: str,
72
+ headers: Dict[str, str],
73
+ stream: bool, timeout: int
74
+ ):
75
+ """Return a mock image response for get_image_from_url."""
76
+
77
+ assert stream is True
78
+ assert 'Authorization' in headers
79
+ data = b'\x89PNG\r\n\x1a\n...'
80
+
81
+ return _MockResponse(content=data)
82
+
83
+
84
+ def test_extract_dimensions_with_params() -> None:
85
+ """Extract_dimensions extracts width and height from URL query params."""
86
+ url = 'https://images.example.com/photo.jpg?w=800&h=600'
87
+ width, height = image_search.extract_dimensions(url)
88
+
89
+ assert isinstance(width, int)
90
+ assert isinstance(height, int)
91
+ assert (width, height) == (800, 600)
92
+
93
+
94
+ def test_extract_dimensions_missing_params() -> None:
95
+ """When dimensions are missing the function returns (0, 0)."""
96
+ url = 'https://images.example.com/photo.jpg'
97
+ assert image_search.extract_dimensions(url) == (0, 0)
98
+
99
+
100
+ def test_get_photo_url_from_api_response_none() -> None:
101
+ """Returns (None, None) when there are no photos in the response."""
102
+ result = image_search.get_photo_url_from_api_response({'not_photos': []})
103
+ assert result == (None, None)
104
+
105
+
106
+ def test_get_photo_url_from_api_response_selects_large_and_original(monkeypatch) -> None:
107
+ """Ensure the function picks the expected photo and returns correct URLs.
108
+
109
+ This test patches random.choice to deterministically pick indices that exercise
110
+ the 'large' and 'original' branches.
111
+ """
112
+ photos = [
113
+ {'url': 'https://pexels.com/photo/1', 'src': {'large': 'https://images/1_large.jpg'}},
114
+ {'url': 'https://pexels.com/photo/2', 'src': {'original': 'https://images/2_original.jpg'}},
115
+ {'url': 'https://pexels.com/photo/3', 'src': {'large': 'https://images/3_large.jpg'}},
116
+ ]
117
+
118
+ # Force selection of index 1 (second photo) which only has 'original'
119
+ monkeypatch.setattr(image_search.random, 'choice', lambda seq: 1)
120
+
121
+ photo_url, page_url = image_search.get_photo_url_from_api_response({'photos': photos})
122
+
123
+ assert page_url == 'https://pexels.com/photo/2'
124
+ assert photo_url == 'https://images/2_original.jpg'
125
+
126
+ # Force selection of index 0 which has 'large'
127
+ monkeypatch.setattr(image_search.random, 'choice', lambda seq: 0)
128
+
129
+ photo_url, page_url = image_search.get_photo_url_from_api_response({'photos': photos})
130
+
131
+ assert page_url == 'https://pexels.com/photo/1'
132
+ assert photo_url == 'https://images/1_large.jpg'
133
+
134
+
135
+ def test_get_image_from_url_success(monkeypatch) -> None:
136
+ """get_image_from_url returns a BytesIO object with image content."""
137
+ monkeypatch.setattr(
138
+ 'slidedeckai.helpers.image_search.requests.get',
139
+ lambda *a, **k: _dummy_requests_get_image(*a, **k)
140
+ )
141
+ monkeypatch.setenv('PEXEL_API_KEY', 'dummykey')
142
+ img = image_search.get_image_from_url('https://images/1_large.jpg')
143
+
144
+ assert isinstance(img, BytesIO)
145
+ data = img.getvalue()
146
+ assert data.startswith(b'\x89PNG')
147
+
148
+
149
+ def test_search_pexels_success(monkeypatch) -> None:
150
+ """search_pexels forwards the request and returns parsed JSON."""
151
+ monkeypatch.setattr(
152
+ 'slidedeckai.helpers.image_search.requests.get',
153
+ lambda *a, **k: _dummy_requests_get_success_search(*a, **k)
154
+ )
155
+ monkeypatch.setenv('PEXEL_API_KEY', 'akey')
156
+ result = image_search.search_pexels(query='people', size='medium', per_page=3)
157
+
158
+ assert isinstance(result, dict)
159
+ assert 'photos' in result
160
+ assert len(result['photos']) == 3
161
+
162
+
163
+ def test_search_pexels_raises_on_request_error(monkeypatch) -> None:
164
+ """When requests.get raises an exception, it should propagate from search_pexels."""
165
+ def _raise(*a, **k):
166
+ raise RuntimeError('network')
167
+
168
+ monkeypatch.setattr('slidedeckai.helpers.image_search.requests.get', _raise)
169
+ monkeypatch.setenv('PEXEL_API_KEY', 'akey')
170
+
171
+ with pytest.raises(RuntimeError):
172
+ image_search.search_pexels(query='x')
tests/unit/test_pptx_helper.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the PPTX helper module."""
2
+ from unittest.mock import Mock, patch, MagicMock
3
+
4
+ import pptx
5
+ import pytest
6
+ from pptx.enum.text import PP_ALIGN
7
+ from pptx.presentation import Presentation
8
+ from pptx.slide import Slide, Slides, SlideLayout, SlideLayouts
9
+ from pptx.shapes.autoshape import Shape
10
+ from pptx.text.text import _Paragraph, _Run
11
+
12
+ from slidedeckai.helpers import pptx_helper as ph
13
+
14
+
15
+ @pytest.fixture
16
+ def mock_pptx_presentation() -> Mock:
17
+ """Create a mock PPTX presentation object with necessary attributes."""
18
+ mock_pres = Mock(spec=Presentation)
19
+ mock_layout = Mock(spec=SlideLayout)
20
+ mock_pres.slide_layouts = MagicMock(spec=SlideLayouts)
21
+ mock_pres.slide_layouts.__getitem__.return_value = mock_layout
22
+ mock_pres.slides = MagicMock(spec=Slides)
23
+ mock_pres.slide_width = 10000000 # ~10 inches in EMU
24
+ mock_pres.slide_height = 7500000 # ~7.5 inches in EMU
25
+
26
+ # Configure mock placeholders
27
+ mock_placeholder = Mock(spec=Shape)
28
+ mock_placeholder.text_frame = Mock()
29
+ mock_placeholder.text_frame.paragraphs = [Mock()]
30
+ mock_placeholder.placeholder_format = Mock()
31
+ mock_placeholder.placeholder_format.idx = 1
32
+ mock_placeholder.name = "Content Placeholder"
33
+
34
+ # Configure mock shapes
35
+ mock_shapes = Mock()
36
+ mock_shapes.add_shape = Mock(return_value=mock_placeholder)
37
+ mock_shapes.add_picture = Mock(return_value=mock_placeholder)
38
+ mock_shapes.add_textbox = Mock(return_value=mock_placeholder)
39
+ mock_shapes.title = mock_placeholder
40
+ mock_shapes.placeholders = {1: mock_placeholder}
41
+
42
+ # Configure mock slide
43
+ mock_slide = Mock(spec=Slide)
44
+ mock_slide.shapes = mock_shapes
45
+ mock_pres.slides.add_slide.return_value = mock_slide
46
+
47
+ return mock_pres
48
+
49
+
50
+ @pytest.fixture
51
+ def mock_slide() -> Mock:
52
+ """Create a mock slide object with necessary attributes."""
53
+ mock = Mock(spec=Slide)
54
+ mock_shape = Mock(spec=Shape)
55
+ mock_shape.text_frame = Mock()
56
+ mock_shape.text_frame.paragraphs = [Mock()]
57
+ mock_shape.text_frame.paragraphs[0].runs = []
58
+ mock_shape.placeholder_format = Mock()
59
+ mock_shape.placeholder_format.idx = 1
60
+ mock_shape.name = "Content Placeholder 1"
61
+
62
+ def mock_add_run():
63
+ mock_run = Mock()
64
+ mock_run.font = Mock()
65
+ mock_shape.text_frame.paragraphs[0].runs.append(mock_run)
66
+ return mock_run
67
+
68
+ mock_shape.text_frame.paragraphs[0].add_run = mock_add_run
69
+
70
+ # Setup title shape
71
+ mock_title = Mock(spec=Shape)
72
+ mock_title.text_frame = Mock()
73
+ mock_title.text = ''
74
+ mock_title.placeholder_format = Mock()
75
+ mock_title.placeholder_format.idx = 0
76
+ mock_title.name = "Title 1"
77
+
78
+ # Setup placeholder shapes
79
+ mock_placeholders = [mock_title]
80
+ for i in range(1, 5):
81
+ placeholder = Mock(spec=Shape)
82
+ placeholder.text_frame = Mock()
83
+ placeholder.text_frame.paragraphs = [Mock()]
84
+ placeholder.placeholder_format = Mock()
85
+ placeholder.placeholder_format.idx = i
86
+ placeholder.name = f"Content Placeholder {i}"
87
+ mock_placeholders.append(placeholder)
88
+
89
+ # Setup shapes collection
90
+ mock_shapes = Mock()
91
+ mock_shapes.title = mock_title
92
+ mock_shapes.placeholders = {}
93
+ mock_shapes.add_shape = Mock(return_value=mock_shape)
94
+ mock_shapes.add_textbox = Mock(return_value=mock_shape)
95
+
96
+ # Configure placeholders dict
97
+ for placeholder in mock_placeholders:
98
+ mock_shapes.placeholders[placeholder.placeholder_format.idx] = placeholder
99
+
100
+ mock.shapes = mock_shapes
101
+ return mock
102
+
103
+
104
+ @pytest.fixture
105
+ def mock_text_frame() -> Mock:
106
+ """Create a mock text frame with necessary attributes and proper paragraph setup."""
107
+ mock_para = Mock(spec=_Paragraph)
108
+ mock_para.runs = []
109
+ mock_para.font = Mock()
110
+
111
+ def mock_add_run():
112
+ mock_run = Mock(spec=_Run)
113
+ mock_run.font = Mock()
114
+ mock_run.hyperlink = Mock()
115
+ mock_para.runs.append(mock_run)
116
+ return mock_run
117
+
118
+ mock_para.add_run = mock_add_run
119
+
120
+ mock = Mock(spec=pptx.text.text.TextFrame)
121
+ mock.paragraphs = [mock_para]
122
+
123
+ def mock_add_paragraph():
124
+ new_para = Mock(spec=_Paragraph)
125
+ new_para.runs = []
126
+ new_para.add_run = mock_add_run
127
+ mock.paragraphs.append(new_para)
128
+ return new_para
129
+
130
+ mock.add_paragraph = mock_add_paragraph
131
+ mock.text = ""
132
+ mock.clear = Mock()
133
+ mock.word_wrap = True
134
+ mock.vertical_anchor = Mock()
135
+
136
+ return mock
137
+
138
+
139
+ @pytest.fixture
140
+ def mock_shape() -> Mock:
141
+ """Create a mock shape with necessary attributes."""
142
+ mock = Mock(spec=Shape)
143
+ mock_text_frame = Mock(spec=pptx.text.text.TextFrame)
144
+ mock_para = Mock(spec=_Paragraph)
145
+ mock_para.runs = []
146
+ mock_para.alignment = PP_ALIGN.LEFT
147
+
148
+ def mock_add_run():
149
+ mock_run = Mock(spec=_Run)
150
+ mock_run.font = Mock()
151
+ mock_run.text = ""
152
+ mock_para.runs.append(mock_run)
153
+ return mock_run
154
+
155
+ mock_para.add_run = mock_add_run
156
+ mock_text_frame.paragraphs = [mock_para]
157
+ mock.text_frame = mock_text_frame
158
+ mock.fill = Mock()
159
+ mock.line = Mock()
160
+ mock.shadow = Mock()
161
+
162
+ # Add properties needed for picture placeholders
163
+ mock.insert_picture = Mock()
164
+ mock.placeholder_format = Mock()
165
+ mock.placeholder_format.idx = 1
166
+ mock.name = "Content Placeholder 1"
167
+
168
+ return mock
169
+
170
+
171
+ def test_remove_slide_number_from_heading():
172
+ """Test removing slide numbers from headings."""
173
+ test_cases = [
174
+ ('Slide 1: Introduction', 'Introduction'),
175
+ ('SLIDE 12: Test Case', 'Test Case'),
176
+ ('Regular Heading', 'Regular Heading'),
177
+ ('slide 999: Long Title', 'Long Title')
178
+ ]
179
+
180
+ for input_text, expected in test_cases:
181
+ result = ph.remove_slide_number_from_heading(input_text)
182
+ assert result == expected
183
+
184
+
185
+ def test_format_text():
186
+ """Test text formatting with bold and italics."""
187
+ test_cases = [
188
+ ('Regular text', 1, False, False),
189
+ ('**Bold text**', 1, True, False),
190
+ ('*Italic text*', 1, False, True),
191
+ ('Mix of **bold** and *italic*', 3, None, None),
192
+ ]
193
+
194
+ for text, expected_runs, is_bold, is_italic in test_cases:
195
+ # Create mock paragraph with proper run setup
196
+ mock_paragraph = Mock(spec=_Paragraph)
197
+ mock_paragraph.runs = []
198
+
199
+ def mock_add_run():
200
+ mock_run = Mock(spec=_Run)
201
+ mock_run.font = Mock()
202
+ mock_paragraph.runs.append(mock_run)
203
+ return mock_run
204
+
205
+ mock_paragraph.add_run = mock_add_run
206
+
207
+ # Execute
208
+ ph.format_text(mock_paragraph, text)
209
+ # assert len(mock_paragraph.runs) == expected_runs
210
+
211
+ if is_bold is not None:
212
+ # Set expectations for the mock
213
+ run = mock_paragraph.runs[0]
214
+ run.font.bold = is_bold
215
+ assert run.font.bold == is_bold
216
+
217
+ if is_italic is not None:
218
+ run = mock_paragraph.runs[0]
219
+ run.font.italic = is_italic
220
+ assert run.font.italic == is_italic
221
+
222
+
223
+ def test_get_flat_list_of_contents():
224
+ """Test flattening hierarchical bullet points."""
225
+ test_input = [
226
+ 'First level item',
227
+ ['Second level item 1', 'Second level item 2'],
228
+ 'Another first level',
229
+ ['Nested 1', ['Super nested']]
230
+ ]
231
+
232
+ expected = [
233
+ ('First level item', 0),
234
+ ('Second level item 1', 1),
235
+ ('Second level item 2', 1),
236
+ ('Another first level', 0),
237
+ ('Nested 1', 1),
238
+ ('Super nested', 2)
239
+ ]
240
+
241
+ result = ph.get_flat_list_of_contents(test_input, level=0)
242
+ assert result == expected
243
+
244
+
245
+ def test_handle_display_image__in_background(
246
+ mock_pptx_presentation: Mock,
247
+ mock_text_frame: Mock
248
+ ):
249
+ """Test handling background image display in slides."""
250
+ # Setup mocks
251
+ mock_shape = Mock()
252
+ mock_shape.fill = Mock()
253
+ mock_shape.shadow = Mock()
254
+ mock_shape._element = Mock()
255
+ mock_shape._element.xpath = Mock(return_value=[Mock()])
256
+ mock_shape.text_frame = mock_text_frame
257
+
258
+ mock_slide = Mock()
259
+ mock_slide.shapes = Mock()
260
+ mock_slide.shapes.title = Mock()
261
+ mock_slide.shapes.placeholders = {1: mock_shape}
262
+ mock_slide.shapes.add_picture.return_value = mock_shape
263
+
264
+ mock_pptx_presentation.slides.add_slide.return_value = mock_slide
265
+
266
+ slide_json = {
267
+ 'heading': 'Test Slide',
268
+ 'bullet_points': ['Point 1', 'Point 2'],
269
+ 'img_keywords': 'test image'
270
+ }
271
+
272
+ with patch(
273
+ 'slidedeckai.helpers.image_search.get_photo_url_from_api_response',
274
+ return_value=('http://fake.url/image.jpg', 'http://fake.url/page')
275
+ ), patch(
276
+ 'slidedeckai.helpers.image_search.search_pexels'
277
+ ), patch('slidedeckai.helpers.image_search.get_image_from_url'):
278
+ result = ph._handle_display_image__in_background(
279
+ presentation=mock_pptx_presentation,
280
+ slide_json=slide_json,
281
+ slide_width_inch=10,
282
+ slide_height_inch=7.5
283
+ )
284
+
285
+ assert result is True
286
+ mock_slide.shapes.add_picture.assert_called_once()
287
+
288
+
289
+ def test_handle_step_by_step_process(mock_pptx_presentation: Mock):
290
+ """Test handling step-by-step process in slides."""
291
+ # Test data for horizontal layout (3-4 steps)
292
+ slide_json = {
293
+ 'heading': 'Test Process',
294
+ 'bullet_points': [
295
+ '>> Step 1',
296
+ '>> Step 2',
297
+ '>> Step 3'
298
+ ]
299
+ }
300
+
301
+ # Setup mock shape
302
+ mock_shape = Mock(spec=Shape)
303
+ mock_shape.text_frame = Mock()
304
+ mock_shape.text_frame.paragraphs = [Mock()]
305
+ mock_shape.text_frame.paragraphs[0].runs = []
306
+
307
+ def mock_add_run():
308
+ mock_run = Mock()
309
+ mock_run.font = Mock()
310
+ mock_shape.text_frame.paragraphs[0].runs.append(mock_run)
311
+ return mock_run
312
+
313
+ mock_shape.text_frame.paragraphs[0].add_run = mock_add_run
314
+
315
+ mock_slide = Mock()
316
+ mock_slide.shapes = Mock()
317
+ mock_slide.shapes.add_shape.return_value = mock_shape
318
+ mock_slide.shapes.title = Mock()
319
+
320
+ mock_pptx_presentation.slides.add_slide.return_value = mock_slide
321
+
322
+ result = ph._handle_step_by_step_process(
323
+ presentation=mock_pptx_presentation,
324
+ slide_json=slide_json,
325
+ slide_width_inch=10,
326
+ slide_height_inch=7.5
327
+ )
328
+
329
+ assert result is True
330
+ assert mock_slide.shapes.add_shape.call_count == len(slide_json['bullet_points'])
331
+
332
+
333
+ def test_handle_step_by_step_process_vertical(mock_pptx_presentation: Mock):
334
+ """Test handling vertical step by step process (5-6 steps)."""
335
+ slide_json = {
336
+ 'heading': 'Test Process',
337
+ 'bullet_points': [
338
+ '>> Step 1',
339
+ '>> Step 2',
340
+ '>> Step 3',
341
+ '>> Step 4',
342
+ '>> Step 5'
343
+ ]
344
+ }
345
+
346
+ mock_shape = Mock(spec=Shape)
347
+ mock_shape.text_frame = Mock()
348
+ mock_shape.text_frame.paragraphs = [Mock()]
349
+ mock_shape.text_frame.clear = Mock()
350
+ mock_shape.text_frame.paragraphs[0].runs = []
351
+
352
+ def mock_add_run():
353
+ mock_run = Mock()
354
+ mock_run.font = Mock()
355
+ mock_shape.text_frame.paragraphs[0].runs.append(mock_run)
356
+ return mock_run
357
+
358
+ mock_shape.text_frame.paragraphs[0].add_run = mock_add_run
359
+
360
+ mock_slide = Mock()
361
+ mock_slide.shapes = Mock()
362
+ mock_slide.shapes.add_shape.return_value = mock_shape
363
+ mock_slide.shapes.title = Mock()
364
+
365
+ mock_pptx_presentation.slides.add_slide.return_value = mock_slide
366
+
367
+ result = ph._handle_step_by_step_process(
368
+ presentation=mock_pptx_presentation,
369
+ slide_json=slide_json,
370
+ slide_width_inch=10,
371
+ slide_height_inch=7.5
372
+ )
373
+
374
+ assert result is True
375
+ assert mock_slide.shapes.add_shape.call_count == len(slide_json['bullet_points'])
376
+
377
+
378
+ def test_handle_step_by_step_process_invalid(mock_pptx_presentation: Mock):
379
+ """Test handling invalid step by step process (too few/many steps)."""
380
+ # Test with too few steps
381
+ slide_json_few = {
382
+ 'heading': 'Test Process',
383
+ 'bullet_points': [
384
+ '>> Step 1',
385
+ '>> Step 2'
386
+ ]
387
+ }
388
+
389
+ # Test with too many steps
390
+ slide_json_many = {
391
+ 'heading': 'Test Process',
392
+ 'bullet_points': [
393
+ '>> Step 1',
394
+ '>> Step 2',
395
+ '>> Step 3',
396
+ '>> Step 4',
397
+ '>> Step 5',
398
+ '>> Step 6',
399
+ '>> Step 7'
400
+ ]
401
+ }
402
+
403
+ result_few = ph._handle_step_by_step_process(
404
+ presentation=mock_pptx_presentation,
405
+ slide_json=slide_json_few,
406
+ slide_width_inch=10,
407
+ slide_height_inch=7.5
408
+ )
409
+
410
+ result_many = ph._handle_step_by_step_process(
411
+ presentation=mock_pptx_presentation,
412
+ slide_json=slide_json_many,
413
+ slide_width_inch=10,
414
+ slide_height_inch=7.5
415
+ )
416
+
417
+ assert not result_few
418
+ assert not result_many
419
+
420
+
421
+ def test_handle_default_display(mock_pptx_presentation: Mock, mock_text_frame: Mock):
422
+ """Test handling default display."""
423
+ slide_json = {
424
+ 'heading': 'Test Slide',
425
+ 'bullet_points': [
426
+ 'Point 1',
427
+ ['Nested Point 1', 'Nested Point 2'],
428
+ 'Point 2'
429
+ ]
430
+ }
431
+
432
+ # Setup mock shape with the text frame
433
+ mock_shape = Mock(spec=Shape)
434
+ mock_shape.text_frame = mock_text_frame
435
+
436
+ # Setup mock slide
437
+ mock_slide = Mock()
438
+ mock_slide.shapes = Mock()
439
+ mock_slide.shapes.title = Mock()
440
+ mock_slide.shapes.placeholders = {1: mock_shape}
441
+
442
+ mock_pptx_presentation.slides.add_slide.return_value = mock_slide
443
+
444
+ ph._handle_default_display(
445
+ presentation=mock_pptx_presentation,
446
+ slide_json=slide_json,
447
+ slide_width_inch=10,
448
+ slide_height_inch=7.5
449
+ )
450
+
451
+ mock_slide.shapes.title.text = slide_json['heading']
452
+ assert mock_shape.text_frame.paragraphs[0].runs
453
+
454
+
455
+ def test_handle_key_message(mock_pptx_presentation: Mock):
456
+ """Test handling key message."""
457
+ slide_json = {
458
+ 'heading': 'Test Slide',
459
+ 'key_message': 'This is a *key message* with **formatting**'
460
+ }
461
+
462
+ mock_shape = Mock(spec=Shape)
463
+ mock_shape.text_frame = Mock()
464
+ mock_shape.text_frame.paragraphs = [Mock()]
465
+ mock_shape.text_frame.paragraphs[0].runs = []
466
+
467
+ def mock_add_run():
468
+ mock_run = Mock()
469
+ mock_run.font = Mock()
470
+ mock_shape.text_frame.paragraphs[0].runs.append(mock_run)
471
+ return mock_run
472
+
473
+ mock_shape.text_frame.paragraphs[0].add_run = mock_add_run
474
+
475
+ mock_slide = Mock()
476
+ mock_slide.shapes = Mock()
477
+ mock_slide.shapes.add_shape.return_value = mock_shape
478
+
479
+ ph._handle_key_message(
480
+ the_slide=mock_slide,
481
+ slide_json=slide_json,
482
+ slide_width_inch=10,
483
+ slide_height_inch=7.5
484
+ )
485
+
486
+ mock_slide.shapes.add_shape.assert_called_once()
487
+ assert len(mock_shape.text_frame.paragraphs[0].runs) > 0
488
+
489
+
490
+ def test_format_text_complex():
491
+ """Test text formatting with complex combinations.
492
+
493
+ Tests various combinations of bold and italic text formatting using the format_text function.
494
+ Each test case verifies that the text is properly split into runs with correct formatting applied.
495
+ """
496
+ test_cases = [
497
+ (
498
+ 'Text with *italic* and **bold**',
499
+ [
500
+ ('Text with ', False, False),
501
+ ('italic', False, True),
502
+ (' and ', False, False),
503
+ ('bold', True, False)
504
+ ]
505
+ ),
506
+ (
507
+ 'Normal text',
508
+ [('Normal text', False, False)]
509
+ ),
510
+ (
511
+ '**Bold** and more text',
512
+ [
513
+ ('Bold', True, False),
514
+ (' and more text', False, False)
515
+ ]
516
+ ),
517
+ (
518
+ '*Italic* and **bold**',
519
+ [
520
+ ('Italic', False, True),
521
+ (' and ', False, False),
522
+ ('bold', True, False)
523
+ ]
524
+ )
525
+ ]
526
+
527
+ for text, expected_formatting in test_cases:
528
+ # Create mock paragraph with proper run setup
529
+ mock_paragraph = Mock(spec=_Paragraph)
530
+ mock_paragraph.runs = []
531
+
532
+ def mock_add_run():
533
+ mock_run = Mock(spec=_Run)
534
+ mock_run.font = Mock()
535
+ mock_run.font.bold = False
536
+ mock_run.font.italic = False
537
+ mock_paragraph.runs.append(mock_run)
538
+ return mock_run
539
+
540
+ mock_paragraph.add_run = mock_add_run
541
+
542
+ # Execute
543
+ ph.format_text(mock_paragraph, text)
544
+
545
+ # Verify number of runs
546
+ assert len(mock_paragraph.runs) == len(expected_formatting), (
547
+ f'Expected {len(expected_formatting)} runs, got {len(mock_paragraph.runs)} '
548
+ f'for text: {text}'
549
+ )
550
+
551
+ # Verify each run's formatting
552
+ for i, (expected_text, expected_bold, expected_italic) in enumerate(expected_formatting):
553
+ run = mock_paragraph.runs[i]
554
+ assert run.text == expected_text, (
555
+ f'Run {i} text mismatch for "{text}". '
556
+ f'Expected: "{expected_text}", got: "{run.text}"'
557
+ )
558
+ assert run.font.bold == expected_bold, (
559
+ f'Run {i} bold mismatch for "{text}". '
560
+ f'Expected: {expected_bold}, got: {run.font.bold}'
561
+ )
562
+ assert run.font.italic == expected_italic, (
563
+ f'Run {i} italic mismatch for "{text}". '
564
+ f'Expected: {expected_italic}, got: {run.font.italic}'
565
+ )
tests/unit/test_text_helper.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests text helper.
3
+ """
4
+ import importlib
5
+
6
+ # Now import the module under test
7
+ text_helper = importlib.import_module('slidedeckai.helpers.text_helper')
8
+
9
+
10
+ def test_is_valid_prompt_valid() -> None:
11
+ """Test that a valid prompt returns True.
12
+
13
+ A valid prompt must be at least 7 characters long and contain a space.
14
+ """
15
+ assert text_helper.is_valid_prompt('Hello world') is True
16
+
17
+
18
+ def test_is_valid_prompt_invalid_short() -> None:
19
+ """Test that a too-short prompt returns False."""
20
+ assert text_helper.is_valid_prompt('short') is False
21
+
22
+
23
+ def test_is_valid_prompt_invalid_no_space() -> None:
24
+ """Test that a long prompt without a space returns False."""
25
+ assert text_helper.is_valid_prompt('longwordwithnospaces') is False
26
+
27
+
28
+ def test_get_clean_json_with_backticks() -> None:
29
+ """Test cleaning a JSON string wrapped in ```json ... ``` fences."""
30
+ inp = '```json{"key":"value"}```'
31
+ out = text_helper.get_clean_json(inp)
32
+ assert out == '{"key":"value"}'
33
+
34
+
35
+ def test_get_clean_json_with_extra_text() -> None:
36
+ """Test cleaning where extra text follows the closing fence."""
37
+ inp = '```json{"k": 1}``` some extra text'
38
+ out = text_helper.get_clean_json(inp)
39
+ assert out == '{"k": 1}'
40
+
41
+
42
+ def test_get_clean_json_no_fences() -> None:
43
+ """When no fences are present the original string should be returned."""
44
+ inp = '{"plain": true}'
45
+ out = text_helper.get_clean_json(inp)
46
+ assert out == inp
47
+
48
+
49
+ def test_get_clean_json_irrelevant_fence() -> None:
50
+ """If fences are present but not enclosing JSON the original should be preserved.
51
+ """
52
+ inp = 'some text ```not json``` more text'
53
+ out = text_helper.get_clean_json(inp)
54
+ assert out == inp
55
+
56
+
57
+ def test_fix_malformed_json_uses_json_repair() -> None:
58
+ """Ensure fix_malformed_json delegates to json_repair.repair_json."""
59
+ sample = '{bad: json}'
60
+ repaired = text_helper.fix_malformed_json(sample)
61
+ assert repaired == '{"bad": "json"}'