1"Test squeezer, coverage 95%"
2
3from textwrap import dedent
4from tkinter import Text, Tk
5import unittest
6from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY
7from test.support import requires
8
9from idlelib.config import idleConf
10from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \
11    Squeezer
12from idlelib import macosx
13from idlelib.textview import view_text
14from idlelib.tooltip import Hovertip
15from idlelib.pyshell import PyShell
16
17
18SENTINEL_VALUE = sentinel.SENTINEL_VALUE
19
20
21def get_test_tk_root(test_instance):
22    """Helper for tests: Create a root Tk object."""
23    requires('gui')
24    root = Tk()
25    root.withdraw()
26
27    def cleanup_root():
28        root.update_idletasks()
29        root.destroy()
30    test_instance.addCleanup(cleanup_root)
31
32    return root
33
34
35class CountLinesTest(unittest.TestCase):
36    """Tests for the count_lines_with_wrapping function."""
37    def check(self, expected, text, linewidth):
38        return self.assertEqual(
39            expected,
40            count_lines_with_wrapping(text, linewidth),
41        )
42
43    def test_count_empty(self):
44        """Test with an empty string."""
45        self.assertEqual(count_lines_with_wrapping(""), 0)
46
47    def test_count_begins_with_empty_line(self):
48        """Test with a string which begins with a newline."""
49        self.assertEqual(count_lines_with_wrapping("\ntext"), 2)
50
51    def test_count_ends_with_empty_line(self):
52        """Test with a string which ends with a newline."""
53        self.assertEqual(count_lines_with_wrapping("text\n"), 1)
54
55    def test_count_several_lines(self):
56        """Test with several lines of text."""
57        self.assertEqual(count_lines_with_wrapping("1\n2\n3\n"), 3)
58
59    def test_empty_lines(self):
60        self.check(expected=1, text='\n', linewidth=80)
61        self.check(expected=2, text='\n\n', linewidth=80)
62        self.check(expected=10, text='\n' * 10, linewidth=80)
63
64    def test_long_line(self):
65        self.check(expected=3, text='a' * 200, linewidth=80)
66        self.check(expected=3, text='a' * 200 + '\n', linewidth=80)
67
68    def test_several_lines_different_lengths(self):
69        text = dedent("""\
70            13 characters
71            43 is the number of characters on this line
72
73            7 chars
74            13 characters""")
75        self.check(expected=5, text=text, linewidth=80)
76        self.check(expected=5, text=text + '\n', linewidth=80)
77        self.check(expected=6, text=text, linewidth=40)
78        self.check(expected=7, text=text, linewidth=20)
79        self.check(expected=11, text=text, linewidth=10)
80
81
82class SqueezerTest(unittest.TestCase):
83    """Tests for the Squeezer class."""
84    def make_mock_editor_window(self, with_text_widget=False):
85        """Create a mock EditorWindow instance."""
86        editwin = NonCallableMagicMock()
87        editwin.width = 80
88
89        if with_text_widget:
90            editwin.root = get_test_tk_root(self)
91            text_widget = self.make_text_widget(root=editwin.root)
92            editwin.text = editwin.per.bottom = text_widget
93
94        return editwin
95
96    def make_squeezer_instance(self, editor_window=None):
97        """Create an actual Squeezer instance with a mock EditorWindow."""
98        if editor_window is None:
99            editor_window = self.make_mock_editor_window()
100        squeezer = Squeezer(editor_window)
101        return squeezer
102
103    def make_text_widget(self, root=None):
104        if root is None:
105            root = get_test_tk_root(self)
106        text_widget = Text(root)
107        text_widget["font"] = ('Courier', 10)
108        text_widget.mark_set("iomark", "1.0")
109        return text_widget
110
111    def set_idleconf_option_with_cleanup(self, configType, section, option, value):
112        prev_val = idleConf.GetOption(configType, section, option)
113        idleConf.SetOption(configType, section, option, value)
114        self.addCleanup(idleConf.SetOption,
115                        configType, section, option, prev_val)
116
117    def test_count_lines(self):
118        """Test Squeezer.count_lines() with various inputs."""
119        editwin = self.make_mock_editor_window()
120        squeezer = self.make_squeezer_instance(editwin)
121
122        for text_code, line_width, expected in [
123            (r"'\n'", 80, 1),
124            (r"'\n' * 3", 80, 3),
125            (r"'a' * 40 + '\n'", 80, 1),
126            (r"'a' * 80 + '\n'", 80, 1),
127            (r"'a' * 200 + '\n'", 80, 3),
128            (r"'aa\t' * 20", 80, 2),
129            (r"'aa\t' * 21", 80, 3),
130            (r"'aa\t' * 20", 40, 4),
131        ]:
132            with self.subTest(text_code=text_code,
133                              line_width=line_width,
134                              expected=expected):
135                text = eval(text_code)
136                with patch.object(editwin, 'width', line_width):
137                    self.assertEqual(squeezer.count_lines(text), expected)
138
139    def test_init(self):
140        """Test the creation of Squeezer instances."""
141        editwin = self.make_mock_editor_window()
142        squeezer = self.make_squeezer_instance(editwin)
143        self.assertIs(squeezer.editwin, editwin)
144        self.assertEqual(squeezer.expandingbuttons, [])
145
146    def test_write_no_tags(self):
147        """Test Squeezer's overriding of the EditorWindow's write() method."""
148        editwin = self.make_mock_editor_window()
149        for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]:
150            editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE)
151            squeezer = self.make_squeezer_instance(editwin)
152
153            self.assertEqual(squeezer.editwin.write(text, ()), SENTINEL_VALUE)
154            self.assertEqual(orig_write.call_count, 1)
155            orig_write.assert_called_with(text, ())
156            self.assertEqual(len(squeezer.expandingbuttons), 0)
157
158    def test_write_not_stdout(self):
159        """Test Squeezer's overriding of the EditorWindow's write() method."""
160        for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]:
161            editwin = self.make_mock_editor_window()
162            editwin.write.return_value = SENTINEL_VALUE
163            orig_write = editwin.write
164            squeezer = self.make_squeezer_instance(editwin)
165
166            self.assertEqual(squeezer.editwin.write(text, "stderr"),
167                              SENTINEL_VALUE)
168            self.assertEqual(orig_write.call_count, 1)
169            orig_write.assert_called_with(text, "stderr")
170            self.assertEqual(len(squeezer.expandingbuttons), 0)
171
172    def test_write_stdout(self):
173        """Test Squeezer's overriding of the EditorWindow's write() method."""
174        editwin = self.make_mock_editor_window()
175
176        for text in ['', 'TEXT']:
177            editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE)
178            squeezer = self.make_squeezer_instance(editwin)
179            squeezer.auto_squeeze_min_lines = 50
180
181            self.assertEqual(squeezer.editwin.write(text, "stdout"),
182                             SENTINEL_VALUE)
183            self.assertEqual(orig_write.call_count, 1)
184            orig_write.assert_called_with(text, "stdout")
185            self.assertEqual(len(squeezer.expandingbuttons), 0)
186
187        for text in ['LONG TEXT' * 1000, 'MANY_LINES\n' * 100]:
188            editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE)
189            squeezer = self.make_squeezer_instance(editwin)
190            squeezer.auto_squeeze_min_lines = 50
191
192            self.assertEqual(squeezer.editwin.write(text, "stdout"), None)
193            self.assertEqual(orig_write.call_count, 0)
194            self.assertEqual(len(squeezer.expandingbuttons), 1)
195
196    def test_auto_squeeze(self):
197        """Test that the auto-squeezing creates an ExpandingButton properly."""
198        editwin = self.make_mock_editor_window(with_text_widget=True)
199        text_widget = editwin.text
200        squeezer = self.make_squeezer_instance(editwin)
201        squeezer.auto_squeeze_min_lines = 5
202        squeezer.count_lines = Mock(return_value=6)
203
204        editwin.write('TEXT\n'*6, "stdout")
205        self.assertEqual(text_widget.get('1.0', 'end'), '\n')
206        self.assertEqual(len(squeezer.expandingbuttons), 1)
207
208    def test_squeeze_current_text_event(self):
209        """Test the squeeze_current_text event."""
210        # Squeezing text should work for both stdout and stderr.
211        for tag_name in ["stdout", "stderr"]:
212            editwin = self.make_mock_editor_window(with_text_widget=True)
213            text_widget = editwin.text
214            squeezer = self.make_squeezer_instance(editwin)
215            squeezer.count_lines = Mock(return_value=6)
216
217            # Prepare some text in the Text widget.
218            text_widget.insert("1.0", "SOME\nTEXT\n", tag_name)
219            text_widget.mark_set("insert", "1.0")
220            self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
221
222            self.assertEqual(len(squeezer.expandingbuttons), 0)
223
224            # Test squeezing the current text.
225            retval = squeezer.squeeze_current_text_event(event=Mock())
226            self.assertEqual(retval, "break")
227            self.assertEqual(text_widget.get('1.0', 'end'), '\n\n')
228            self.assertEqual(len(squeezer.expandingbuttons), 1)
229            self.assertEqual(squeezer.expandingbuttons[0].s, 'SOME\nTEXT')
230
231            # Test that expanding the squeezed text works and afterwards
232            # the Text widget contains the original text.
233            squeezer.expandingbuttons[0].expand(event=Mock())
234            self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
235            self.assertEqual(len(squeezer.expandingbuttons), 0)
236
237    def test_squeeze_current_text_event_no_allowed_tags(self):
238        """Test that the event doesn't squeeze text without a relevant tag."""
239        editwin = self.make_mock_editor_window(with_text_widget=True)
240        text_widget = editwin.text
241        squeezer = self.make_squeezer_instance(editwin)
242        squeezer.count_lines = Mock(return_value=6)
243
244        # Prepare some text in the Text widget.
245        text_widget.insert("1.0", "SOME\nTEXT\n", "TAG")
246        text_widget.mark_set("insert", "1.0")
247        self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
248
249        self.assertEqual(len(squeezer.expandingbuttons), 0)
250
251        # Test squeezing the current text.
252        retval = squeezer.squeeze_current_text_event(event=Mock())
253        self.assertEqual(retval, "break")
254        self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
255        self.assertEqual(len(squeezer.expandingbuttons), 0)
256
257    def test_squeeze_text_before_existing_squeezed_text(self):
258        """Test squeezing text before existing squeezed text."""
259        editwin = self.make_mock_editor_window(with_text_widget=True)
260        text_widget = editwin.text
261        squeezer = self.make_squeezer_instance(editwin)
262        squeezer.count_lines = Mock(return_value=6)
263
264        # Prepare some text in the Text widget and squeeze it.
265        text_widget.insert("1.0", "SOME\nTEXT\n", "stdout")
266        text_widget.mark_set("insert", "1.0")
267        squeezer.squeeze_current_text_event(event=Mock())
268        self.assertEqual(len(squeezer.expandingbuttons), 1)
269
270        # Test squeezing the current text.
271        text_widget.insert("1.0", "MORE\nSTUFF\n", "stdout")
272        text_widget.mark_set("insert", "1.0")
273        retval = squeezer.squeeze_current_text_event(event=Mock())
274        self.assertEqual(retval, "break")
275        self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n')
276        self.assertEqual(len(squeezer.expandingbuttons), 2)
277        self.assertTrue(text_widget.compare(
278            squeezer.expandingbuttons[0],
279            '<',
280            squeezer.expandingbuttons[1],
281        ))
282
283    def test_reload(self):
284        """Test the reload() class-method."""
285        editwin = self.make_mock_editor_window(with_text_widget=True)
286        squeezer = self.make_squeezer_instance(editwin)
287
288        orig_auto_squeeze_min_lines = squeezer.auto_squeeze_min_lines
289
290        # Increase auto-squeeze-min-lines.
291        new_auto_squeeze_min_lines = orig_auto_squeeze_min_lines + 10
292        self.set_idleconf_option_with_cleanup(
293            'main', 'PyShell', 'auto-squeeze-min-lines',
294            str(new_auto_squeeze_min_lines))
295
296        Squeezer.reload()
297        self.assertEqual(squeezer.auto_squeeze_min_lines,
298                         new_auto_squeeze_min_lines)
299
300    def test_reload_no_squeezer_instances(self):
301        """Test that Squeezer.reload() runs without any instances existing."""
302        Squeezer.reload()
303
304
305class ExpandingButtonTest(unittest.TestCase):
306    """Tests for the ExpandingButton class."""
307    # In these tests the squeezer instance is a mock, but actual tkinter
308    # Text and Button instances are created.
309    def make_mock_squeezer(self):
310        """Helper for tests: Create a mock Squeezer object."""
311        root = get_test_tk_root(self)
312        squeezer = Mock()
313        squeezer.editwin.text = Text(root)
314
315        # Set default values for the configuration settings.
316        squeezer.auto_squeeze_min_lines = 50
317        return squeezer
318
319    @patch('idlelib.squeezer.Hovertip', autospec=Hovertip)
320    def test_init(self, MockHovertip):
321        """Test the simplest creation of an ExpandingButton."""
322        squeezer = self.make_mock_squeezer()
323        text_widget = squeezer.editwin.text
324
325        expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
326        self.assertEqual(expandingbutton.s, 'TEXT')
327
328        # Check that the underlying tkinter.Button is properly configured.
329        self.assertEqual(expandingbutton.master, text_widget)
330        self.assertTrue('50 lines' in expandingbutton.cget('text'))
331
332        # Check that the text widget still contains no text.
333        self.assertEqual(text_widget.get('1.0', 'end'), '\n')
334
335        # Check that the mouse events are bound.
336        self.assertIn('<Double-Button-1>', expandingbutton.bind())
337        right_button_code = '<Button-%s>' % ('2' if macosx.isAquaTk() else '3')
338        self.assertIn(right_button_code, expandingbutton.bind())
339
340        # Check that ToolTip was called once, with appropriate values.
341        self.assertEqual(MockHovertip.call_count, 1)
342        MockHovertip.assert_called_with(expandingbutton, ANY, hover_delay=ANY)
343
344        # Check that 'right-click' appears in the tooltip text.
345        tooltip_text = MockHovertip.call_args[0][1]
346        self.assertIn('right-click', tooltip_text.lower())
347
348    def test_expand(self):
349        """Test the expand event."""
350        squeezer = self.make_mock_squeezer()
351        expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
352
353        # Insert the button into the text widget
354        # (this is normally done by the Squeezer class).
355        text_widget = expandingbutton.text
356        text_widget.window_create("1.0", window=expandingbutton)
357
358        # Set base_text to the text widget, so that changes are actually
359        # made to it (by ExpandingButton) and we can inspect these
360        # changes afterwards.
361        expandingbutton.base_text = expandingbutton.text
362
363        # trigger the expand event
364        retval = expandingbutton.expand(event=Mock())
365        self.assertEqual(retval, None)
366
367        # Check that the text was inserted into the text widget.
368        self.assertEqual(text_widget.get('1.0', 'end'), 'TEXT\n')
369
370        # Check that the 'TAGS' tag was set on the inserted text.
371        text_end_index = text_widget.index('end-1c')
372        self.assertEqual(text_widget.get('1.0', text_end_index), 'TEXT')
373        self.assertEqual(text_widget.tag_nextrange('TAGS', '1.0'),
374                          ('1.0', text_end_index))
375
376        # Check that the button removed itself from squeezer.expandingbuttons.
377        self.assertEqual(squeezer.expandingbuttons.remove.call_count, 1)
378        squeezer.expandingbuttons.remove.assert_called_with(expandingbutton)
379
380    def test_expand_dangerous_oupput(self):
381        """Test that expanding very long output asks user for confirmation."""
382        squeezer = self.make_mock_squeezer()
383        text = 'a' * 10**5
384        expandingbutton = ExpandingButton(text, 'TAGS', 50, squeezer)
385        expandingbutton.set_is_dangerous()
386        self.assertTrue(expandingbutton.is_dangerous)
387
388        # Insert the button into the text widget
389        # (this is normally done by the Squeezer class).
390        text_widget = expandingbutton.text
391        text_widget.window_create("1.0", window=expandingbutton)
392
393        # Set base_text to the text widget, so that changes are actually
394        # made to it (by ExpandingButton) and we can inspect these
395        # changes afterwards.
396        expandingbutton.base_text = expandingbutton.text
397
398        # Patch the message box module to always return False.
399        with patch('idlelib.squeezer.messagebox') as mock_msgbox:
400            mock_msgbox.askokcancel.return_value = False
401            mock_msgbox.askyesno.return_value = False
402            # Trigger the expand event.
403            retval = expandingbutton.expand(event=Mock())
404
405        # Check that the event chain was broken and no text was inserted.
406        self.assertEqual(retval, 'break')
407        self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), '')
408
409        # Patch the message box module to always return True.
410        with patch('idlelib.squeezer.messagebox') as mock_msgbox:
411            mock_msgbox.askokcancel.return_value = True
412            mock_msgbox.askyesno.return_value = True
413            # Trigger the expand event.
414            retval = expandingbutton.expand(event=Mock())
415
416        # Check that the event chain wasn't broken and the text was inserted.
417        self.assertEqual(retval, None)
418        self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), text)
419
420    def test_copy(self):
421        """Test the copy event."""
422        # Testing with the actual clipboard proved problematic, so this
423        # test replaces the clipboard manipulation functions with mocks
424        # and checks that they are called appropriately.
425        squeezer = self.make_mock_squeezer()
426        expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
427        expandingbutton.clipboard_clear = Mock()
428        expandingbutton.clipboard_append = Mock()
429
430        # Trigger the copy event.
431        retval = expandingbutton.copy(event=Mock())
432        self.assertEqual(retval, None)
433
434        # Vheck that the expanding button called clipboard_clear() and
435        # clipboard_append('TEXT') once each.
436        self.assertEqual(expandingbutton.clipboard_clear.call_count, 1)
437        self.assertEqual(expandingbutton.clipboard_append.call_count, 1)
438        expandingbutton.clipboard_append.assert_called_with('TEXT')
439
440    def test_view(self):
441        """Test the view event."""
442        squeezer = self.make_mock_squeezer()
443        expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
444        expandingbutton.selection_own = Mock()
445
446        with patch('idlelib.squeezer.view_text', autospec=view_text)\
447                as mock_view_text:
448            # Trigger the view event.
449            expandingbutton.view(event=Mock())
450
451            # Check that the expanding button called view_text.
452            self.assertEqual(mock_view_text.call_count, 1)
453
454            # Check that the proper text was passed.
455            self.assertEqual(mock_view_text.call_args[0][2], 'TEXT')
456
457    def test_rmenu(self):
458        """Test the context menu."""
459        squeezer = self.make_mock_squeezer()
460        expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
461        with patch('tkinter.Menu') as mock_Menu:
462            mock_menu = Mock()
463            mock_Menu.return_value = mock_menu
464            mock_event = Mock()
465            mock_event.x = 10
466            mock_event.y = 10
467            expandingbutton.context_menu_event(event=mock_event)
468            self.assertEqual(mock_menu.add_command.call_count,
469                             len(expandingbutton.rmenu_specs))
470            for label, *data in expandingbutton.rmenu_specs:
471                mock_menu.add_command.assert_any_call(label=label, command=ANY)
472
473
474if __name__ == '__main__':
475    unittest.main(verbosity=2)
476