Coverage for idle_test/test_colorizer.py: 27%
370 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
1"Test colorizer, coverage 99%."
2from idlelib import colorizer
3from test.support import requires
4import unittest
5from unittest import mock
6from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop
8from functools import partial
9import textwrap
10from tkinter import Tk, Text
11from idlelib import config
12from idlelib.percolator import Percolator
15usercfg = colorizer.idleConf.userCfg
16testcfg = {
17 'main': config.IdleUserConfParser(''),
18 'highlight': config.IdleUserConfParser(''),
19 'keys': config.IdleUserConfParser(''),
20 'extensions': config.IdleUserConfParser(''),
21}
23source = textwrap.dedent("""\
24 if True: int ('1') # keyword, builtin, string, comment
25 elif False: print(0) # 'string' in comment
26 else: float(None) # if in comment
27 if iF + If + IF: 'keyword matching must respect case'
28 if'': x or'' # valid keyword-string no-space combinations
29 async def f(): await g()
30 # Strings should be entirely colored, including quotes.
31 'x', '''x''', "x", \"""x\"""
32 'abc\\
33 def'
34 '''abc\\
35 def'''
36 # All valid prefixes for unicode and byte strings should be colored.
37 r'x', u'x', R'x', U'x', f'x', F'x'
38 fr'x', Fr'x', fR'x', FR'x', rf'x', rF'x', Rf'x', RF'x'
39 b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x', rB'x',Rb'x',RB'x'
40 # Invalid combinations of legal characters should be half colored.
41 ur'x', ru'x', uf'x', fu'x', UR'x', ufr'x', rfu'x', xf'x', fx'x'
42 match point:
43 case (x, 0) as _:
44 print(f"X={x}")
45 case [_, [_], "_",
46 _]:
47 pass
48 case _ if ("a" if _ else set()): pass
49 case _:
50 raise ValueError("Not a point _")
51 '''
52 case _:'''
53 "match x:"
54 """)
57def setUpModule():
58 colorizer.idleConf.userCfg = testcfg
61def tearDownModule():
62 colorizer.idleConf.userCfg = usercfg
65class FunctionTest(unittest.TestCase):
67 def test_any(self):
68 self.assertEqual(colorizer.any('test', ('a', 'b', 'cd')), 1d
69 '(?P<test>a|b|cd)')
71 def test_make_pat(self):
72 # Tested in more detail by testing prog.
73 self.assertTrue(colorizer.make_pat()) 1e
75 def test_prog(self):
76 prog = colorizer.prog 1b
77 eq = self.assertEqual 1b
78 line = 'def f():\n print("hello")\n' 1b
79 m = prog.search(line) 1b
80 eq(m.groupdict()['KEYWORD'], 'def') 1b
81 m = prog.search(line, m.end()) 1b
82 eq(m.groupdict()['SYNC'], '\n') 1b
83 m = prog.search(line, m.end()) 1b
84 eq(m.groupdict()['BUILTIN'], 'print') 1b
85 m = prog.search(line, m.end()) 1b
86 eq(m.groupdict()['STRING'], '"hello"') 1b
87 m = prog.search(line, m.end()) 1b
88 eq(m.groupdict()['SYNC'], '\n') 1b
90 def test_idprog(self):
91 idprog = colorizer.idprog 1c
92 m = idprog.match('nospace') 1c
93 self.assertIsNone(m) 1c
94 m = idprog.match(' space') 1c
95 self.assertEqual(m.group(0), ' space') 1c
98class ColorConfigTest(unittest.TestCase):
100 @classmethod
101 def setUpClass(cls):
102 requires('gui')
103 root = cls.root = Tk()
104 root.withdraw()
105 cls.text = Text(root)
107 @classmethod
108 def tearDownClass(cls):
109 del cls.text
110 cls.root.update_idletasks()
111 cls.root.destroy()
112 del cls.root
114 def test_color_config(self):
115 text = self.text
116 eq = self.assertEqual
117 colorizer.color_config(text)
118 # Uses IDLE Classic theme as default.
119 eq(text['background'], '#ffffff')
120 eq(text['foreground'], '#000000')
121 eq(text['selectbackground'], 'gray')
122 eq(text['selectforeground'], '#000000')
123 eq(text['insertbackground'], 'black')
124 eq(text['inactiveselectbackground'], 'gray')
127class ColorDelegatorInstantiationTest(unittest.TestCase):
129 @classmethod
130 def setUpClass(cls):
131 requires('gui')
132 root = cls.root = Tk()
133 root.withdraw()
134 cls.text = Text(root)
136 @classmethod
137 def tearDownClass(cls):
138 del cls.text
139 cls.root.update_idletasks()
140 cls.root.destroy()
141 del cls.root
143 def setUp(self):
144 self.color = colorizer.ColorDelegator()
146 def tearDown(self):
147 self.color.close()
148 self.text.delete('1.0', 'end')
149 self.color.resetcache()
150 del self.color
152 def test_init(self):
153 color = self.color
154 self.assertIsInstance(color, colorizer.ColorDelegator)
156 def test_init_state(self):
157 # init_state() is called during the instantiation of
158 # ColorDelegator in setUp().
159 color = self.color
160 self.assertIsNone(color.after_id)
161 self.assertTrue(color.allow_colorizing)
162 self.assertFalse(color.colorizing)
163 self.assertFalse(color.stop_colorizing)
166class ColorDelegatorTest(unittest.TestCase):
168 @classmethod
169 def setUpClass(cls):
170 requires('gui')
171 root = cls.root = Tk()
172 root.withdraw()
173 text = cls.text = Text(root)
174 cls.percolator = Percolator(text)
175 # Delegator stack = [Delegator(text)]
177 @classmethod
178 def tearDownClass(cls):
179 cls.percolator.close()
180 del cls.percolator, cls.text
181 cls.root.update_idletasks()
182 cls.root.destroy()
183 del cls.root
185 def setUp(self):
186 self.color = colorizer.ColorDelegator()
187 self.percolator.insertfilter(self.color)
188 # Calls color.setdelegate(Delegator(text)).
190 def tearDown(self):
191 self.color.close()
192 self.percolator.removefilter(self.color)
193 self.text.delete('1.0', 'end')
194 self.color.resetcache()
195 del self.color
197 def test_setdelegate(self):
198 # Called in setUp when filter is attached to percolator.
199 color = self.color
200 self.assertIsInstance(color.delegate, colorizer.Delegator)
201 # It is too late to mock notify_range, so test side effect.
202 self.assertEqual(self.root.tk.call(
203 'after', 'info', color.after_id)[1], 'timer')
205 def test_LoadTagDefs(self):
206 highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic')
207 for tag, colors in self.color.tagdefs.items():
208 with self.subTest(tag=tag):
209 self.assertIn('background', colors)
210 self.assertIn('foreground', colors)
211 if tag not in ('SYNC', 'TODO'):
212 self.assertEqual(colors, highlight(element=tag.lower()))
214 def test_config_colors(self):
215 text = self.text
216 highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic')
217 for tag in self.color.tagdefs:
218 for plane in ('background', 'foreground'):
219 with self.subTest(tag=tag, plane=plane):
220 if tag in ('SYNC', 'TODO'):
221 self.assertEqual(text.tag_cget(tag, plane), '')
222 else:
223 self.assertEqual(text.tag_cget(tag, plane),
224 highlight(element=tag.lower())[plane])
225 # 'sel' is marked as the highest priority.
226 self.assertEqual(text.tag_names()[-1], 'sel')
228 @mock.patch.object(colorizer.ColorDelegator, 'notify_range')
229 def test_insert(self, mock_notify):
230 text = self.text
231 # Initial text.
232 text.insert('insert', 'foo')
233 self.assertEqual(text.get('1.0', 'end'), 'foo\n')
234 mock_notify.assert_called_with('1.0', '1.0+3c')
235 # Additional text.
236 text.insert('insert', 'barbaz')
237 self.assertEqual(text.get('1.0', 'end'), 'foobarbaz\n')
238 mock_notify.assert_called_with('1.3', '1.3+6c')
240 @mock.patch.object(colorizer.ColorDelegator, 'notify_range')
241 def test_delete(self, mock_notify):
242 text = self.text
243 # Initialize text.
244 text.insert('insert', 'abcdefghi')
245 self.assertEqual(text.get('1.0', 'end'), 'abcdefghi\n')
246 # Delete single character.
247 text.delete('1.7')
248 self.assertEqual(text.get('1.0', 'end'), 'abcdefgi\n')
249 mock_notify.assert_called_with('1.7')
250 # Delete multiple characters.
251 text.delete('1.3', '1.6')
252 self.assertEqual(text.get('1.0', 'end'), 'abcgi\n')
253 mock_notify.assert_called_with('1.3')
255 def test_notify_range(self):
256 text = self.text
257 color = self.color
258 eq = self.assertEqual
260 # Colorizing already scheduled.
261 save_id = color.after_id
262 eq(self.root.tk.call('after', 'info', save_id)[1], 'timer')
263 self.assertFalse(color.colorizing)
264 self.assertFalse(color.stop_colorizing)
265 self.assertTrue(color.allow_colorizing)
267 # Coloring scheduled and colorizing in progress.
268 color.colorizing = True
269 color.notify_range('1.0', 'end')
270 self.assertFalse(color.stop_colorizing)
271 eq(color.after_id, save_id)
273 # No colorizing scheduled and colorizing in progress.
274 text.after_cancel(save_id)
275 color.after_id = None
276 color.notify_range('1.0', '1.0+3c')
277 self.assertTrue(color.stop_colorizing)
278 self.assertIsNotNone(color.after_id)
279 eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer')
280 # New event scheduled.
281 self.assertNotEqual(color.after_id, save_id)
283 # No colorizing scheduled and colorizing off.
284 text.after_cancel(color.after_id)
285 color.after_id = None
286 color.allow_colorizing = False
287 color.notify_range('1.4', '1.4+10c')
288 # Nothing scheduled when colorizing is off.
289 self.assertIsNone(color.after_id)
291 def test_toggle_colorize_event(self):
292 color = self.color
293 eq = self.assertEqual
295 # Starts with colorizing allowed and scheduled.
296 self.assertFalse(color.colorizing)
297 self.assertFalse(color.stop_colorizing)
298 self.assertTrue(color.allow_colorizing)
299 eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer')
301 # Toggle colorizing off.
302 color.toggle_colorize_event()
303 self.assertIsNone(color.after_id)
304 self.assertFalse(color.colorizing)
305 self.assertFalse(color.stop_colorizing)
306 self.assertFalse(color.allow_colorizing)
308 # Toggle on while colorizing in progress (doesn't add timer).
309 color.colorizing = True
310 color.toggle_colorize_event()
311 self.assertIsNone(color.after_id)
312 self.assertTrue(color.colorizing)
313 self.assertFalse(color.stop_colorizing)
314 self.assertTrue(color.allow_colorizing)
316 # Toggle off while colorizing in progress.
317 color.toggle_colorize_event()
318 self.assertIsNone(color.after_id)
319 self.assertTrue(color.colorizing)
320 self.assertTrue(color.stop_colorizing)
321 self.assertFalse(color.allow_colorizing)
323 # Toggle on while colorizing not in progress.
324 color.colorizing = False
325 color.toggle_colorize_event()
326 eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer')
327 self.assertFalse(color.colorizing)
328 self.assertTrue(color.stop_colorizing)
329 self.assertTrue(color.allow_colorizing)
331 @mock.patch.object(colorizer.ColorDelegator, 'recolorize_main')
332 def test_recolorize(self, mock_recmain):
333 text = self.text
334 color = self.color
335 eq = self.assertEqual
336 # Call recolorize manually and not scheduled.
337 text.after_cancel(color.after_id)
339 # No delegate.
340 save_delegate = color.delegate
341 color.delegate = None
342 color.recolorize()
343 mock_recmain.assert_not_called()
344 color.delegate = save_delegate
346 # Toggle off colorizing.
347 color.allow_colorizing = False
348 color.recolorize()
349 mock_recmain.assert_not_called()
350 color.allow_colorizing = True
352 # Colorizing in progress.
353 color.colorizing = True
354 color.recolorize()
355 mock_recmain.assert_not_called()
356 color.colorizing = False
358 # Colorizing is done, but not completed, so rescheduled.
359 color.recolorize()
360 self.assertFalse(color.stop_colorizing)
361 self.assertFalse(color.colorizing)
362 mock_recmain.assert_called()
363 eq(mock_recmain.call_count, 1)
364 # Rescheduled when TODO tag still exists.
365 eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer')
367 # No changes to text, so no scheduling added.
368 text.tag_remove('TODO', '1.0', 'end')
369 color.recolorize()
370 self.assertFalse(color.stop_colorizing)
371 self.assertFalse(color.colorizing)
372 mock_recmain.assert_called()
373 eq(mock_recmain.call_count, 2)
374 self.assertIsNone(color.after_id)
376 @mock.patch.object(colorizer.ColorDelegator, 'notify_range')
377 def test_recolorize_main(self, mock_notify):
378 text = self.text
379 color = self.color
380 eq = self.assertEqual
382 text.insert('insert', source)
383 expected = (('1.0', ('KEYWORD',)), ('1.2', ()), ('1.3', ('KEYWORD',)),
384 ('1.7', ()), ('1.9', ('BUILTIN',)), ('1.14', ('STRING',)),
385 ('1.19', ('COMMENT',)),
386 ('2.1', ('KEYWORD',)), ('2.18', ()), ('2.25', ('COMMENT',)),
387 ('3.6', ('BUILTIN',)), ('3.12', ('KEYWORD',)), ('3.21', ('COMMENT',)),
388 ('4.0', ('KEYWORD',)), ('4.3', ()), ('4.6', ()),
389 ('5.2', ('STRING',)), ('5.8', ('KEYWORD',)), ('5.10', ('STRING',)),
390 ('6.0', ('KEYWORD',)), ('6.10', ('DEFINITION',)), ('6.11', ()),
391 ('8.0', ('STRING',)), ('8.4', ()), ('8.5', ('STRING',)),
392 ('8.12', ()), ('8.14', ('STRING',)),
393 ('19.0', ('KEYWORD',)),
394 ('20.4', ('KEYWORD',)), ('20.16', ('KEYWORD',)),# ('20.19', ('KEYWORD',)),
395 #('22.4', ('KEYWORD',)), ('22.10', ('KEYWORD',)), ('22.14', ('KEYWORD',)), ('22.19', ('STRING',)),
396 #('23.12', ('KEYWORD',)),
397 ('24.8', ('KEYWORD',)),
398 ('25.4', ('KEYWORD',)), ('25.9', ('KEYWORD',)),
399 ('25.11', ('KEYWORD',)), ('25.15', ('STRING',)),
400 ('25.19', ('KEYWORD',)), ('25.22', ()),
401 ('25.24', ('KEYWORD',)), ('25.29', ('BUILTIN',)), ('25.37', ('KEYWORD',)),
402 ('26.4', ('KEYWORD',)), ('26.9', ('KEYWORD',)),# ('26.11', ('KEYWORD',)), ('26.14', (),),
403 ('27.25', ('STRING',)), ('27.38', ('STRING',)),
404 ('29.0', ('STRING',)),
405 ('30.1', ('STRING',)),
406 # SYNC at the end of every line.
407 ('1.55', ('SYNC',)), ('2.50', ('SYNC',)), ('3.34', ('SYNC',)),
408 )
410 # Nothing marked to do therefore no tags in text.
411 text.tag_remove('TODO', '1.0', 'end')
412 color.recolorize_main()
413 for tag in text.tag_names():
414 with self.subTest(tag=tag):
415 eq(text.tag_ranges(tag), ())
417 # Source marked for processing.
418 text.tag_add('TODO', '1.0', 'end')
419 # Check some indexes.
420 color.recolorize_main()
421 for index, expected_tags in expected:
422 with self.subTest(index=index):
423 eq(text.tag_names(index), expected_tags)
425 # Check for some tags for ranges.
426 eq(text.tag_nextrange('TODO', '1.0'), ())
427 eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2'))
428 eq(text.tag_nextrange('COMMENT', '2.0'), ('2.22', '2.43'))
429 eq(text.tag_nextrange('SYNC', '2.0'), ('2.43', '3.0'))
430 eq(text.tag_nextrange('STRING', '2.0'), ('4.17', '4.53'))
431 eq(text.tag_nextrange('STRING', '8.0'), ('8.0', '8.3'))
432 eq(text.tag_nextrange('STRING', '8.3'), ('8.5', '8.12'))
433 eq(text.tag_nextrange('STRING', '8.12'), ('8.14', '8.17'))
434 eq(text.tag_nextrange('STRING', '8.17'), ('8.19', '8.26'))
435 eq(text.tag_nextrange('SYNC', '8.0'), ('8.26', '9.0'))
436 eq(text.tag_nextrange('SYNC', '30.0'), ('30.10', '32.0'))
438 def _assert_highlighting(self, source, tag_ranges):
439 """Check highlighting of a given piece of code.
441 This inserts just this code into the Text widget. It will then
442 check that the resulting highlighting tag ranges exactly match
443 those described in the given `tag_ranges` dict.
445 Note that the irrelevant tags 'sel', 'TODO' and 'SYNC' are
446 ignored.
447 """
448 text = self.text
450 with mock.patch.object(colorizer.ColorDelegator, 'notify_range'):
451 text.delete('1.0', 'end-1c')
452 text.insert('insert', source)
453 text.tag_add('TODO', '1.0', 'end-1c')
454 self.color.recolorize_main()
456 # Make a dict with highlighting tag ranges in the Text widget.
457 text_tag_ranges = {}
458 for tag in set(text.tag_names()) - {'sel', 'TODO', 'SYNC'}:
459 indexes = [rng.string for rng in text.tag_ranges(tag)]
460 for index_pair in zip(indexes[::2], indexes[1::2]):
461 text_tag_ranges.setdefault(tag, []).append(index_pair)
463 self.assertEqual(text_tag_ranges, tag_ranges)
465 with mock.patch.object(colorizer.ColorDelegator, 'notify_range'):
466 text.delete('1.0', 'end-1c')
468 def test_def_statement(self):
469 # empty def
470 self._assert_highlighting('def', {'KEYWORD': [('1.0', '1.3')]})
472 # def followed by identifier
473 self._assert_highlighting('def foo:', {'KEYWORD': [('1.0', '1.3')],
474 'DEFINITION': [('1.4', '1.7')]})
476 # def followed by partial identifier
477 self._assert_highlighting('def fo', {'KEYWORD': [('1.0', '1.3')],
478 'DEFINITION': [('1.4', '1.6')]})
480 # def followed by non-keyword
481 self._assert_highlighting('def ++', {'KEYWORD': [('1.0', '1.3')]})
483 def test_match_soft_keyword(self):
484 # empty match
485 self._assert_highlighting('match', {'KEYWORD': [('1.0', '1.5')]})
487 # match followed by partial identifier
488 self._assert_highlighting('match fo', {'KEYWORD': [('1.0', '1.5')]})
490 # match followed by identifier and colon
491 self._assert_highlighting('match foo:', {'KEYWORD': [('1.0', '1.5')]})
493 # match followed by keyword
494 self._assert_highlighting('match and', {'KEYWORD': [('1.6', '1.9')]})
496 # match followed by builtin with keyword prefix
497 self._assert_highlighting('match int:', {'KEYWORD': [('1.0', '1.5')],
498 'BUILTIN': [('1.6', '1.9')]})
500 # match followed by non-text operator
501 self._assert_highlighting('match^', {})
502 self._assert_highlighting('match @', {})
504 # match followed by colon
505 self._assert_highlighting('match :', {})
507 # match followed by comma
508 self._assert_highlighting('match\t,', {})
510 # match followed by a lone underscore
511 self._assert_highlighting('match _:', {'KEYWORD': [('1.0', '1.5')]})
513 def test_case_soft_keyword(self):
514 # empty case
515 self._assert_highlighting('case', {'KEYWORD': [('1.0', '1.4')]})
517 # case followed by partial identifier
518 self._assert_highlighting('case fo', {'KEYWORD': [('1.0', '1.4')]})
520 # case followed by identifier and colon
521 self._assert_highlighting('case foo:', {'KEYWORD': [('1.0', '1.4')]})
523 # case followed by keyword
524 self._assert_highlighting('case and', {'KEYWORD': [('1.5', '1.8')]})
526 # case followed by builtin with keyword prefix
527 self._assert_highlighting('case int:', {'KEYWORD': [('1.0', '1.4')],
528 'BUILTIN': [('1.5', '1.8')]})
530 # case followed by non-text operator
531 self._assert_highlighting('case^', {})
532 self._assert_highlighting('case @', {})
534 # case followed by colon
535 self._assert_highlighting('case :', {})
537 # case followed by comma
538 self._assert_highlighting('case\t,', {})
540 # case followed by a lone underscore
541 self._assert_highlighting('case _:', {'KEYWORD': [('1.0', '1.4'),
542 ('1.5', '1.6')]})
544 def test_long_multiline_string(self):
545 source = textwrap.dedent('''\
546 """a
547 b
548 c
549 d
550 e"""
551 ''')
552 self._assert_highlighting(source, {'STRING': [('1.0', '5.4')]})
554 @run_in_tk_mainloop(delay=50)
555 def test_incremental_editing(self):
556 text = self.text
557 eq = self.assertEqual
559 # Simulate typing 'inte'. During this, the highlighting should
560 # change from normal to keyword to builtin to normal.
561 text.insert('insert', 'i')
562 yield
563 eq(text.tag_nextrange('BUILTIN', '1.0'), ())
564 eq(text.tag_nextrange('KEYWORD', '1.0'), ())
566 text.insert('insert', 'n')
567 yield
568 eq(text.tag_nextrange('BUILTIN', '1.0'), ())
569 eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2'))
571 text.insert('insert', 't')
572 yield
573 eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3'))
574 eq(text.tag_nextrange('KEYWORD', '1.0'), ())
576 text.insert('insert', 'e')
577 yield
578 eq(text.tag_nextrange('BUILTIN', '1.0'), ())
579 eq(text.tag_nextrange('KEYWORD', '1.0'), ())
581 # Simulate deleting three characters from the end of 'inte'.
582 # During this, the highlighting should change from normal to
583 # builtin to keyword to normal.
584 text.delete('insert-1c', 'insert')
585 yield
586 eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3'))
587 eq(text.tag_nextrange('KEYWORD', '1.0'), ())
589 text.delete('insert-1c', 'insert')
590 yield
591 eq(text.tag_nextrange('BUILTIN', '1.0'), ())
592 eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2'))
594 text.delete('insert-1c', 'insert')
595 yield
596 eq(text.tag_nextrange('BUILTIN', '1.0'), ())
597 eq(text.tag_nextrange('KEYWORD', '1.0'), ())
599 @mock.patch.object(colorizer.ColorDelegator, 'recolorize')
600 @mock.patch.object(colorizer.ColorDelegator, 'notify_range')
601 def test_removecolors(self, mock_notify, mock_recolorize):
602 text = self.text
603 color = self.color
604 text.insert('insert', source)
606 color.recolorize_main()
607 # recolorize_main doesn't add these tags.
608 text.tag_add("ERROR", "1.0")
609 text.tag_add("TODO", "1.0")
610 text.tag_add("hit", "1.0")
611 for tag in color.tagdefs:
612 with self.subTest(tag=tag):
613 self.assertNotEqual(text.tag_ranges(tag), ())
615 color.removecolors()
616 for tag in color.tagdefs:
617 with self.subTest(tag=tag):
618 self.assertEqual(text.tag_ranges(tag), ())
621if __name__ == '__main__': 621 ↛ 622line 621 didn't jump to line 622, because the condition on line 621 was never true
622 unittest.main(verbosity=2)