Skip to content

Add UI tests for chess.py and fix four latent bugs they exposed#1

Open
BrandonZacharie wants to merge 7 commits into
mainfrom
claude/review-chess-py-wktEg
Open

Add UI tests for chess.py and fix four latent bugs they exposed#1
BrandonZacharie wants to merge 7 commits into
mainfrom
claude/review-chess-py-wktEg

Conversation

@BrandonZacharie
Copy link
Copy Markdown
Owner

Summary

Adds a UI test layer for chess.py (the curses front end) and fixes four bugs the new tests pinned down. The tests came first; each fix is a separate commit that flips one strict-xfail to green so individual fixes can be reverted in isolation if anything looks off when exercised manually.

The fixes are deliberately minimal — none of them change the visible behavior of paths that were already working, only the paths that crashed, corrupted input, or left the curses panel deck out of sync with the screen.

Tests

tests/test_chess_ui.py (37 tests, ~0.3 s). The curses Window and module-level helpers (panel, doupdate, curs_set) are replaced with unittest.mock fakes so the suite runs anywhere without a terminal and without re-parsing the heavy PGN fixtures.

Area Count Notes
check_window_maxyx 3 happy path, retry on too-small window, custom min size
Menu pagination & insert 3 including the caller-list-mutation case
Menu.navigate 7 within page, across pages, large jumps, the len(items) % page_size == 0 edge
Menu.display 5 ESC, ENTER on exit item, ENTER on callback, callback raising KeyboardInterrupt, panel.hide() call count
Configuration 1 default log_style
Chess._fileprompt 6 ESC, ENTER, BACKSPACE, arrow keys mid-string, handler returning False, curses special keys
Chess.__init__ 4 root menu entries, all sub-menus, no active game, cli.main not called on ESC
Chess.play 4 "continue…" / "save file" insertion, no duplication on repeat, main arguments, position reset
Chess._load_pgn 3 missing file, test1.pgn, test2.pgn (covers the .?? partial-date branch)

Fixes

Each commit is a focused diff; the bug-fix commits are in increasing order of risk so a git revert on the top commit alone can roll back the panel-lifecycle change while leaving the others.

  • 4a77e1f — Stop Menu from mutating the caller's items list. __init__ appended "exit" to the list it received. Today every caller passes a throwaway literal, so the side effect is invisible — but two Menus sharing a list would have produced duplicate "exit" rows. Fix: self.items = list(items).
  • 6e6a28d — Clamp Menu.navigate to the actual last page index. The forward branch clamped self.page to len(items) // page_size, which is one past the last valid page when len(items) is an exact multiple of page_size (e.g. 50 items / 25 per page → clamp said 2, but only pages 0 and 1 exist). Switching to len(self.pages) - 1 is identical in every other case.
  • 60bdb59 — Drop curses special keys in _fileprompt's default branch. The catch-all arm called chr(key) for every unhandled keycode, so F-keys, KEY_RESIZE, KEY_MOUSE, etc. either inserted garbage into the filename or raised ValueError for codes outside the Unicode range. Now gated on key < 0x100, which preserves every byte 0–255 (including UTF-8 continuation bytes) and ignores only the curses KEY_* constants (all at 0o401+).
  • 282a026 — Hide Menu panel once on display() exit, not after every keypress. panel.hide() / panel.update_panels() / doupdate() lived inside the keypress loop, so the panel was removed from the panel deck after every navigation key. Rendering still worked because window.refresh() bypasses the panel mechanism — but the deck state diverged from what was on screen, which would surface as wrong z-ordering once the file-prompt + nested-game-picker flow stacked panels. The per-iteration window.clear() that clears stale rendering stays where it is. As a bonus, cleanup now also runs when display() exits via a callback's KeyboardInterrupt (the way submenus unwind), where it was previously skipped.

