Skip to content

Commit 6dfafe9

Browse files
serhiy-storchakaclaude
authored andcommitted
gh-151693: Add curses tests for panels, textpad, and window behavior (GH-151694)
Add curses tests for panels, textpad, and window behavior Extend test_curses with behavior-verifying tests that go beyond the existing smoke tests: * curses.panel stacking: new_panel/top/bottom/above/below ordering, hide/show/hidden, move, replace and userptr round-trip. * Real-window curses.textpad.Textbox: gather(), edit(), stripspaces, insert mode and the Emacs-like editing commands (previously only exercised through a MagicMock). * Window output: addstr cursor advance and addnstr truncation, insstr/insnstr shifting without cursor movement, and pad behavior (instr, subpad cell sharing, the required 6-argument refresh()). * Error handling: out-of-range coordinates raising curses.error and bad character/string argument types. (cherry picked from commit b4cfb99) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8650126 commit 6dfafe9

1 file changed

Lines changed: 277 additions & 1 deletion

File tree

Lib/test/test_curses.py

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,63 @@ def test_output_string_embedded_null_chars(self):
337337
self.assertRaises(ValueError, stdscr.insstr, arg)
338338
self.assertRaises(ValueError, stdscr.insnstr, arg, 1)
339339

340+
def test_add_string_behavior(self):
341+
# addstr() advances the cursor past the written text; addnstr()
342+
# writes at most n characters.
343+
win = curses.newwin(1, 10, 0, 0)
344+
win.addstr(0, 0, 'abc')
345+
self.assertEqual(win.getyx(), (0, 3))
346+
win.erase()
347+
win.addnstr(0, 0, 'abcdef', 3)
348+
self.assertEqual(win.instr(0, 0), b'abc ')
349+
350+
def test_insert_string_behavior(self):
351+
# insstr()/insnstr() insert at the cursor, shift the rest of the
352+
# line right (losing characters off the edge), and leave the cursor
353+
# where it was.
354+
win = curses.newwin(1, 10, 0, 0)
355+
win.addstr(0, 0, 'abcde')
356+
win.move(0, 1)
357+
win.insstr('XY')
358+
self.assertEqual(win.getyx(), (0, 1)) # cursor did not advance
359+
self.assertEqual(win.instr(0, 0), b'aXYbcde ')
360+
361+
win.erase()
362+
win.addstr(0, 0, 'ZZZZZ')
363+
win.move(0, 0)
364+
win.insnstr('abcdef', 3) # at most 3 characters
365+
self.assertEqual(win.instr(0, 0), b'abcZZZZZ ')
366+
367+
def test_insch(self):
368+
# insch() inserts a single character at the cursor (or at y, x),
369+
# shifting the rest of the line right.
370+
win = curses.newwin(2, 10, 0, 0)
371+
win.addstr(0, 0, 'abc')
372+
win.move(0, 1)
373+
win.insch(ord('X'))
374+
self.assertEqual(win.instr(0, 0), b'aXbc ')
375+
win.insch(1, 0, 'Y', curses.A_BOLD)
376+
self.assertEqual(win.inch(1, 0), b'Y'[0] | curses.A_BOLD)
377+
378+
def test_pad(self):
379+
pad = curses.newpad(10, 20)
380+
pad.addstr(0, 0, 'PADTEXT')
381+
self.assertEqual(pad.instr(0, 0, 7), b'PADTEXT')
382+
383+
# subpad() shares the parent pad's character cells.
384+
sub = pad.subpad(3, 5, 0, 0)
385+
self.assertEqual(sub.getmaxyx(), (3, 5))
386+
self.assertEqual(sub.instr(0, 0, 5), b'PADTE')
387+
388+
# A pad is refreshed onto an explicit screen rectangle; the
389+
# 6-argument form is required (and rejected for ordinary windows).
390+
pad.refresh(0, 0, 0, 0, 4, 10)
391+
pad.noutrefresh(0, 0, 0, 0, 4, 10)
392+
curses.doupdate()
393+
self.assertRaises(TypeError, pad.refresh)
394+
win = curses.newwin(5, 5, 0, 0)
395+
self.assertRaises(TypeError, win.refresh, 0, 0, 0, 0, 4, 4)
396+
340397
def test_read_from_window(self):
341398
stdscr = self.stdscr
342399
stdscr.addstr(0, 1, 'ABCD', curses.A_BOLD)
@@ -353,6 +410,26 @@ def test_read_from_window(self):
353410
self.assertRaises(ValueError, stdscr.instr, -2)
354411
self.assertRaises(ValueError, stdscr.instr, 0, 2, -2)
355412

