1import itertools
2import os
3import pydoc
4import string
5import sys
6
7from contextlib import contextmanager
8from curtsies.formatstringarray import (
9    fsarray,
10    assertFSArraysEqual,
11    assertFSArraysEqualIgnoringFormatting,
12)
13from curtsies.fmtfuncs import cyan, bold, green, yellow, on_magenta, red
14from unittest import mock
15
16from bpython.curtsiesfrontend.events import RefreshRequestEvent
17from bpython import config, inspection
18from bpython.curtsiesfrontend.repl import BaseRepl
19from bpython.curtsiesfrontend import replpainter
20from bpython.curtsiesfrontend.repl import (
21    INCONSISTENT_HISTORY_MSG,
22    CONTIGUITY_BROKEN_MSG,
23)
24from bpython.test import FixLanguageTestCase as TestCase, TEST_CONFIG
25
26
27def setup_config():
28    config_struct = config.Config(TEST_CONFIG)
29    config_struct.cli_suggestion_width = 1
30    return config_struct
31
32
33class ClearEnviron(TestCase):
34    @classmethod
35    def setUpClass(cls):
36        cls.mock_environ = mock.patch.dict(
37            "os.environ",
38            {
39                "LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
40                "LANG": os.environ.get("LANG", "C.UTF-8"),
41            },
42            clear=True,
43        )
44        cls.mock_environ.start()
45        TestCase.setUpClass()
46
47    @classmethod
48    def tearDownClass(cls):
49        cls.mock_environ.stop()
50        TestCase.tearDownClass()
51
52
53class CurtsiesPaintingTest(ClearEnviron):
54    def setUp(self):
55        class TestRepl(BaseRepl):
56            def _request_refresh(inner_self):
57                pass
58
59        self.repl = TestRepl(config=setup_config())
60        self.repl.height, self.repl.width = (5, 10)
61
62    @property
63    def locals(self):
64        return self.repl.coderunner.interp.locals
65
66    def assert_paint(self, screen, cursor_row_col):
67        array, cursor_pos = self.repl.paint()
68        assertFSArraysEqual(array, screen)
69        self.assertEqual(cursor_pos, cursor_row_col)
70
71    def assert_paint_ignoring_formatting(
72        self, screen, cursor_row_col=None, **paint_kwargs
73    ):
74        array, cursor_pos = self.repl.paint(**paint_kwargs)
75        assertFSArraysEqualIgnoringFormatting(array, screen)
76        if cursor_row_col is not None:
77            self.assertEqual(cursor_pos, cursor_row_col)
78
79    def process_box_characters(self, screen):
80        if not self.repl.config.unicode_box or not config.supports_box_chars():
81            return [
82                line.replace("┌", "+")
83                .replace("└", "+")
84                .replace("┘", "+")
85                .replace("┐", "+")
86                .replace("─", "-")
87                for line in screen
88            ]
89        return screen
90
91
92class TestCurtsiesPaintingTest(CurtsiesPaintingTest):
93    def test_history_is_cleared(self):
94        self.assertEqual(self.repl.rl_history.entries, [""])
95
96
97class TestCurtsiesPaintingSimple(CurtsiesPaintingTest):
98    def test_startup(self):
99        screen = fsarray([cyan(">>> "), cyan("Welcome to")])
100        self.assert_paint(screen, (0, 4))
101
102    def test_enter_text(self):
103        [self.repl.add_normal_character(c) for c in "1 + 1"]
104        screen = fsarray(
105            [
106                cyan(">>> ")
107                + bold(
108                    green("1")
109                    + cyan(" ")
110                    + yellow("+")
111                    + cyan(" ")
112                    + green("1")
113                ),
114                cyan("Welcome to"),
115            ]
116        )
117        self.assert_paint(screen, (0, 9))
118
119    def test_run_line(self):
120        try:
121            orig_stdout = sys.stdout
122            sys.stdout = self.repl.stdout
123            [self.repl.add_normal_character(c) for c in "1 + 1"]
124            self.repl.on_enter(new_code=False)
125            screen = fsarray([">>> 1 + 1", "2", "Welcome to"])
126            self.assert_paint_ignoring_formatting(screen, (1, 1))
127        finally:
128            sys.stdout = orig_stdout
129
130    def test_completion(self):
131        self.repl.height, self.repl.width = (5, 32)
132        self.repl.current_line = "an"
133        self.cursor_offset = 2
134        screen = self.process_box_characters(
135            [
136                ">>> an",
137                "┌──────────────────────────────┐",
138                "│ and  any(                    │",
139                "└──────────────────────────────┘",
140                "Welcome to bpython! Press <F1> f",
141            ]
142            if sys.version_info[:2] < (3, 10)
143            else [
144                ">>> an",
145                "┌──────────────────────────────┐",
146                "│ and    anext( any(           │",
147                "└──────────────────────────────┘",
148                "Welcome to bpython! Press <F1> f",
149            ]
150        )
151        self.assert_paint_ignoring_formatting(screen, (0, 4))
152
153    def test_argspec(self):
154        def foo(x, y, z=10):
155            "docstring!"
156            pass
157
158        argspec = inspection.getfuncprops("foo", foo)
159        array = replpainter.formatted_argspec(argspec, 1, 30, setup_config())
160        screen = [
161            bold(cyan("foo"))
162            + cyan(":")
163            + cyan(" ")
164            + cyan("(")
165            + cyan("x")
166            + yellow(",")
167            + yellow(" ")
168            + bold(cyan("y"))
169            + yellow(",")
170            + yellow(" ")
171            + cyan("z")
172            + yellow("=")
173            + bold(cyan("10"))
174            + yellow(")")
175        ]
176        assertFSArraysEqual(fsarray(array), fsarray(screen))
177
178    def test_formatted_docstring(self):
179        actual = replpainter.formatted_docstring(
180            "Returns the results\n\n" "Also has side effects",
181            40,
182            config=setup_config(),
183        )
184        expected = fsarray(["Returns the results", "", "Also has side effects"])
185        assertFSArraysEqualIgnoringFormatting(actual, expected)
186
187    def test_unicode_docstrings(self):
188        "A bit of a special case in Python 2"
189        # issue 653
190
191        def foo():
192            "åß∂ƒ"
193
194        actual = replpainter.formatted_docstring(
195            foo.__doc__, 40, config=setup_config()
196        )
197        expected = fsarray(["åß∂ƒ"])
198        assertFSArraysEqualIgnoringFormatting(actual, expected)
199
200    def test_nonsense_docstrings(self):
201        for docstring in [
202            123,
203            {},
204            [],
205        ]:
206            try:
207                replpainter.formatted_docstring(
208                    docstring, 40, config=setup_config()
209                )
210            except Exception:
211                self.fail(f"bad docstring caused crash: {docstring!r}")
212
213    def test_weird_boto_docstrings(self):
214        # Boto does something like this.
215        # botocore: botocore/docs/docstring.py
216        class WeirdDocstring(str):
217            # a mighty hack. See botocore/docs/docstring.py
218            def expandtabs(self, tabsize=8):
219                return "asdfåß∂ƒ".expandtabs(tabsize)
220
221        def foo():
222            pass
223
224        foo.__doc__ = WeirdDocstring()
225        wd = pydoc.getdoc(foo)
226        actual = replpainter.formatted_docstring(wd, 40, config=setup_config())
227        expected = fsarray(["asdfåß∂ƒ"])
228        assertFSArraysEqualIgnoringFormatting(actual, expected)
229
230    def test_paint_lasts_events(self):
231        actual = replpainter.paint_last_events(
232            4, 100, ["a", "b", "c"], config=setup_config()
233        )
234        if config.supports_box_chars():
235            expected = fsarray(["┌─┐", "│c│", "│b│", "└─┘"])
236        else:
237            expected = fsarray(["+-+", "|c|", "|b|", "+-+"])
238
239        assertFSArraysEqualIgnoringFormatting(actual, expected)
240
241
242@contextmanager
243def output_to_repl(repl):
244    old_out, old_err = sys.stdout, sys.stderr
245    try:
246        sys.stdout, sys.stderr = repl.stdout, repl.stderr
247        yield
248    finally:
249        sys.stdout, sys.stderr = old_out, old_err
250
251
252class HigherLevelCurtsiesPaintingTest(CurtsiesPaintingTest):
253    def refresh(self):
254        self.refresh_requests.append(RefreshRequestEvent())
255
256    def send_refreshes(self):
257        while self.refresh_requests:
258            self.repl.process_event(self.refresh_requests.pop())
259            _, _ = self.repl.paint()
260
261    def enter(self, line=None):
262        """Enter a line of text, avoiding autocompletion windows
263
264        autocomplete could still happen if the entered line has
265        autocompletion that would happen then, but intermediate
266        stages won't happen"""
267        if line is not None:
268            self.repl._set_cursor_offset(len(line), update_completion=False)
269            self.repl.current_line = line
270        with output_to_repl(self.repl):
271            self.repl.on_enter(new_code=False)
272            self.assertEqual(self.repl.rl_history.entries, [""])
273            self.send_refreshes()
274
275    def undo(self):
276        with output_to_repl(self.repl):
277            self.repl.undo()
278            self.send_refreshes()
279
280    def setUp(self):
281        self.refresh_requests = []
282
283        class TestRepl(BaseRepl):
284            def _request_refresh(inner_self):
285                self.refresh()
286
287        self.repl = TestRepl(banner="", config=setup_config())
288        self.repl.height, self.repl.width = (5, 32)
289
290    def send_key(self, key):
291        self.repl.process_event("<SPACE>" if key == " " else key)
292        self.repl.paint()  # has some side effects we need to be wary of
293
294
295class TestWidthAwareness(HigherLevelCurtsiesPaintingTest):
296    def test_cursor_position_with_fullwidth_char(self):
297        self.repl.add_normal_character("間")
298
299        cursor_pos = self.repl.paint()[1]
300        self.assertEqual(cursor_pos, (0, 6))
301
302    def test_cursor_position_with_padding_char(self):
303        # odd numbered so fullwidth chars don't wrap evenly
304        self.repl.width = 11
305        [self.repl.add_normal_character(c) for c in "width"]
306
307        cursor_pos = self.repl.paint()[1]
308        self.assertEqual(cursor_pos, (1, 4))
309
310    def test_display_of_padding_chars(self):
311        self.repl.width = 11
312        [self.repl.add_normal_character(c) for c in "width"]
313
314        self.enter()
315        expected = [">>> wid ", "th"]  # <--- note the added trailing space
316        result = [d.s for d in self.repl.display_lines[0:2]]
317        self.assertEqual(result, expected)
318
319
320class TestCurtsiesRewindRedraw(HigherLevelCurtsiesPaintingTest):
321    def test_rewind(self):
322        self.repl.current_line = "1 + 1"
323        self.enter()
324        screen = [">>> 1 + 1", "2", ">>> "]
325        self.assert_paint_ignoring_formatting(screen, (2, 4))
326        self.repl.undo()
327        screen = [">>> "]
328        self.assert_paint_ignoring_formatting(screen, (0, 4))
329
330    def test_rewind_contiguity_loss(self):
331        self.enter("1 + 1")
332        self.enter("2 + 2")
333        self.enter("def foo(x):")
334        self.repl.current_line = "    return x + 1"
335        screen = [
336            ">>> 1 + 1",
337            "2",
338            ">>> 2 + 2",
339            "4",
340            ">>> def foo(x):",
341            "...     return x + 1",
342        ]
343        self.assert_paint_ignoring_formatting(screen, (5, 8))
344        self.repl.scroll_offset = 1
345        self.assert_paint_ignoring_formatting(screen[1:], (4, 8))
346        self.undo()
347        screen = ["2", ">>> 2 + 2", "4", ">>> "]
348        self.assert_paint_ignoring_formatting(screen, (3, 4))
349        self.undo()
350        screen = ["2", ">>> "]
351        self.assert_paint_ignoring_formatting(screen, (1, 4))
352        self.undo()
353        screen = [
354            CONTIGUITY_BROKEN_MSG[: self.repl.width],
355            ">>> ",
356            "",
357            "",
358            "",
359            " ",
360        ]  # TODO why is that there? Necessary?
361        self.assert_paint_ignoring_formatting(screen, (1, 4))
362        screen = [">>> "]
363        self.assert_paint_ignoring_formatting(screen, (0, 4))
364
365    def test_inconsistent_history_doesnt_happen_if_onscreen(self):
366        self.enter("1 + 1")
367        screen = [">>> 1 + 1", "2", ">>> "]
368        self.assert_paint_ignoring_formatting(screen, (2, 4))
369        self.enter("2 + 2")
370        screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> "]
371        self.assert_paint_ignoring_formatting(screen, (4, 4))
372        self.repl.display_lines[0] = self.repl.display_lines[0] * 2
373        self.undo()
374        screen = [">>> 1 + 1", "2", ">>> "]
375        self.assert_paint_ignoring_formatting(screen, (2, 4))
376
377    def test_rewind_inconsistent_history(self):
378        self.enter("1 + 1")
379        self.enter("2 + 2")
380        self.enter("3 + 3")
381        screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> 3 + 3", "6", ">>> "]
382        self.assert_paint_ignoring_formatting(screen, (6, 4))
383        self.repl.scroll_offset += len(screen) - self.repl.height
384        self.assert_paint_ignoring_formatting(screen[2:], (4, 4))
385        self.repl.display_lines[0] = self.repl.display_lines[0] * 2
386        self.undo()
387        screen = [
388            INCONSISTENT_HISTORY_MSG[: self.repl.width],
389            ">>> 2 + 2",
390            "4",
391            ">>> ",
392            "",
393            " ",
394        ]
395        self.assert_paint_ignoring_formatting(screen, (3, 4))
396        self.repl.scroll_offset += len(screen) - self.repl.height
397        self.assert_paint_ignoring_formatting(screen[1:-2], (2, 4))
398        self.assert_paint_ignoring_formatting(screen[1:-2], (2, 4))
399
400    def test_rewind_inconsistent_history_more_lines_same_screen(self):
401        self.repl.width = 60
402        sys.a = 5
403        self.enter("import sys")
404        self.enter("for i in range(sys.a):")
405        self.enter("    print(sys.a)")
406        self.enter("")
407        self.enter("1 + 1")
408        self.enter("2 + 2")
409        screen = [
410            ">>> import sys",
411            ">>> for i in range(sys.a):",
412            "...     print(sys.a)",
413            "... ",
414            "5",
415            "5",
416            "5",
417            "5",
418            "5",
419            ">>> 1 + 1",
420            "2",
421            ">>> 2 + 2",
422            "4",
423            ">>> ",
424        ]
425        self.assert_paint_ignoring_formatting(screen, (13, 4))
426        self.repl.scroll_offset += len(screen) - self.repl.height
427        self.assert_paint_ignoring_formatting(screen[9:], (4, 4))
428        sys.a = 6
429        self.undo()
430        screen = [
431            INCONSISTENT_HISTORY_MSG[: self.repl.width],
432            "6",
433            # everything will jump down a line - that's perfectly
434            # reasonable
435            ">>> 1 + 1",
436            "2",
437            ">>> ",
438            " ",
439        ]
440        self.assert_paint_ignoring_formatting(screen, (4, 4))
441        self.repl.scroll_offset += len(screen) - self.repl.height
442        self.assert_paint_ignoring_formatting(screen[1:-1], (3, 4))
443
444    def test_rewind_inconsistent_history_more_lines_lower_screen(self):
445        self.repl.width = 60
446        sys.a = 5
447        self.enter("import sys")
448        self.enter("for i in range(sys.a):")
449        self.enter("    print(sys.a)")
450        self.enter("")
451        self.enter("1 + 1")
452        self.enter("2 + 2")
453        screen = [
454            ">>> import sys",
455            ">>> for i in range(sys.a):",
456            "...     print(sys.a)",
457            "... ",
458            "5",
459            "5",
460            "5",
461            "5",
462            "5",
463            ">>> 1 + 1",
464            "2",
465            ">>> 2 + 2",
466            "4",
467            ">>> ",
468        ]
469        self.assert_paint_ignoring_formatting(screen, (13, 4))
470        self.repl.scroll_offset += len(screen) - self.repl.height
471        self.assert_paint_ignoring_formatting(screen[9:], (4, 4))
472        sys.a = 8
473        self.undo()
474        screen = [
475            INCONSISTENT_HISTORY_MSG[: self.repl.width],
476            "8",
477            "8",
478            "8",
479            ">>> 1 + 1",
480            "2",
481            ">>> ",
482        ]
483        self.assert_paint_ignoring_formatting(screen)
484        self.repl.scroll_offset += len(screen) - self.repl.height
485        self.assert_paint_ignoring_formatting(screen[-5:])
486
487    def test_rewind_inconsistent_history_more_lines_raise_screen(self):
488        self.repl.width = 60
489        sys.a = 5
490        self.enter("import sys")
491        self.enter("for i in range(sys.a):")
492        self.enter("    print(sys.a)")
493        self.enter("")
494        self.enter("1 + 1")
495        self.enter("2 + 2")
496        screen = [
497            ">>> import sys",
498            ">>> for i in range(sys.a):",
499            "...     print(sys.a)",
500            "... ",
501            "5",
502            "5",
503            "5",
504            "5",
505            "5",
506            ">>> 1 + 1",
507            "2",
508            ">>> 2 + 2",
509            "4",
510            ">>> ",
511        ]
512        self.assert_paint_ignoring_formatting(screen, (13, 4))
513        self.repl.scroll_offset += len(screen) - self.repl.height
514        self.assert_paint_ignoring_formatting(screen[9:], (4, 4))
515        sys.a = 1
516        self.undo()
517        screen = [
518            INCONSISTENT_HISTORY_MSG[: self.repl.width],
519            "1",
520            ">>> 1 + 1",
521            "2",
522            ">>> ",
523            " ",
524        ]
525        self.assert_paint_ignoring_formatting(screen)
526        self.repl.scroll_offset += len(screen) - self.repl.height
527        self.assert_paint_ignoring_formatting(screen[1:-1])
528
529    def test_rewind_history_not_quite_inconsistent(self):
530        self.repl.width = 50
531        sys.a = 5
532        self.enter("for i in range(__import__('sys').a):")
533        self.enter("    print(i)")
534        self.enter("")
535        self.enter("1 + 1")
536        self.enter("2 + 2")
537        screen = [
538            ">>> for i in range(__import__('sys').a):",
539            "...     print(i)",
540            "... ",
541            "0",
542            "1",
543            "2",
544            "3",
545            "4",
546            ">>> 1 + 1",
547            "2",
548            ">>> 2 + 2",
549            "4",
550            ">>> ",
551        ]
552        self.assert_paint_ignoring_formatting(screen, (12, 4))
553        self.repl.scroll_offset += len(screen) - self.repl.height
554        self.assert_paint_ignoring_formatting(screen[8:], (4, 4))
555        sys.a = 6
556        self.undo()
557        screen = [
558            "5",
559            # everything will jump down a line - that's perfectly
560            # reasonable
561            ">>> 1 + 1",
562            "2",
563            ">>> ",
564        ]
565        self.assert_paint_ignoring_formatting(screen, (3, 4))
566
567    def test_rewind_barely_consistent(self):
568        self.enter("1 + 1")
569        self.enter("2 + 2")
570        self.enter("3 + 3")
571        screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> 3 + 3", "6", ">>> "]
572        self.assert_paint_ignoring_formatting(screen, (6, 4))
573        self.repl.scroll_offset += len(screen) - self.repl.height
574        self.assert_paint_ignoring_formatting(screen[2:], (4, 4))
575        self.repl.display_lines[2] = self.repl.display_lines[2] * 2
576        self.undo()
577        screen = [">>> 2 + 2", "4", ">>> "]
578        self.assert_paint_ignoring_formatting(screen, (2, 4))
579
580    def test_clear_screen(self):
581        self.enter("1 + 1")
582        self.enter("2 + 2")
583        screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> "]
584        self.assert_paint_ignoring_formatting(screen, (4, 4))
585        self.repl.request_paint_to_clear_screen = True
586        screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> ", "", "", "", ""]
587        self.assert_paint_ignoring_formatting(screen, (4, 4))
588
589    def test_scroll_down_while_banner_visible(self):
590        self.repl.status_bar.message("STATUS_BAR")
591        self.enter("1 + 1")
592        self.enter("2 + 2")
593        screen = [
594            ">>> 1 + 1",
595            "2",
596            ">>> 2 + 2",
597            "4",
598            ">>> ",
599            "STATUS_BAR                      ",
600        ]
601        self.assert_paint_ignoring_formatting(screen, (4, 4))
602        self.repl.scroll_offset += len(screen) - self.repl.height
603        self.assert_paint_ignoring_formatting(screen[1:], (3, 4))
604
605    def test_clear_screen_while_banner_visible(self):
606        self.repl.status_bar.message("STATUS_BAR")
607        self.enter("1 + 1")
608        self.enter("2 + 2")
609        screen = [
610            ">>> 1 + 1",
611            "2",
612            ">>> 2 + 2",
613            "4",
614            ">>> ",
615            "STATUS_BAR                      ",
616        ]
617        self.assert_paint_ignoring_formatting(screen, (4, 4))
618        self.repl.scroll_offset += len(screen) - self.repl.height
619        self.assert_paint_ignoring_formatting(screen[1:], (3, 4))
620
621        self.repl.request_paint_to_clear_screen = True
622        screen = [
623            "2",
624            ">>> 2 + 2",
625            "4",
626            ">>> ",
627            "",
628            "",
629            "",
630            "STATUS_BAR                      ",
631        ]
632        self.assert_paint_ignoring_formatting(screen, (3, 4))
633
634    def test_cursor_stays_at_bottom_of_screen(self):
635        """infobox showing up during intermediate render was causing this to
636        fail, #371"""
637        self.repl.width = 50
638        self.repl.current_line = "__import__('random').__name__"
639        with output_to_repl(self.repl):
640            self.repl.on_enter(new_code=False)
641        screen = [">>> __import__('random').__name__", "'random'"]
642        self.assert_paint_ignoring_formatting(screen)
643
644        with output_to_repl(self.repl):
645            self.repl.process_event(self.refresh_requests.pop())
646        screen = [">>> __import__('random').__name__", "'random'", ""]
647        self.assert_paint_ignoring_formatting(screen)
648
649        with output_to_repl(self.repl):
650            self.repl.process_event(self.refresh_requests.pop())
651        screen = [">>> __import__('random').__name__", "'random'", ">>> "]
652        self.assert_paint_ignoring_formatting(screen, (2, 4))
653
654    def test_unhighlight_paren_bugs(self):
655        """two previous bugs, parent didn't highlight until next render
656        and paren didn't unhighlight until enter"""
657        self.repl.width = 32
658        self.assertEqual(self.repl.rl_history.entries, [""])
659        self.enter("(")
660        self.assertEqual(self.repl.rl_history.entries, [""])
661        screen = [">>> (", "... "]
662        self.assertEqual(self.repl.rl_history.entries, [""])
663        self.assert_paint_ignoring_formatting(screen)
664        self.assertEqual(self.repl.rl_history.entries, [""])
665
666        with output_to_repl(self.repl):
667            self.assertEqual(self.repl.rl_history.entries, [""])
668            self.repl.process_event(")")
669            self.assertEqual(self.repl.rl_history.entries, [""])
670        screen = fsarray(
671            [
672                cyan(">>> ") + on_magenta(bold(red("("))),
673                green("... ") + on_magenta(bold(red(")"))),
674            ],
675            width=32,
676        )
677        self.assert_paint(screen, (1, 5))
678
679        with output_to_repl(self.repl):
680            self.repl.process_event(" ")
681        screen = fsarray(
682            [
683                cyan(">>> ") + yellow("("),
684                green("... ") + yellow(")") + bold(cyan(" ")),
685            ],
686            width=32,
687        )
688        self.assert_paint(screen, (1, 6))
689
690    def test_472(self):
691        [self.send_key(c) for c in "(1, 2, 3)"]
692        with output_to_repl(self.repl):
693            self.send_key("\n")
694            self.send_refreshes()
695            self.send_key("<UP>")
696            self.repl.paint()
697            [self.send_key("<LEFT>") for _ in range(4)]
698            self.send_key("<BACKSPACE>")
699            self.send_key("4")
700            self.repl.on_enter()
701            self.send_refreshes()
702        screen = [
703            ">>> (1, 2, 3)",
704            "(1, 2, 3)",
705            ">>> (1, 4, 3)",
706            "(1, 4, 3)",
707            ">>> ",
708        ]
709        self.assert_paint_ignoring_formatting(screen, (4, 4))
710
711
712def completion_target(num_names, chars_in_first_name=1):
713    class Class:
714        pass
715
716    if chars_in_first_name < 1:
717        raise ValueError("need at least one char in each name")
718    elif chars_in_first_name == 1 and num_names > len(string.ascii_letters):
719        raise ValueError("need more chars to make so many names")
720
721    names = gen_names()
722    if num_names > 0:
723        setattr(Class, "a" * chars_in_first_name, 1)
724        next(names)  # use the above instead of first name
725    for _, name in zip(range(num_names - 1), names):
726        setattr(Class, name, 0)
727
728    return Class()
729
730
731def gen_names():
732    for letters in itertools.chain(
733        itertools.combinations_with_replacement(string.ascii_letters, 1),
734        itertools.combinations_with_replacement(string.ascii_letters, 2),
735    ):
736        yield "".join(letters)
737
738
739class TestCompletionHelpers(TestCase):
740    def test_gen_names(self):
741        self.assertEqual(
742            list(zip([1, 2, 3], gen_names())), [(1, "a"), (2, "b"), (3, "c")]
743        )
744
745    def test_completion_target(self):
746        target = completion_target(14)
747        self.assertEqual(
748            len([x for x in dir(target) if not x.startswith("_")]), 14
749        )
750
751
752class TestCurtsiesInfoboxPaint(HigherLevelCurtsiesPaintingTest):
753    def test_simple(self):
754        self.repl.width, self.repl.height = (20, 30)
755        self.locals["abc"] = completion_target(3, 50)
756        self.repl.current_line = "abc"
757        self.repl.cursor_offset = 3
758        self.repl.process_event(".")
759        screen = self.process_box_characters(
760            [
761                ">>> abc.",
762                "┌──────────────────┐",
763                "│ aaaaaaaaaaaaaaaa │",
764                "│ b                │",
765                "│ c                │",
766                "└──────────────────┘",
767            ]
768        )
769        self.assert_paint_ignoring_formatting(screen, (0, 8))
770
771    def test_fill_screen(self):
772        self.repl.width, self.repl.height = (20, 15)
773        self.locals["abc"] = completion_target(20, 100)
774        self.repl.current_line = "abc"
775        self.repl.cursor_offset = 3
776        self.repl.process_event(".")
777        screen = self.process_box_characters(
778            [
779                ">>> abc.",
780                "┌──────────────────┐",
781                "│ aaaaaaaaaaaaaaaa │",
782                "│ b                │",
783                "│ c                │",
784                "│ d                │",
785                "│ e                │",
786                "│ f                │",
787                "│ g                │",
788                "│ h                │",
789                "│ i                │",
790                "│ j                │",
791                "│ k                │",
792                "│ l                │",
793                "└──────────────────┘",
794            ]
795        )
796        self.assert_paint_ignoring_formatting(screen, (0, 8))
797
798    def test_lower_on_screen(self):
799        self.repl.get_top_usable_line = lambda: 10  # halfway down terminal
800        self.repl.width, self.repl.height = (20, 15)
801        self.locals["abc"] = completion_target(20, 100)
802        self.repl.current_line = "abc"
803        self.repl.cursor_offset = 3
804        self.repl.process_event(".")
805        screen = self.process_box_characters(
806            [
807                ">>> abc.",
808                "┌──────────────────┐",
809                "│ aaaaaaaaaaaaaaaa │",
810                "│ b                │",
811                "│ c                │",
812                "│ d                │",
813                "│ e                │",
814                "│ f                │",
815                "│ g                │",
816                "│ h                │",
817                "│ i                │",
818                "│ j                │",
819                "│ k                │",
820                "│ l                │",
821                "└──────────────────┘",
822            ]
823        )
824        # behavior before issue #466
825        self.assert_paint_ignoring_formatting(
826            screen, try_preserve_history_height=0
827        )
828        self.assert_paint_ignoring_formatting(screen, min_infobox_height=100)
829        # behavior after issue #466
830        screen = self.process_box_characters(
831            [
832                ">>> abc.",
833                "┌──────────────────┐",
834                "│ aaaaaaaaaaaaaaaa │",
835                "│ b                │",
836                "│ c                │",
837                "└──────────────────┘",
838            ]
839        )
840        self.assert_paint_ignoring_formatting(screen)
841
842    def test_at_bottom_of_screen(self):
843        self.repl.get_top_usable_line = lambda: 17  # two lines from bottom
844        self.repl.width, self.repl.height = (20, 15)
845        self.locals["abc"] = completion_target(20, 100)
846        self.repl.current_line = "abc"
847        self.repl.cursor_offset = 3
848        self.repl.process_event(".")
849        screen = self.process_box_characters(
850            [
851                ">>> abc.",
852                "┌──────────────────┐",
853                "│ aaaaaaaaaaaaaaaa │",
854                "│ b                │",
855                "│ c                │",
856                "│ d                │",
857                "│ e                │",
858                "│ f                │",
859                "│ g                │",
860                "│ h                │",
861                "│ i                │",
862                "│ j                │",
863                "│ k                │",
864                "│ l                │",
865                "└──────────────────┘",
866            ]
867        )
868        # behavior before issue #466
869        self.assert_paint_ignoring_formatting(
870            screen, try_preserve_history_height=0
871        )
872        self.assert_paint_ignoring_formatting(screen, min_infobox_height=100)
873        # behavior after issue #466
874        screen = self.process_box_characters(
875            [
876                ">>> abc.",
877                "┌──────────────────┐",
878                "│ aaaaaaaaaaaaaaaa │",
879                "│ b                │",
880                "│ c                │",
881                "└──────────────────┘",
882            ]
883        )
884        self.assert_paint_ignoring_formatting(screen)
885