Test plan

  • coverage run -m pytest -xsvv — full suite (UI + engine) passes.
  • python -m pytest tests/test_chess_ui.py — UI suite passes on its own in well under a second.
  • Manual: open the root menu, arrow up/down, enter options → log style, pick a style, return to root.
  • Manual: load file → PGN, type a path to a multi-game .pgn, pick a game from the nested menu, return to root.
  • Manual: at the file prompt, type a path with arrow-key edits, backspace, Home/End — behavior unchanged from before.
  • Manual: at the file prompt, press F1 — previously inserted a garbage char; should now do nothing.

https://claude.ai/code/session_01RnQvxtXfn9JFTN8jpNyriL


Generated by Claude Code

claude added 7 commits May 19, 2026 01:36
Covers Menu pagination/navigation/display and Chess._fileprompt by
mocking the curses Window plus the panel/doupdate/curs_set helpers, so
the suite runs in milliseconds and does not require a terminal.

Four bugs surfaced in review are pinned with xfail(strict=True) so the
suite stays green today, but flips red the moment a fix lands and the
marker needs removing:

- Menu mutates the caller's items list (appends "exit" in place)
- Menu.navigate clamps self.page to len(items)//page_size, which is
  one past the end when items align with page_size
- Menu.display hides the panel inside the keypress loop instead of on
  exit
- Chess._fileprompt feeds every unhandled keycode through chr(), so
  curses special keys leak into the path
Adds three test classes that round out the UI test file:

- TestChessInit constructs Chess with ESC queued as the first keypress
  so the root-menu loop exits immediately, then asserts the root-menu
  entries, the presence of all sub-menus, and that cli.main was not
  invoked.
- TestChessPlay drives Chess.play() on the same instance with
  cli.main patched out, pinning the "continue…" / "save file"
  insertions and verifying repeat calls don't duplicate entries.
- TestLoadPgn runs _load_pgn against tests/test1.pgn and tests/test2.pgn
  (covering the partial-date branch in the menu label formatter) and a
  missing path, all via a _BareChess stand-in.

Lifts _load_pgn onto _BareChess and switches the chess_instance
fixture to yield inside the patch context so the mock outlives
construction.
Menu.__init__ appended 'exit' to the list it received. Today the
callers only pass throwaway literals, so the mutation is invisible —
but the moment two Menus shared a list (or a caller kept a reference
expecting the original entries) the side effect would surface as
duplicate 'exit' rows or surprise list contents.

Copy the list on the way in so the caller's reference is unaffected.
Internal behavior is unchanged: self.items is still the working list,
the appended 'exit' still lands at the end of it.
The forward branch clamped self.page to len(items)//page_size, which
is one past the last valid page when len(items) is an exact multiple
of page_size (e.g. 50 items / 25 per page → clamp said 2, but only
pages 0 and 1 exist). Navigating off the end of the last page in that
configuration set self.page out of bounds, and the next access to
self.pages[self.page] would IndexError on the next render.

Switching the clamp to len(self.pages) - 1 is mathematically the same
in every other case but always lands on a valid index.
The catch-all match arm inserted chr(key) into the prompt for any
keycode that wasn't named in an earlier case. For curses special keys
(F1, KEY_RESIZE, KEY_MOUSE, …) that meant either a garbage character
in the filename or a ValueError once the codepoint exceeded the
Unicode range.

Gate on key < 0x100 so every byte that worked before (including the
high range used for UTF-8 continuation bytes) still inserts, and only
the curses-specific KEY_* codes (which all live at 0o401+) are
ignored.
The panel.hide() / panel.update_panels() / doupdate() block lived
inside the keypress loop, so the panel was removed from the panel
deck after every navigation key. Rendering still worked because the
loop's window.refresh() bypasses the panel mechanism — but the deck
state diverged from what was on screen, which would surface as
wrong z-ordering when multiple Menus were layered (the file-prompt
flow can stack one over another).

Move only the panel hide / update / doupdate calls out of the loop;
the per-iteration window.clear() that clears stale rendering stays
where it is. As a bonus, the cleanup now also runs when display()
exits via a callback's KeyboardInterrupt (which is how submenus
unwind), where previously it was skipped.
Cosmetic only — no behavior change. Brings the file in line with the
formatter pinned in pyproject.toml so the CI lint job passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants