feat: TUI modal dialogs (confirm Y/N, cert detail form)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -453,6 +453,138 @@ class InputField:
|
|||||||
self._cur += 1
|
self._cur += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# === TUI DIALOGS ===
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def show_confirm(stdscr, message: str) -> bool:
|
||||||
|
"""Y/N modal dialog. Returns True if user presses y/Y."""
|
||||||
|
sh, sw = stdscr.getmaxyx()
|
||||||
|
lines = message.splitlines()
|
||||||
|
h = len(lines) + 6
|
||||||
|
w = max(max(len(l) for l in lines) + 6, 38)
|
||||||
|
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
|
||||||
|
win.keypad(True)
|
||||||
|
draw_box(win, "Confirm")
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
try:
|
||||||
|
win.addstr(2 + i, 3, line, curses.color_pair(COLOR_NORMAL))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
prompt = " [Y] Confirm [N / Esc] Cancel "
|
||||||
|
try:
|
||||||
|
win.addstr(h - 2, max(1, (w - len(prompt)) // 2), prompt,
|
||||||
|
curses.color_pair(COLOR_NORMAL))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
win.refresh()
|
||||||
|
while True:
|
||||||
|
key = win.getch()
|
||||||
|
if key in (ord("y"), ord("Y")):
|
||||||
|
return True
|
||||||
|
if key in (ord("n"), ord("N"), 27, ord("q")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CertFormResult:
|
||||||
|
cn: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
cancelled: bool
|
||||||
|
|
||||||
|
|
||||||
|
_FORM_FIELDS = ["cn", "email", "password"]
|
||||||
|
_FORM_LABELS = {
|
||||||
|
"cn": "Common Name (CN):",
|
||||||
|
"email": "Email address:",
|
||||||
|
"password": "Password [F5 = regenerate]:",
|
||||||
|
}
|
||||||
|
_FORM_FIELD_Y = {"cn": 3, "email": 6, "password": 9}
|
||||||
|
|
||||||
|
|
||||||
|
def show_cert_form(
|
||||||
|
stdscr,
|
||||||
|
cn: str = "",
|
||||||
|
email: str = "",
|
||||||
|
cn_readonly: bool = False,
|
||||||
|
) -> CertFormResult:
|
||||||
|
"""Modal cert-detail form.
|
||||||
|
Tab / Shift-Tab cycle fields. F5 regenerates password.
|
||||||
|
Enter on last field confirms. Escape cancels."""
|
||||||
|
sh, sw = stdscr.getmaxyx()
|
||||||
|
h, w = 16, 62
|
||||||
|
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2)
|
||||||
|
win.keypad(True)
|
||||||
|
fw = w - 6 # field width
|
||||||
|
|
||||||
|
active = [n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)]
|
||||||
|
fields: dict[str, InputField] = {
|
||||||
|
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
|
||||||
|
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
|
||||||
|
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
|
||||||
|
initial=generate_password()),
|
||||||
|
}
|
||||||
|
focus = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
win.clear()
|
||||||
|
draw_box(win, "Certificate Details")
|
||||||
|
for name in _FORM_FIELDS:
|
||||||
|
fy = _FORM_FIELD_Y[name]
|
||||||
|
label = _FORM_LABELS[name]
|
||||||
|
ro = name == "cn" and cn_readonly
|
||||||
|
attr = curses.color_pair(COLOR_DISABLED if ro else COLOR_NORMAL)
|
||||||
|
try:
|
||||||
|
win.addstr(fy - 1, 3, label, attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
if ro:
|
||||||
|
try:
|
||||||
|
win.addstr(fy, 3, cn.ljust(fw)[:fw],
|
||||||
|
curses.color_pair(COLOR_DISABLED) | curses.A_UNDERLINE)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
fields[name].draw()
|
||||||
|
|
||||||
|
hint = "Tab=next F5=regen password Enter=confirm Esc=cancel"
|
||||||
|
try:
|
||||||
|
win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
win.refresh()
|
||||||
|
|
||||||
|
key = win.getch()
|
||||||
|
|
||||||
|
if key == 27:
|
||||||
|
return CertFormResult(cn=cn, email="", password="", cancelled=True)
|
||||||
|
if key == 9: # Tab
|
||||||
|
focus = (focus + 1) % len(active)
|
||||||
|
continue
|
||||||
|
if key == curses.KEY_BTAB: # Shift-Tab
|
||||||
|
focus = (focus - 1) % len(active)
|
||||||
|
continue
|
||||||
|
if key == curses.KEY_F5:
|
||||||
|
fields["password"] = InputField(
|
||||||
|
win, _FORM_FIELD_Y["password"], 3, fw, initial=generate_password(),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if key in (10, 13, curses.KEY_ENTER):
|
||||||
|
if focus < len(active) - 1:
|
||||||
|
focus += 1
|
||||||
|
continue
|
||||||
|
final_cn = cn if cn_readonly else fields["cn"].value
|
||||||
|
return CertFormResult(
|
||||||
|
cn=final_cn,
|
||||||
|
email=fields["email"].value,
|
||||||
|
password=fields["password"].value,
|
||||||
|
cancelled=False,
|
||||||
|
)
|
||||||
|
fields[active[focus]].handle_key(key)
|
||||||
|
|
||||||
|
|
||||||
# (remaining sections added in later tasks)
|
# (remaining sections added in later tasks)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|||||||
96
tests/test_dialogs.py
Normal file
96
tests/test_dialogs.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests for TUI DIALOGS (show_confirm, show_cert_form).
|
||||||
|
|
||||||
|
Uses the same mock-curses approach as test_widgets.py — no real terminal needed.
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import curses as _curses
|
||||||
|
|
||||||
|
# Stub curses constants before importing the module under test, mirroring
|
||||||
|
# what test_widgets.py does for InputField's dependencies.
|
||||||
|
_curses.color_pair = lambda x: 0
|
||||||
|
_curses.A_UNDERLINE = 0
|
||||||
|
_curses.A_BOLD = 0
|
||||||
|
_curses.KEY_BACKSPACE = 263
|
||||||
|
_curses.KEY_LEFT = 260
|
||||||
|
_curses.KEY_RIGHT = 261
|
||||||
|
_curses.KEY_HOME = 262
|
||||||
|
_curses.KEY_END = 360
|
||||||
|
_curses.KEY_DC = 330
|
||||||
|
_curses.KEY_BTAB = 353
|
||||||
|
_curses.KEY_F5 = 269
|
||||||
|
_curses.KEY_ENTER = 343
|
||||||
|
|
||||||
|
from openvpncertupdate import show_confirm, show_cert_form, CertFormResult
|
||||||
|
|
||||||
|
|
||||||
|
def _make_stdscr(rows=40, cols=120):
|
||||||
|
"""Return a minimal MagicMock that looks like a curses stdscr."""
|
||||||
|
stdscr = MagicMock()
|
||||||
|
stdscr.getmaxyx.return_value = (rows, cols)
|
||||||
|
return stdscr
|
||||||
|
|
||||||
|
|
||||||
|
def _make_win(rows=20, cols=60):
|
||||||
|
"""Return a minimal MagicMock for the subwindow created by curses.newwin."""
|
||||||
|
win = MagicMock()
|
||||||
|
win.getmaxyx.return_value = (rows, cols)
|
||||||
|
return win
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# show_confirm tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_show_confirm_yes():
|
||||||
|
"""Pressing 'y' returns True."""
|
||||||
|
stdscr = _make_stdscr()
|
||||||
|
win = _make_win()
|
||||||
|
win.getch.side_effect = [ord("y")]
|
||||||
|
with patch("curses.newwin", return_value=win):
|
||||||
|
result = show_confirm(stdscr, "Do you want to continue?")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_confirm_esc():
|
||||||
|
"""Pressing Escape (27) returns False."""
|
||||||
|
stdscr = _make_stdscr()
|
||||||
|
win = _make_win()
|
||||||
|
win.getch.side_effect = [27]
|
||||||
|
with patch("curses.newwin", return_value=win):
|
||||||
|
result = show_confirm(stdscr, "Do you want to continue?")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# show_cert_form tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_show_cert_form_cancel():
|
||||||
|
"""Pressing Escape returns a CertFormResult with cancelled=True."""
|
||||||
|
stdscr = _make_stdscr()
|
||||||
|
win = _make_win(rows=20, cols=70)
|
||||||
|
win.getch.side_effect = [27]
|
||||||
|
with patch("curses.newwin", return_value=win):
|
||||||
|
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
|
||||||
|
assert isinstance(result, CertFormResult)
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_cert_form_confirm():
|
||||||
|
"""Pressing Enter three times advances through all 3 fields and confirms.
|
||||||
|
|
||||||
|
The form has 3 active fields (cn, email, password). Enter on field 0
|
||||||
|
advances to field 1; Enter on field 1 advances to field 2; Enter on
|
||||||
|
field 2 (the last) confirms. We therefore need 3 Enter keypresses.
|
||||||
|
"""
|
||||||
|
stdscr = _make_stdscr()
|
||||||
|
win = _make_win(rows=20, cols=70)
|
||||||
|
# Three Enter keypresses to advance through each field and confirm.
|
||||||
|
win.getch.side_effect = [10, 10, 10]
|
||||||
|
with patch("curses.newwin", return_value=win):
|
||||||
|
result = show_cert_form(stdscr, cn="bob", email="bob@example.com")
|
||||||
|
assert isinstance(result, CertFormResult)
|
||||||
|
assert result.cancelled is False
|
||||||
|
assert result.cn == "bob"
|
||||||
|
assert result.email == "bob@example.com"
|
||||||
|
assert len(result.password) > 0
|
||||||
Reference in New Issue
Block a user