"""Test sidebar, coverage 85%""" from textwrap import dedent import sys from itertools import chain import unittest import unittest.mock from test.support import requires, swap_attr from test import support import tkinter as tk from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop from idlelib.delegator import Delegator from idlelib.editor import fixwordbreaks from idlelib.percolator import Percolator import idlelib.pyshell from idlelib.pyshell import fix_x11_paste, PyShell, PyShellFileList from idlelib.run import fix_scaling import idlelib.sidebar from idlelib.sidebar import get_end_linenumber, get_lineno class Dummy_editwin: def __init__(self, text): self.text = text self.text_frame = self.text.master self.per = Percolator(text) self.undo = Delegator() self.per.insertfilter(self.undo) def setvar(self, name, value): pass def getlineno(self, index): return int(float(self.text.index(index))) class LineNumbersTest(unittest.TestCase): @classmethod def setUpClass(cls): requires('gui') cls.root = tk.Tk() cls.root.withdraw() cls.text_frame = tk.Frame(cls.root) cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) cls.text_frame.rowconfigure(1, weight=1) cls.text_frame.columnconfigure(1, weight=1) cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE) cls.text.grid(row=1, column=1, sticky=tk.NSEW) cls.editwin = Dummy_editwin(cls.text) cls.editwin.vbar = tk.Scrollbar(cls.text_frame) @classmethod def tearDownClass(cls): cls.editwin.per.close() cls.root.update_idletasks() cls.root.destroy() del cls.text, cls.text_frame, cls.editwin, cls.root def setUp(self): self.linenumber = idlelib.sidebar.LineNumbers(self.editwin) self.highlight_cfg = {"background": '#abcdef', "foreground": '#123456'} orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight def mock_idleconf_GetHighlight(theme, element): if element == 'linenumber': return self.highlight_cfg return orig_idleConf_GetHighlight(theme, element) GetHighlight_patcher = unittest.mock.patch.object( idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight) GetHighlight_patcher.start() self.addCleanup(GetHighlight_patcher.stop) self.font_override = 'TkFixedFont' def mock_idleconf_GetFont(root, configType, section): return self.font_override GetFont_patcher = unittest.mock.patch.object( idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) GetFont_patcher.start() self.addCleanup(GetFont_patcher.stop) def tearDown(self): self.text.delete('1.0', 'end') def get_selection(self): return tuple(map(str, self.text.tag_ranges('sel'))) def get_line_screen_position(self, line): bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c') x = bbox[0] + 2 y = bbox[1] + 2 return x, y def assert_state_disabled(self): state = self.linenumber.sidebar_text.config()['state'] self.assertEqual(state[-1], tk.DISABLED) def get_sidebar_text_contents(self): return self.linenumber.sidebar_text.get('1.0', tk.END) def assert_sidebar_n_lines(self, n_lines): expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), [''])) self.assertEqual(self.get_sidebar_text_contents(), expected) def assert_text_equals(self, expected): return self.assertEqual(self.text.get('1.0', 'end'), expected) def test_init_empty(self): self.assert_sidebar_n_lines(1) def test_init_not_empty(self): self.text.insert('insert', 'foo bar\n'*3) self.assert_text_equals('foo bar\n'*3 + '\n') self.assert_sidebar_n_lines(4) def test_toggle_linenumbering(self): self.assertEqual(self.linenumber.is_shown, False) self.linenumber.show_sidebar() self.assertEqual(self.linenumber.is_shown, True) self.linenumber.hide_sidebar() self.assertEqual(self.linenumber.is_shown, False) self.linenumber.hide_sidebar() self.assertEqual(self.linenumber.is_shown, False) self.linenumber.show_sidebar() self.assertEqual(self.linenumber.is_shown, True) self.linenumber.show_sidebar() self.assertEqual(self.linenumber.is_shown, True) def test_insert(self): self.text.insert('insert', 'foobar') self.assert_text_equals('foobar\n') self.assert_sidebar_n_lines(1) self.assert_state_disabled() self.text.insert('insert', '\nfoo') self.assert_text_equals('foobar\nfoo\n') self.assert_sidebar_n_lines(2) self.assert_state_disabled() self.text.insert('insert', 'hello\n'*2) self.assert_text_equals('foobar\nfoohello\nhello\n\n') self.assert_sidebar_n_lines(4) self.assert_state_disabled() self.text.insert('insert', '\nworld') self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n') self.assert_sidebar_n_lines(5) self.assert_state_disabled() def test_delete(self): self.text.insert('insert', 'foobar') self.assert_text_equals('foobar\n') self.text.delete('1.1', '1.3') self.assert_text_equals('fbar\n') self.assert_sidebar_n_lines(1) self.assert_state_disabled() self.text.insert('insert', 'foo\n'*2) self.assert_text_equals('fbarfoo\nfoo\n\n') self.assert_sidebar_n_lines(3) self.assert_state_disabled() # Deleting up to "2.end" doesn't delete the final newline. self.text.delete('2.0', '2.end') self.assert_text_equals('fbarfoo\n\n\n') self.assert_sidebar_n_lines(3) self.assert_state_disabled() self.text.delete('1.3', 'end') self.assert_text_equals('fba\n') self.assert_sidebar_n_lines(1) self.assert_state_disabled() # Text widgets always keep a single '\n' character at the end. self.text.delete('1.0', 'end') self.assert_text_equals('\n') self.assert_sidebar_n_lines(1) self.assert_state_disabled() def test_sidebar_text_width(self): """ Test that linenumber text widget is always at the minimum width """ def get_width(): return self.linenumber.sidebar_text.config()['width'][-1] self.assert_sidebar_n_lines(1) self.assertEqual(get_width(), 1) self.text.insert('insert', 'foo') self.assert_sidebar_n_lines(1) self.assertEqual(get_width(), 1) self.text.insert('insert', 'foo\n'*8) self.assert_sidebar_n_lines(9) self.assertEqual(get_width(), 1) self.text.insert('insert', 'foo\n') self.assert_sidebar_n_lines(10) self.assertEqual(get_width(), 2) self.text.insert('insert', 'foo\n') self.assert_sidebar_n_lines(11) self.assertEqual(get_width(), 2) self.text.delete('insert -1l linestart', 'insert linestart') self.assert_sidebar_n_lines(10) self.assertEqual(get_width(), 2) self.text.delete('insert -1l linestart', 'insert linestart') self.assert_sidebar_n_lines(9) self.assertEqual(get_width(), 1) self.text.insert('insert', 'foo\n'*90) self.assert_sidebar_n_lines(99) self.assertEqual(get_width(), 2) self.text.insert('insert', 'foo\n') self.assert_sidebar_n_lines(100) self.assertEqual(get_width(), 3) self.text.insert('insert', 'foo\n') self.assert_sidebar_n_lines(101) self.assertEqual(get_width(), 3) self.text.delete('insert -1l linestart', 'insert linestart') self.assert_sidebar_n_lines(100) self.assertEqual(get_width(), 3) self.text.delete('insert -1l linestart', 'insert linestart') self.assert_sidebar_n_lines(99) self.assertEqual(get_width(), 2) self.text.delete('50.0 -1c', 'end -1c') self.assert_sidebar_n_lines(49) self.assertEqual(get_width(), 2) self.text.delete('5.0 -1c', 'end -1c') self.assert_sidebar_n_lines(4) self.assertEqual(get_width(), 1) # Text widgets always keep a single '\n' character at the end. self.text.delete('1.0', 'end -1c') self.assert_sidebar_n_lines(1) self.assertEqual(get_width(), 1) # The following tests are temporarily disabled due to relying on # simulated user input and inspecting which text is selected, which # are fragile and can fail when several GUI tests are run in parallel # or when the windows created by the test lose focus. # # TODO: Re-work these tests or remove them from the test suite. @unittest.skip('test disabled') def test_click_selection(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'one\ntwo\nthree\nfour\n') self.root.update() # Click on the second line. x, y = self.get_line_screen_position(2) self.linenumber.sidebar_text.event_generate('', x=x, y=y) self.linenumber.sidebar_text.update() self.root.update() self.assertEqual(self.get_selection(), ('2.0', '3.0')) def simulate_drag(self, start_line, end_line): start_x, start_y = self.get_line_screen_position(start_line) end_x, end_y = self.get_line_screen_position(end_line) self.linenumber.sidebar_text.event_generate('', x=start_x, y=start_y) self.root.update() def lerp(a, b, steps): """linearly interpolate from a to b (inclusive) in equal steps""" last_step = steps - 1 for i in range(steps): yield ((last_step - i) / last_step) * a + (i / last_step) * b for x, y in zip( map(int, lerp(start_x, end_x, steps=11)), map(int, lerp(start_y, end_y, steps=11)), ): self.linenumber.sidebar_text.event_generate('', x=x, y=y) self.root.update() self.linenumber.sidebar_text.event_generate('', x=end_x, y=end_y) self.root.update() @unittest.skip('test disabled') def test_drag_selection_down(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') self.root.update() # Drag from the second line to the fourth line. self.simulate_drag(2, 4) self.assertEqual(self.get_selection(), ('2.0', '5.0')) @unittest.skip('test disabled') def test_drag_selection_up(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') self.root.update() # Drag from the fourth line to the second line. self.simulate_drag(4, 2) self.assertEqual(self.get_selection(), ('2.0', '5.0')) def test_scroll(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'line\n' * 100) self.root.update() # Scroll down 10 lines. self.text.yview_scroll(10, 'unit') self.root.update() self.assertEqual(self.text.index('@0,0'), '11.0') self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0') # Generate a mouse-wheel event and make sure it scrolled up or down. # The meaning of the "delta" is OS-dependent, so this just checks for # any change. self.linenumber.sidebar_text.event_generate('', x=0, y=0, delta=10) self.root.update() self.assertNotEqual(self.text.index('@0,0'), '11.0') self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0') def test_font(self): ln = self.linenumber orig_font = ln.sidebar_text['font'] test_font = 'TkTextFont' self.assertNotEqual(orig_font, test_font) # Ensure line numbers aren't shown. ln.hide_sidebar() self.font_override = test_font # Nothing breaks when line numbers aren't shown. ln.update_font() # Activate line numbers, previous font change is immediately effective. ln.show_sidebar() self.assertEqual(ln.sidebar_text['font'], test_font) # Call the font update with line numbers shown, change is picked up. self.font_override = orig_font ln.update_font() self.assertEqual(ln.sidebar_text['font'], orig_font) def test_highlight_colors(self): ln = self.linenumber orig_colors = dict(self.highlight_cfg) test_colors = {'background': '#222222', 'foreground': '#ffff00'} def assert_colors_are_equal(colors): self.assertEqual(ln.sidebar_text['background'], colors['background']) self.assertEqual(ln.sidebar_text['foreground'], colors['foreground']) # Ensure line numbers aren't shown. ln.hide_sidebar() self.highlight_cfg = test_colors # Nothing breaks with inactive line numbers. ln.update_colors() # Show line numbers, previous colors change is immediately effective. ln.show_sidebar() assert_colors_are_equal(test_colors) # Call colors update with no change to the configured colors. ln.update_colors() assert_colors_are_equal(test_colors) # Call the colors update with line numbers shown, change is picked up. self.highlight_cfg = orig_colors ln.update_colors() assert_colors_are_equal(orig_colors) class ShellSidebarTest(unittest.TestCase): root: tk.Tk = None shell: PyShell = None @classmethod def setUpClass(cls): requires('gui') cls.root = root = tk.Tk() root.withdraw() fix_scaling(root) fixwordbreaks(root) fix_x11_paste(root) cls.flist = flist = PyShellFileList(root) # See #43981 about macosx.setupApp(root, flist) causing failure. root.update_idletasks() cls.init_shell() @classmethod def tearDownClass(cls): if cls.shell is not None: cls.shell.executing = False cls.shell.close() cls.shell = None cls.flist = None cls.root.update_idletasks() cls.root.destroy() cls.root = None @classmethod def init_shell(cls): cls.shell = cls.flist.open_shell() cls.shell.pollinterval = 10 cls.root.update() cls.n_preface_lines = get_lineno(cls.shell.text, 'end-1c') - 1 @classmethod def reset_shell(cls): cls.shell.per.bottom.delete(f'{cls.n_preface_lines+1}.0', 'end-1c') cls.shell.shell_sidebar.update_sidebar() cls.root.update() def setUp(self): # In some test environments, e.g. Azure Pipelines (as of # Apr. 2021), sys.stdout is changed between tests. However, # PyShell relies on overriding sys.stdout when run without a # sub-process (as done here; see setUpClass). self._saved_stdout = None if sys.stdout != self.shell.stdout: self._saved_stdout = sys.stdout sys.stdout = self.shell.stdout self.reset_shell() def tearDown(self): if self._saved_stdout is not None: sys.stdout = self._saved_stdout def get_sidebar_lines(self): canvas = self.shell.shell_sidebar.canvas texts = list(canvas.find(tk.ALL)) texts_by_y_coords = { canvas.bbox(text)[1]: canvas.itemcget(text, 'text') for text in texts } line_y_coords = self.get_shell_line_y_coords() return [texts_by_y_coords.get(y, None) for y in line_y_coords] def assert_sidebar_lines_end_with(self, expected_lines): self.shell.shell_sidebar.update_sidebar() self.assertEqual( self.get_sidebar_lines()[-len(expected_lines):], expected_lines, ) def get_shell_line_y_coords(self): text = self.shell.text y_coords = [] index = text.index("@0,0") if index.split('.', 1)[1] != '0': index = text.index(f"{index} +1line linestart") while (lineinfo := text.dlineinfo(index)) is not None: y_coords.append(lineinfo[1]) index = text.index(f"{index} +1line") return y_coords def get_sidebar_line_y_coords(self): canvas = self.shell.shell_sidebar.canvas texts = list(canvas.find(tk.ALL)) texts.sort(key=lambda text: canvas.bbox(text)[1]) return [canvas.bbox(text)[1] for text in texts] def assert_sidebar_lines_synced(self): self.assertLessEqual( set(self.get_sidebar_line_y_coords()), set(self.get_shell_line_y_coords()), ) def do_input(self, input): shell = self.shell text = shell.text for line_index, line in enumerate(input.split('\n')): if line_index > 0: text.event_generate('<>') text.insert('insert', line, 'stdin') def test_initial_state(self): sidebar_lines = self.get_sidebar_lines() self.assertEqual( sidebar_lines, [None] * (len(sidebar_lines) - 1) + ['>>>'], ) self.assert_sidebar_lines_synced() @run_in_tk_mainloop() def test_single_empty_input(self): self.do_input('\n') yield self.assert_sidebar_lines_end_with(['>>>', '>>>']) @run_in_tk_mainloop() def test_single_line_statement(self): self.do_input('1\n') yield self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) @run_in_tk_mainloop() def test_multi_line_statement(self): # Block statements are not indented because IDLE auto-indents. self.do_input(dedent('''\ if True: print(1) ''')) yield self.assert_sidebar_lines_end_with([ '>>>', '...', '...', '...', None, '>>>', ]) @run_in_tk_mainloop() def test_single_long_line_wraps(self): self.do_input('1' * 200 + '\n') yield self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) self.assert_sidebar_lines_synced() @run_in_tk_mainloop() def test_squeeze_multi_line_output(self): shell = self.shell text = shell.text self.do_input('print("a\\nb\\nc")\n') yield self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) text.mark_set('insert', f'insert -1line linestart') text.event_generate('<>') yield self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) self.assert_sidebar_lines_synced() shell.squeezer.expandingbuttons[0].expand() yield self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) self.assert_sidebar_lines_synced() @run_in_tk_mainloop() def test_interrupt_recall_undo_redo(self): text = self.shell.text # Block statements are not indented because IDLE auto-indents. initial_sidebar_lines = self.get_sidebar_lines() self.do_input(dedent('''\ if True: print(1) ''')) yield self.assert_sidebar_lines_end_with(['>>>', '...', '...']) with_block_sidebar_lines = self.get_sidebar_lines() self.assertNotEqual(with_block_sidebar_lines, initial_sidebar_lines) # Control-C text.event_generate('<>') yield self.assert_sidebar_lines_end_with(['>>>', '...', '...', None, '>>>']) # Recall previous via history text.event_generate('<>') text.event_generate('<>') yield self.assert_sidebar_lines_end_with(['>>>', '...', None, '>>>']) # Recall previous via recall text.mark_set('insert', text.index('insert -2l')) text.event_generate('<>') yield text.event_generate('<>') yield self.assert_sidebar_lines_end_with(['>>>']) text.event_generate('<>') yield self.assert_sidebar_lines_end_with(['>>>', '...']) text.event_generate('<>') text.event_generate('<>') yield self.assert_sidebar_lines_end_with( ['>>>', '...', '...', '...', None, '>>>'] ) @run_in_tk_mainloop() def test_very_long_wrapped_line(self): with support.adjust_int_max_str_digits(11_111), \ swap_attr(self.shell, 'squeezer', None): self.do_input('x = ' + '1'*10_000 + '\n') yield self.assertEqual(self.get_sidebar_lines(), ['>>>']) def test_font(self): sidebar = self.shell.shell_sidebar test_font = 'TkTextFont' def mock_idleconf_GetFont(root, configType, section): return test_font GetFont_patcher = unittest.mock.patch.object( idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) GetFont_patcher.start() def cleanup(): GetFont_patcher.stop() sidebar.update_font() self.addCleanup(cleanup) def get_sidebar_font(): canvas = sidebar.canvas texts = list(canvas.find(tk.ALL)) fonts = {canvas.itemcget(text, 'font') for text in texts} self.assertEqual(len(fonts), 1) return next(iter(fonts)) self.assertNotEqual(get_sidebar_font(), test_font) sidebar.update_font() self.assertEqual(get_sidebar_font(), test_font) def test_highlight_colors(self): sidebar = self.shell.shell_sidebar test_colors = {"background": '#abcdef', "foreground": '#123456'} orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight def mock_idleconf_GetHighlight(theme, element): if element in ['linenumber', 'console']: return test_colors return orig_idleConf_GetHighlight(theme, element) GetHighlight_patcher = unittest.mock.patch.object( idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight) GetHighlight_patcher.start() def cleanup(): GetHighlight_patcher.stop() sidebar.update_colors() self.addCleanup(cleanup) def get_sidebar_colors(): canvas = sidebar.canvas texts = list(canvas.find(tk.ALL)) fgs = {canvas.itemcget(text, 'fill') for text in texts} self.assertEqual(len(fgs), 1) fg = next(iter(fgs)) bg = canvas.cget('background') return {"background": bg, "foreground": fg} self.assertNotEqual(get_sidebar_colors(), test_colors) sidebar.update_colors() self.assertEqual(get_sidebar_colors(), test_colors) @run_in_tk_mainloop() def test_mousewheel(self): sidebar = self.shell.shell_sidebar text = self.shell.text # Enter a 100-line string to scroll the shell screen down. self.do_input('x = """' + '\n'*100 + '"""\n') yield self.assertGreater(get_lineno(text, '@0,0'), 1) last_lineno = get_end_linenumber(text) self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) # Scroll up using the event. # The meaning of delta is platform-dependent. delta = -1 if sys.platform == 'darwin' else 120 sidebar.canvas.event_generate('', x=0, y=0, delta=delta) yield if sys.platform != 'darwin': # .update_idletasks() does not work. self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) # Scroll back down using the event. sidebar.canvas.event_generate('', x=0, y=0) yield self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) @run_in_tk_mainloop() def test_copy(self): sidebar = self.shell.shell_sidebar text = self.shell.text first_line = get_end_linenumber(text) self.do_input(dedent('''\ if True: print(1) ''')) yield text.tag_add('sel', f'{first_line}.0', 'end-1c') selected_text = text.get('sel.first', 'sel.last') self.assertTrue(selected_text.startswith('if True:\n')) self.assertIn('\n1\n', selected_text) text.event_generate('<>') self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() self.assertEqual(copied_text, selected_text) @run_in_tk_mainloop() def test_copy_with_prompts(self): sidebar = self.shell.shell_sidebar text = self.shell.text first_line = get_end_linenumber(text) self.do_input(dedent('''\ if True: print(1) ''')) yield text.tag_add('sel', f'{first_line}.3', 'end-1c') selected_text = text.get('sel.first', 'sel.last') self.assertTrue(selected_text.startswith('True:\n')) selected_lines_text = text.get('sel.first linestart', 'sel.last') selected_lines = selected_lines_text.split('\n') selected_lines.pop() # Final '' is a split artifact, not a line. # Expect a block of input and a single output line. expected_prompts = \ ['>>>'] + ['...'] * (len(selected_lines) - 2) + [None] selected_text_with_prompts = '\n'.join( line if prompt is None else prompt + ' ' + line for prompt, line in zip(expected_prompts, selected_lines, strict=True) ) + '\n' text.event_generate('<>') self.addCleanup(text.clipboard_clear) copied_text = text.clipboard_get() self.assertEqual(copied_text, selected_text_with_prompts) if __name__ == '__main__': unittest.main(verbosity=2)