| "Test editor, coverage 53%." | |
| from idlelib import editor | |
| import os | |
| import tempfile | |
| import types | |
| import unittest | |
| from pathlib import Path | |
| from collections import namedtuple | |
| from unittest import mock | |
| from test.support import requires | |
| from tkinter import Tk, Text | |
| Editor = editor.EditorWindow | |
| class EditorWindowTest(unittest.TestCase): | |
| @classmethod | |
| def setUpClass(cls): | |
| requires('gui') | |
| cls.root = Tk() | |
| cls.root.withdraw() | |
| @classmethod | |
| def tearDownClass(cls): | |
| cls.root.update_idletasks() | |
| for id in cls.root.after_info(): | |
| cls.root.after_cancel(id) | |
| cls.root.destroy() | |
| del cls.root | |
| def test_init(self): | |
| e = Editor(root=self.root) | |
| self.assertEqual(e.root, self.root) | |
| e._close() | |
| def test_apply_bindings_caps_lock(self): | |
| # gh-56596: Caps Lock changes the case of letter keysyms, so the | |
| # sequences are bound with both cases. | |
| e = Editor(root=self.root) | |
| try: | |
| e.apply_bindings({'<<spam>>': ('<Control-Key-s>', '<Key-F1>'), | |
| '<<eggs>>': ('<Control-Key-x><Alt-Shift-Key-S>',), | |
| '<<ham>>': ('<Control-Key-h>', '<Control-Key-H>')}) | |
| self.assertEqual(set(e.text.event_info('<<spam>>')), | |
| {'<Control-KeyPress-s>', '<Control-KeyPress-S>', | |
| '<KeyPress-F1>'}) | |
| # Existing variants are not added again. | |
| self.assertEqual(e.text.event_info('<<ham>>'), | |
| ('<Control-KeyPress-h>', '<Control-KeyPress-H>')) | |
| self.assertEqual(set(e.text.event_info('<<eggs>>')), | |
| {'<Control-Key-x><Shift-Alt-Key-S>', | |
| '<Control-Key-X><Shift-Alt-Key-s>'}) | |
| finally: | |
| e._close() | |
| def test_set_width_zero_char_width(self): | |
| # A zero-width '0' must not raise ZeroDivisionError (gh-90304). | |
| e = Editor(root=self.root) | |
| try: | |
| with mock.patch.object(editor, 'Font') as MockFont: | |
| MockFont.return_value.measure.return_value = 0 | |
| e.set_width() | |
| self.assertEqual(e.width, | |
| e.text.tk.getint(e.text.cget('width'))) | |
| finally: | |
| e._close() | |
| class GetLineIndentTest(unittest.TestCase): | |
| def test_empty_lines(self): | |
| for tabwidth in [1, 2, 4, 6, 8]: | |
| for line in ['', '\n']: | |
| with self.subTest(line=line, tabwidth=tabwidth): | |
| self.assertEqual( | |
| editor.get_line_indent(line, tabwidth=tabwidth), | |
| (0, 0), | |
| ) | |
| def test_tabwidth_4(self): | |
| # (line, (raw, effective)) | |
| tests = (('no spaces', (0, 0)), | |
| # Internal space isn't counted. | |
| (' space test', (4, 4)), | |
| ('\ttab test', (1, 4)), | |
| ('\t\tdouble tabs test', (2, 8)), | |
| # Different results when mixing tabs and spaces. | |
| (' \tmixed test', (5, 8)), | |
| (' \t mixed test', (5, 6)), | |
| ('\t mixed test', (5, 8)), | |
| # Spaces not divisible by tabwidth. | |
| (' \tmixed test', (3, 4)), | |
| (' \t mixed test', (3, 5)), | |
| ('\t mixed test', (3, 6)), | |
| # Only checks spaces and tabs. | |
| ('\nnewline test', (0, 0))) | |
| for line, expected in tests: | |
| with self.subTest(line=line): | |
| self.assertEqual( | |
| editor.get_line_indent(line, tabwidth=4), | |
| expected, | |
| ) | |
| def test_tabwidth_8(self): | |
| # (line, (raw, effective)) | |
| tests = (('no spaces', (0, 0)), | |
| # Internal space isn't counted. | |
| (' space test', (8, 8)), | |
| ('\ttab test', (1, 8)), | |
| ('\t\tdouble tabs test', (2, 16)), | |
| # Different results when mixing tabs and spaces. | |
| (' \tmixed test', (9, 16)), | |
| (' \t mixed test', (9, 10)), | |
| ('\t mixed test', (9, 16)), | |
| # Spaces not divisible by tabwidth. | |
| (' \tmixed test', (3, 8)), | |
| (' \t mixed test', (3, 9)), | |
| ('\t mixed test', (3, 10)), | |
| # Only checks spaces and tabs. | |
| ('\nnewline test', (0, 0))) | |
| for line, expected in tests: | |
| with self.subTest(line=line): | |
| self.assertEqual( | |
| editor.get_line_indent(line, tabwidth=8), | |
| expected, | |
| ) | |
| def insert(text, string): | |
| text.delete('1.0', 'end') | |
| text.insert('end', string) | |
| text.update_idletasks() # Force update for colorizer to finish. | |
| class IndentAndNewlineTest(unittest.TestCase): | |
| @classmethod | |
| def setUpClass(cls): | |
| requires('gui') | |
| cls.root = Tk() | |
| cls.root.withdraw() | |
| cls.window = Editor(root=cls.root) | |
| cls.window.indentwidth = 2 | |
| cls.window.tabwidth = 2 | |
| @classmethod | |
| def tearDownClass(cls): | |
| cls.window._close() | |
| del cls.window | |
| cls.root.update_idletasks() | |
| for id in cls.root.after_info(): | |
| cls.root.after_cancel(id) | |
| cls.root.destroy() | |
| del cls.root | |
| def test_indent_and_newline_event(self): | |
| eq = self.assertEqual | |
| w = self.window | |
| text = w.text | |
| get = text.get | |
| nl = w.newline_and_indent_event | |
| TestInfo = namedtuple('Tests', ['label', 'text', 'expected', 'mark']) | |
| tests = (TestInfo('Empty line inserts with no indent.', | |
| ' \n def __init__(self):', | |
| '\n \n def __init__(self):\n', | |
| '1.end'), | |
| TestInfo('Inside bracket before space, deletes space.', | |
| ' def f1(self, a, b):', | |
| ' def f1(self,\n a, b):\n', | |
| '1.14'), | |
| TestInfo('Inside bracket after space, deletes space.', | |
| ' def f1(self, a, b):', | |
| ' def f1(self,\n a, b):\n', | |
| '1.15'), | |
| TestInfo('Inside string with one line - no indent.', | |
| ' """Docstring."""', | |
| ' """Docstring.\n"""\n', | |
| '1.15'), | |
| TestInfo('Inside string with more than one line.', | |
| ' """Docstring.\n Docstring Line 2"""', | |
| ' """Docstring.\n Docstring Line 2\n """\n', | |
| '2.18'), | |
| TestInfo('Backslash with one line.', | |
| 'a =\\', | |
| 'a =\\\n \n', | |
| '1.end'), | |
| TestInfo('Backslash with more than one line.', | |
| 'a =\\\n multiline\\', | |
| 'a =\\\n multiline\\\n \n', | |
| '2.end'), | |
| TestInfo('Block opener - indents +1 level.', | |
| ' def f1(self):\n pass', | |
| ' def f1(self):\n \n pass\n', | |
| '1.end'), | |
| TestInfo('Block closer - dedents -1 level.', | |
| ' def f1(self):\n pass', | |
| ' def f1(self):\n pass\n \n', | |
| '2.end'), | |
| ) | |
| for test in tests: | |
| with self.subTest(label=test.label): | |
| insert(text, test.text) | |
| text.mark_set('insert', test.mark) | |
| nl(event=None) | |
| eq(get('1.0', 'end'), test.expected) | |
| # Selected text. | |
| insert(text, ' def f1(self, a, b):\n return a + b') | |
| text.tag_add('sel', '1.17', '1.end') | |
| nl(None) | |
| # Deletes selected text before adding new line. | |
| eq(get('1.0', 'end'), ' def f1(self, a,\n \n return a + b\n') | |
| class IndentSearcherTest(unittest.TestCase): | |
| @classmethod | |
| def setUpClass(cls): | |
| requires('gui') | |
| cls.root = Tk() | |
| cls.root.withdraw() | |
| cls.text = Text(cls.root) | |
| @classmethod | |
| def tearDownClass(cls): | |
| cls.root.destroy() | |
| del cls.root | |
| def test_searcher(self): | |
| text = self.text | |
| searcher = (self.text) | |
| test_info = (# text, (block, indent)) | |
| ("", (None, None)), | |
| ("[1,", (None, None)), # TokenError | |
| ("if 1:\n", ('if 1:\n', None)), | |
| ("if 1:\n 2\n 3\n", ('if 1:\n', ' 2\n')), | |
| ) | |
| for code, expected_pair in test_info: | |
| with self.subTest(code=code): | |
| insert(text, code) | |
| actual_pair = editor.IndentSearcher(text).run() | |
| self.assertEqual(actual_pair, expected_pair) | |
| class RMenuTest(unittest.TestCase): | |
| # Test selection-rclick interaction in right_click_event and | |
| # rmenu_check_copy(cut) status settings. These are part of the rmenu | |
| # functions common to all text windows with context windows. | |
| @classmethod | |
| def setUpClass(cls): | |
| requires('gui') | |
| cls.root = Tk() | |
| cls.root.withdraw() | |
| cls.window = Editor(root=cls.root) | |
| cls.text = cls.window.text | |
| cls.window.rmenu = cls.DummyRMenu | |
| @classmethod | |
| def tearDownClass(cls): | |
| cls.window._close() | |
| del cls.window, cls.text | |
| cls.root.update_idletasks() | |
| for id in cls.root.after_info(): | |
| cls.root.after_cancel(id) | |
| cls.root.destroy() | |
| del cls.root | |
| class DummyRMenu: | |
| def tk_popup(x, y): pass | |
| def click(self): | |
| """Simulate a right click at(text pixel 0,0). | |
| """ | |
| Event = namedtuple('Event', ['x', 'y', 'x_root', 'y_root']) | |
| event = Event(0, 0, 0, 0) | |
| self.assertEqual(self.window.right_menu_event(event), 'break') | |
| # This assertion should be moved to a new method that also | |
| # text that dummy rmenu.tk_popup is called. | |
| def test_rclick_not_in_a_selection(self): | |
| # Like left click, 'insert' moves to click and any selection is deleted. | |
| eq = self.assertEqual | |
| text = self.text | |
| insert(text, 'one two three') | |
| # Selection exists but not clicked. | |
| text.tag_add('sel', '1.4', '1.7') # 'two' selected. | |
| text.mark_set('insert', '1.7') # Outside of selection. | |
| self.click() | |
| eq(text.tag_ranges('sel'), ()) | |
| eq(text.index('insert'), '1.0') | |
| # No selection to click, same result. | |
| text.mark_set('insert', '1.8') | |
| index = self.click() | |
| eq(text.tag_ranges('sel'), ()) | |
| eq(text.index('insert'), '1.0') | |
| def test_rclick_inside_selection(self): | |
| # Unlike left click, selection is not deleted. | |
| eq = self.assertEqual | |
| text = self.text | |
| insert(text, 'one two three') # 'insert' at 1.13. | |
| # The selection contains the clicked character. | |
| text.tag_add('sel', '1.0', '1.3') # Select 'one'. | |
| text.mark_set('insert', '1.3') # If select rightward, 'insert' at 1.3. | |
| self.click() | |
| eq((text.index('sel.first'), text.index('sel.last')), ('1.0', '1.3')) | |
| eq(text.index('insert'), '1.3') | |
| def test_rmenu_check_copy(self): | |
| # copy and cut only valid for click inside selection. | |
| eq = self.assertEqual | |
| text = self.text | |
| insert(text, 'one two three') | |
| eq(self.window.rmenu_check_copy(), 'disabled') | |
| eq(self.window.rmenu_check_cut(), 'disabled') | |
| text.tag_add('sel', '1.0', '1.3') # Includes '1.0' click. | |
| eq(self.window.rmenu_check_copy(), 'normal') | |
| eq(self.window.rmenu_check_cut(), 'normal') | |
| class LastMtimeTest(unittest.TestCase): | |
| # Exercise last_mtime as an unbound method on a stub; no GUI needed. | |
| def test_existing_file_returns_mtime(self): | |
| with tempfile.TemporaryDirectory() as d: | |
| p = os.path.join(d, 'f.py') | |
| Path(p).touch() | |
| stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p)) | |
| self.assertEqual(Editor.last_mtime(stub), os.path.getmtime(p)) | |
| def test_deleted_file_returns_none(self): | |
| with tempfile.TemporaryDirectory() as d: | |
| p = os.path.join(d, 'gone.py') | |
| Path(p).touch() | |
| os.remove(p) | |
| stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=p)) | |
| self.assertIsNone(Editor.last_mtime(stub)) | |
| def test_not_yet_created_filename(self): | |
| # __init__ calls last_mtime() before self.mtime is set, so last_mtime() | |
| # must not read self.mtime (the stub has no mtime attribute). | |
| stub = types.SimpleNamespace( | |
| io=types.SimpleNamespace(filename='/no/such/file.py')) | |
| self.assertIsNone(Editor.last_mtime(stub)) | |
| def test_no_filename_returns_none(self): | |
| stub = types.SimpleNamespace(io=types.SimpleNamespace(filename=None)) | |
| self.assertIsNone(Editor.last_mtime(stub)) | |
| class DeletedFileEventTest(unittest.TestCase): | |
| # Exercise the deleted-file handling as unbound methods; dialog is mocked. | |
| def make_stub(self): | |
| return types.SimpleNamespace( | |
| mtime=1.0, | |
| text=None, | |
| io=types.SimpleNamespace(filename='/gone.py', save_as=mock.Mock()), | |
| close=mock.Mock(), | |
| set_saved=mock.Mock(), | |
| deleted_file_event=mock.Mock(), | |
| askyesno=mock.Mock(), | |
| last_mtime=lambda: None) | |
| def test_focus_in_routes_deleted_to_dialog(self): | |
| stub = self.make_stub() | |
| Editor.focus_in_event(stub, 'event') | |
| stub.deleted_file_event.assert_called_once_with('event') | |
| stub.askyesno.assert_not_called() | |
| def _run_choice(self, choice): | |
| stub = self.make_stub() | |
| with mock.patch.object(editor.simpledialog, 'SimpleDialog') as SD: | |
| SD.return_value.go.return_value = choice | |
| Editor.deleted_file_event(stub, 'event') | |
| return stub | |
| def test_close_choice_closes_window(self): | |
| stub = self._run_choice(0) | |
| self.assertTrue(stub.close.called) | |
| # mtime is cleared before Close so the queued FocusIn does not reprompt. | |
| self.assertIsNone(stub.mtime) | |
| def test_save_as_choice_clears_mtime_and_saves(self): | |
| stub = self._run_choice(1) | |
| stub.io.save_as.assert_called_once_with('event') | |
| # A cancelled Save As leaves mtime None so it does not reprompt. | |
| self.assertIsNone(stub.mtime) | |
| def test_ignore_choice_clears_mtime(self): | |
| stub = self._run_choice(2) | |
| self.assertIsNone(stub.mtime) | |
| stub.io.save_as.assert_not_called() | |
| stub.set_saved.assert_not_called() | |
| if __name__ == '__main__': | |
| unittest.main(verbosity=2) |