413+
def test_coordinate_errors(self):
414+
# Addressing a cell outside the window raises curses.error.
415+
win = curses.newwin(5, 10, 0, 0)
416+
self.assertRaises(curses.error, win.move, 100, 100)
417+
self.assertRaises(curses.error, win.move, -1, -1)
418+
self.assertRaises(curses.error, win.addch, 100, 100, ord('x'))
419+
self.assertRaises(curses.error, win.inch, 100, 100)
420+
self.assertRaises(curses.error, win.chgat, 100, 0, curses.A_BOLD)
421+
422+
def test_argument_errors(self):
423+
win = curses.newwin(5, 10, 0, 0)
424+
# A character argument must be an int, a byte or a one-element string.
425+
self.assertRaises(TypeError, win.addch, [])
426+
self.assertRaises(OverflowError, win.addch, 2**64)
427+
# A string method rejects a non-string, non-bytes argument.
428+
self.assertRaises(TypeError, win.addstr, 5)
429+
self.assertRaises(TypeError, win.addstr)
430+
# Wrong number of positional arguments.
431+
self.assertRaises(TypeError, win.instr, 0, 0, 0, 0)
432+
356433
def test_getch(self):
357434
win = curses.newwin(5, 12, 5, 2)
358435

@@ -822,6 +899,10 @@ def test_prog_mode(self):
822899
self.skipTest('requires terminal')
823900
curses.def_prog_mode()
824901
curses.reset_prog_mode()
902+
# def_shell_mode()/reset_shell_mode() are intentionally not exercised
903+
# here: they capture and restore curses' "shell mode" terminal state,
904+
# which is only meaningful before initscr(). Calling them mid-suite
905+
# corrupts the modes that endwin() restores and breaks later tests.
825906

826907
def test_beep(self):
827908
if (curses.tigetstr("bel") is not None
@@ -1022,7 +1103,8 @@ def test_keyname(self):
10221103

10231104
@requires_curses_func('has_key')
10241105
def test_has_key(self):
1025-
curses.has_key(13)
1106+
self.assertIsInstance(curses.has_key(13), bool)
1107+
self.assertIsInstance(curses.has_key(curses.KEY_LEFT), bool)
10261108

10271109
@requires_curses_func('getmouse')
10281110
def test_getmouse(self):
@@ -1074,6 +1156,200 @@ def test_disallow_instantiation(self):
10741156
panel = curses.panel.new_panel(w)
10751157
check_disallow_instantiation(self, type(panel))
10761158

1159+
@requires_curses_func('panel')
1160+
def test_panel_stack(self):
1161+
panel = curses.panel
1162+
# new_panel() puts the panel on top of the stack, so the three
1163+
# panels end up ordered bottom -> top as p1, p2, p3.
1164+
p1 = panel.new_panel(curses.newwin(3, 6, 0, 0))
1165+
p2 = panel.new_panel(curses.newwin(3, 6, 1, 1))
1166+
p3 = panel.new_panel(curses.newwin(3, 6, 2, 2))
1167+
self.addCleanup(self._delete_panels, p1, p2, p3)
1168+
1169+
# The most recently created panel is on top.
1170+
self.assertIs(panel.top_panel(), p3)
1171+
# window() returns the wrapped window.
1172+
self.assertEqual(p2.window().getbegyx(), (1, 1))
1173+
1174+
# above()/below() walk the stack one step at a time.
1175+
self.assertIs(p1.above(), p2)
1176+
self.assertIs(p2.above(), p3)
1177+
self.assertIsNone(p3.above()) # nothing above the top panel
1178+
self.assertIs(p3.below(), p2)
1179+
self.assertIs(p2.below(), p1)
1180+
1181+
# top() raises a panel to the top, bottom() lowers it to the bottom.
1182+
p1.top()
1183+
self.assertIs(panel.top_panel(), p1)
1184+
self.assertIsNone(p1.above())
1185+
p1.bottom()
1186+
self.assertIs(panel.bottom_panel(), p1)
1187+
self.assertIsNone(p1.below())
1188+
1189+
# update_panels() refreshes the virtual screen from the stack.
1190+
panel.update_panels()
1191+
1192+
@requires_curses_func('panel')
1193+
def test_panel_hide_show(self):
1194+
p = curses.panel.new_panel(curses.newwin(3, 6, 0, 0))
1195+
self.addCleanup(self._delete_panels, p)
1196+
self.assertIs(p.hidden(), False)
1197+
p.hide()
1198+
self.assertIs(p.hidden(), True)
1199+
p.show()
1200+
self.assertIs(p.hidden(), False)
1201+
1202+
@requires_curses_func('panel')
1203+
def test_panel_move(self):
1204+
win = curses.newwin(3, 6, 1, 2)
1205+
p = curses.panel.new_panel(win)
1206+
self.addCleanup(self._delete_panels, p)
1207+
self.assertEqual(win.getbegyx(), (1, 2))
1208+
p.move(4, 5)
1209+
self.assertEqual(win.getbegyx(), (4, 5))
1210+
1211+
@requires_curses_func('panel')
1212+
def test_panel_replace(self):
1213+
win1 = curses.newwin(3, 6, 0, 0)
1214+
win2 = curses.newwin(4, 8, 1, 1)
1215+
p = curses.panel.new_panel(win1)
1216+
self.addCleanup(self._delete_panels, p)
1217+
self.assertIs(p.window(), win1)
1218+
p.replace(win2)
1219+
self.assertIs(p.window(), win2)
1220+
1221+
@requires_curses_func('panel')
1222+
def test_panel_userptr(self):
1223+
p = curses.panel.new_panel(curses.newwin(3, 6, 0, 0))
1224+
self.addCleanup(self._delete_panels, p)
1225+
obj = ['userptr']
1226+
p.set_userptr(obj)
1227+
self.assertIs(p.userptr(), obj)
1228+
1229+
def _delete_panels(self, *panels):
1230+
# Drop the panels from the global stack so they do not leak into
1231+
# later tests that inspect top_panel()/bottom_panel().
1232+
for p in panels:
1233+
try:
1234+
p.bottom()
1235+
except curses.panel.error:
1236+
pass
1237+
del panels
1238+
gc_collect()
1239+
1240+
def _make_textbox(self, nlines, ncols, *, insert_mode=False, stripspaces=1):
1241+
win = curses.newwin(nlines, ncols, 0, 0)
1242+
box = curses.textpad.Textbox(win, insert_mode=insert_mode)
1243+
box.stripspaces = stripspaces
1244+
return box, win
1245+
1246+
def _type(self, box, text):
1247+
for ch in text:
1248+
box.do_command(ch if isinstance(ch, int) else ord(ch))
1249+
1250+
def test_textbox_gather(self):
1251+
# Typed text is read back by gather(). With stripspaces on (the
1252+
# default) gather() keeps a single trailing blank on a line and
1253+
# drops trailing empty lines.
1254+
box, win = self._make_textbox(3, 10)
1255+
self._type(box, 'Hello')
1256+
self.assertEqual(box.gather(), 'Hello \n')
1257+
1258+
def test_textbox_gather_multiline(self):
1259+
box, win = self._make_textbox(3, 10)
1260+
self._type(box, 'ab')
1261+
box.do_command(curses.ascii.NL) # ^j -> start of next line
1262+
self._type(box, 'cd')
1263+
self.assertEqual(box.gather(), 'ab \ncd \n')
1264+
1265+
def test_textbox_stripspaces(self):
1266+
box, win = self._make_textbox(1, 8, stripspaces=1)
1267+
self._type(box, 'hi')
1268+
self.assertEqual(box.gather(), 'hi ')
1269+
1270+
box, win = self._make_textbox(1, 8, stripspaces=0)
1271+
self._type(box, 'hi')
1272+
self.assertEqual(box.gather(), 'hi ')
1273+
1274+
def test_textbox_insert_mode(self):
1275+
# In insert mode a typed character shifts the rest of the line right.
1276+
box, win = self._make_textbox(1, 10, insert_mode=True)
1277+
self._type(box, 'aXc')
1278+
win.move(0, 1)
1279+
self._type(box, 'b')
1280+
self.assertEqual(box.gather(), 'abXc ')
1281+
1282+
def test_textbox_movement(self):
1283+
box, win = self._make_textbox(3, 10)
1284+
self._type(box, 'abc')
1285+
box.do_command(curses.ascii.SOH) # ^a -> left edge
1286+
self.assertEqual(win.getyx(), (0, 0))
1287+
box.do_command(curses.ascii.ENQ) # ^e -> end of line
1288+
self.assertEqual(win.getyx(), (0, 3))
1289+
1290+
def test_textbox_kill_to_eol(self):
1291+
box, win = self._make_textbox(1, 10)
1292+
self._type(box, 'abcdef')
1293+
win.move(0, 3)
1294+
box.do_command(curses.ascii.VT) # ^k -> clear to end of line
1295+
self.assertEqual(box.gather(), 'abc ')
1296+
1297+
def test_textbox_backspace(self):
1298+
box, win = self._make_textbox(1, 10)
1299+
self._type(box, 'abc')
1300+
box.do_command(curses.ascii.BS) # ^h -> delete backward
1301+
self.assertEqual(box.gather(), 'ab ')
1302+
1303+
def test_textbox_edit(self):
1304+
# edit() reads characters until Ctrl-G and returns the contents.
1305+
box, win = self._make_textbox(1, 10)
1306+
for ch in reversed('Hi' + chr(curses.ascii.BEL)):
1307+
curses.ungetch(ch)
1308+
self.assertEqual(box.edit(), 'Hi ')
1309+
1310+
def test_textbox_edit_validate(self):
1311+
# The validate hook can rewrite an incoming keystroke.
1312+
box, win = self._make_textbox(1, 10)
1313+
for ch in reversed('abc' + chr(curses.ascii.BEL)):
1314+
curses.ungetch(ch)
1315+
box.edit(lambda ch: ord('X') if ch == ord('b') else ch)
1316+
self.assertEqual(box.gather(), 'aXc ')
1317+
1318+
def test_textpad_rectangle(self):
1319+
# rectangle() draws a box with ACS line/corner characters.
1320+
win = curses.newwin(6, 12, 0, 0)
1321+
curses.textpad.rectangle(win, 0, 0, 4, 8)
1322+
chartext = curses.A_CHARTEXT
1323+
self.assertEqual(win.inch(0, 0) & chartext,
1324+
curses.ACS_ULCORNER & chartext)
1325+
self.assertEqual(win.inch(0, 8) & chartext,
1326+
curses.ACS_URCORNER & chartext)
1327+
self.assertEqual(win.inch(4, 0) & chartext,
1328+
curses.ACS_LLCORNER & chartext)
1329+
self.assertEqual(win.inch(4, 8) & chartext,
1330+
curses.ACS_LRCORNER & chartext)
1331+
self.assertEqual(win.inch(0, 1) & chartext,
1332+
curses.ACS_HLINE & chartext)
1333+
self.assertEqual(win.inch(1, 0) & chartext,
1334+
curses.ACS_VLINE & chartext)
1335+
1336+
def test_wrapper(self):
1337+
# wrapper() sets up curses, passes the screen to the callable along
1338+
# with extra arguments, returns its result and restores the terminal.
1339+
if not self.isatty:
1340+
self.skipTest('requires terminal')
1341+
1342+
def body(stdscr, a, b):
1343+
self.assertIsInstance(stdscr, type(self.stdscr))
1344+
self.assertIs(curses.isendwin(), False)
1345+
return a + b
1346+
1347+
self.assertEqual(curses.wrapper(body, 2, 3), 5)
1348+
self.assertIs(curses.isendwin(), True)
1349+
# wrapper() left the screen ended; revive it so the per-test
1350+
# endwin() cleanup does not fail with ERR.
1351+
curses.doupdate()
1352+
10771353
@requires_curses_func('is_term_resized')
10781354
def test_is_term_resized(self):
10791355
lines, cols = curses.LINES, curses.COLS

0 commit comments

Comments
 (0)