aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 17:46:02 -0400
committerhistoria <historiavg@proton.me>2026-08-27 17:46:02 -0400
commit2a9a78dd1caec0811ed2640b28375890ee96e4cc (patch)
tree6aebbb503a98eb035015ff56e51d4ccc4f4a395e /app/tests
parentcef2352a5e81b272d067c2c02eb9588e54edfcfd (diff)
downloadtts-audiobook-generator-2a9a78dd1caec0811ed2640b28375890ee96e4cc.tar.gz
feat: help menu in tui
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_backends.py4
-rw-r--r--app/tests/test_hub.py109
-rw-r--r--app/tests/test_tui.py106
3 files changed, 171 insertions, 48 deletions
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index d4534fb..a49a2f2 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -650,7 +650,7 @@ class QwenModelsScreenTests(unittest.TestCase):
("Install Base", ("install", "Base")),
("Install VoiceDesign", ("install", "VoiceDesign")),
])
- self.assertEqual(kwargs["table_title"], "Model state")
+ self.assertEqual(kwargs["table_title"], "Model State")
self.assertEqual(kwargs["table_rows"][0],
("CustomVoice", "installed", "ok"))
@@ -707,7 +707,7 @@ class QwenModelsScreenTests(unittest.TestCase):
# No task-view run, one guidance flash instead of a download.
self.assertEqual(runs, [])
guidance = ("Install the qwen-tts backend first "
- "(Configure backends > Install Backend).")
+ "(Configure Backends > Install Backend).")
self.assertEqual(flashes, [(guidance, "warn")])
# While the backend is missing, Install is replaced by dimmed-out
# "(backend not installed)" placeholders — actions stay inert.
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 4186c92..798c27f 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -143,9 +143,11 @@ class HubMenuTests(unittest.TestCase):
return BackendStatus(key, label, installed=False, configured=False)
def test_quit_returns_none_when_no_backend(self):
- # No backends installed/running: menu is [Configure backends,
- # Settings, Quit]. Quit is the 3rd option (Down twice) then Enter.
- screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10])
+ # No backends installed/running: menu is [Configure Backends,
+ # Settings, Help, Quit]. Quit is the 4th option (Down x3) then
+ # Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10])
with patch.object(hub, "detect_all", return_value=[]):
result = hub._Hub(screen).run()
self.assertIsNone(result)
@@ -190,7 +192,8 @@ class HubMenuTests(unittest.TestCase):
patch.object(hub, "detect_all", return_value=[]):
hub._Hub(screen).run()
labels = [label for label, _ in captured["options"]]
- self.assertEqual(labels, ["Configure backends", "Settings", "Quit"])
+ self.assertEqual(labels,
+ ["Configure Backends", "Settings", "Help", "Quit"])
def test_menu_has_all_five_when_one_installed(self):
captured = {}
@@ -209,8 +212,8 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Generate audiobooks", "Configure backends",
- "Start/Stop Backend Servers", "Settings", "Quit"])
+ ["Generate Audiobooks", "Configure Backends",
+ "Start/Stop Backend Servers", "Settings", "Help", "Quit"])
# The status table is passed through, one row per backend.
self.assertEqual(captured["rows"],
[("qwen-tts", "installed", "ok", "body")])
@@ -256,8 +259,8 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Generate audiobooks", "Configure backends", "Settings",
- "Quit"])
+ ["Generate Audiobooks", "Configure Backends", "Settings",
+ "Help", "Quit"])
def test_ffmpeg_warning_shown_when_missing(self):
# ffmpeg not on PATH → a red notice is passed above the table.
@@ -293,8 +296,8 @@ class HubMenuTests(unittest.TestCase):
def test_convert_with_no_available_backend_flashes(self):
# Installed-but-not-ready backends → Convert is offered, but the
# convert flow has nothing to list: it flashes a hint (no "Configure
- # backends" detour anymore) and returns to the main menu. Then
- # quit: 5 main-menu options, Quit is the 5th (Down x4).
+ # Backends" detour anymore) and returns to the main menu. Then
+ # quit: 6 main-menu options, Quit is the 6th (Down x5).
from backends import BackendStatus
statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
configured=False),
@@ -309,16 +312,54 @@ class HubMenuTests(unittest.TestCase):
with patch.object(hub, "detect_all", return_value=statuses), \
patch.object(hub.tui, "flash", fake_flash):
- # Convert(Enter) → flash → main menu; Down x4 -> Quit, Enter.
+ # Convert(Enter) → flash → main menu; Down x5 -> Quit, Enter.
screen = FakeScreen(keys=[10,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN,
10])
result = hub._Hub(screen).run()
self.assertIsNone(result)
self.assertEqual(len(flashed), 1)
self.assertIn("No backend is ready", flashed[0])
+ def test_help_opens_viewer_and_backs_out(self):
+ # Selecting Help opens the text viewer with the quick-start text
+ # (real folder paths); closing it lands back on the main menu.
+ calls = []
+
+ def fake_viewer(stdscr, title, lines, **kwargs):
+ calls.append((title, list(lines), kwargs))
+ return kwargs.get("back_value")
+
+ with patch.object(hub, "detect_all", return_value=[]), \
+ patch.object(hub.tui, "text_viewer", fake_viewer):
+ # Help is the 3rd main-menu option (Down x2), then Enter;
+ # back on the main menu Quit is the 4th (Down x3), Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ 10,
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN,
+ 10])
+ result = hub._Hub(screen).run()
+ self.assertIsNone(result)
+ self.assertEqual(len(calls), 1)
+ title, lines, kwargs = calls[0]
+ self.assertEqual(title, "Help")
+ self.assertIs(kwargs.get("back_value"), tui.Wizard.BACK)
+ text = "\n".join(lines)
+ self.assertIn("1. Put your ebooks (epub, txt, or pdf) here:", text)
+ self.assertIn(str(hub.BOOKS_FOLDER), text)
+ self.assertIn("2. Put any .wavs of voices to clone here:", text)
+ self.assertIn(str(hub.common.VOICES_DIR), text)
+ self.assertIn("Install Backend and install audio.cpp.", text)
+ self.assertIn("qwen3_tts_1_7b_base_q8_0", text)
+ self.assertIn("qwen3_tts_1_7b_customvoice_q8_0", text)
+ self.assertIn("5. Go to Generate Audiobooks.", text)
+ self.assertIn("6. Generated audiobooks (m4b, mp3, etc.) will "
+ "output here:", text)
+ self.assertIn(str(hub.AUDIOBOOKS_FOLDER), text)
+
class SubmenuStatusTableTests(unittest.TestCase):
"""First picker screen of every flow repeats the backend status table.
@@ -375,7 +416,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
self.assertEqual([label for label, _ in captured["options"]],
["Install Backend", "Uninstall Backend"])
# ...the shared status table carries the states instead.
- self.assertEqual(captured["table_title"], "Backend status")
+ self.assertEqual(captured["table_title"], "Backend Status")
self.assertEqual(
captured["table_rows"],
[("qwen-tts", "installed", "ok", "body"),
@@ -421,7 +462,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
# the whole set of installed backends (faster has nothing on disk
# and so contributes nothing).
self.assertEqual([label for label, _ in captured["options"]],
- ["Install Backend", "Update backends",
+ ["Install Backend", "Update Backends",
"Uninstall Backend"])
def test_configure_backends_menu_audiocpp_model_actions(self):
@@ -492,9 +533,9 @@ class SubmenuStatusTableTests(unittest.TestCase):
# model download stays hidden until the binary exists — Build and
# Download never coexist. Configure needs an installed (built)
# backend.
- self.assertEqual(labels, ["Build audio.cpp server", "Uninstall Backend"])
+ self.assertEqual(labels, ["Build audio.cpp Server", "Uninstall Backend"])
self.assertEqual(captured["options"][0],
- ("Build audio.cpp server", "build_audiocpp",
+ ("Build audio.cpp Server", "build_audiocpp",
("[recommended]", "warn")))
self.assertIs(captured["options"][1], tui.MENU_SEPARATOR)
@@ -520,7 +561,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
result = hub._Hub(None).screen_configure()
self.assertIs(result, tui.Wizard.BACK)
labels = self._labels(captured["options"])
- self.assertNotIn("Build audio.cpp server", labels)
+ self.assertNotIn("Build audio.cpp Server", labels)
def test_configure_backends_menu_configure_only_when_built_unconfigured(self):
captured = {}
@@ -607,7 +648,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
def test_bare_qwen_without_configure_screen_still_has_no_entry(self):
# Without a dedicated configure screen, plain qwen stays excluded
- # from Configure backends (its wizard asks nothing to configure).
+ # from Configure Backends (its wizard asks nothing to configure).
captured = {}
infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0,
uninstall=lambda **kwargs: 0)]
@@ -633,7 +674,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._Hub(None).screen_convert()
self.assertIs(result, tui.Wizard.BACK)
- self.assertEqual(captured["title"], "Generate audiobooks")
+ self.assertEqual(captured["title"], "Generate Audiobooks")
# One form, no picker menu: the first field is the Backend picker,
# and only convertible backends are offered in it.
self.assertEqual(captured["fields"][0]["key"], "backend")
@@ -677,7 +718,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._Hub(None).screen_configure()
self.assertIs(result, tui.Wizard.BACK)
- self.assertEqual(captured["table_title"], "Backend status")
+ self.assertEqual(captured["table_title"], "Backend Status")
self.assertEqual(
captured["table_rows"], [("qwen-tts", "installed", "ok",
"body")])
@@ -706,7 +747,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
["audio.cpp"])
# The running/stopped state lives in the status table above the
# menu (not on the entries, whose colors the selection bar covers).
- self.assertEqual(captured["table_title"], "Server status")
+ self.assertEqual(captured["table_title"], "Server Status")
self.assertEqual(captured["table_rows"],
[("audio.cpp", "stopped", "err", "body")])
@@ -863,7 +904,7 @@ class ConvertFlowTests(unittest.TestCase):
# One form, not a cascade of menus/editors.
self.assertEqual(len(self.tui.forms_seen), 1)
title, fields, form_kwargs = self.tui.forms_seen[0]
- self.assertEqual(title, "Generate audiobooks")
+ self.assertEqual(title, "Generate Audiobooks")
self.assertEqual([f["key"] for f in fields],
["backend", "audiocpp-remote.model_id",
"audiocpp-remote.audiocpp_voice",
@@ -991,7 +1032,7 @@ class ConvertFlowTests(unittest.TestCase):
def test_audiocpp_local_without_voice_dir_points_at_configure(self):
# The managed entry's server.json has no voice_dir: clone-capable
# models get an empty Voice picker whose hint sends the user to
- # Configure backends instead of crashing on menu().
+ # Configure Backends instead of crashing on menu().
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "server.json").write_text(json.dumps({
@@ -1012,7 +1053,7 @@ class ConvertFlowTests(unittest.TestCase):
error = voice_field["validate"]("")
self.assertIsNotNone(error)
self.assertIn(".wav", error)
- self.assertIn("Configure backends", error)
+ self.assertIn("Configure Backends", error)
def test_audiocpp_remote_missing_family_is_clone_capable(self):
# A missing family is unknown — not guessed as qwen3_tts — so the
@@ -2213,7 +2254,7 @@ class SettingsTests(unittest.TestCase):
self.assertIsNotNone(hub._validate_port("abc"))
def test_language_fields_are_pickers_with_edit_hint(self):
- # Both Language fields (Settings and Generate audiobooks) are
+ # Both Language fields (Settings and Generate Audiobooks) are
# static pickers over the audio.cpp-menu languages, with a dim
# hint inside their edit dialog. Common languages lead.
expected_choices = ["English", "Spanish", "Chinese", "French",
@@ -2820,12 +2861,12 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
return_value=0) as mk_run, \
patch_flash:
hub._update_backends_action(None)
- # One task-view run titled "Update backends", one step per
+ # One task-view run titled "Update Backends", one step per
# updatable backend in registry order; executing a step
# forwards emit/cancel to that backend's update.
mk_run.assert_called_once()
self.assertEqual(mk_run.call_args[0][0], None)
- self.assertEqual(mk_run.call_args[0][1], "Update backends")
+ self.assertEqual(mk_run.call_args[0][1], "Update Backends")
steps = mk_run.call_args[0][2]
self.assertEqual([step.title for step in steps],
["Update audio.cpp", "Update qwen-tts"])
@@ -2908,7 +2949,7 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
self.assertEqual(invalidated, [True])
# An inline action: the same menu re-shows (second title) with a
# freshly detected status table.
- self.assertEqual(titles, ["Configure backends", "Configure backends"])
+ self.assertEqual(titles, ["Configure Backends", "Configure Backends"])
def test_pick_backend_install_lists_uninstalled_only(self):
captured = {}
@@ -3067,7 +3108,7 @@ class HubNavigationTests(unittest.TestCase):
def test_esc_on_wizard_first_screen_returns_to_configure(self):
# The reported bug: Esc on the audio.cpp "Select TTS model
# families" tree (the wizard's first screen) must land back on
- # "Configure backends", not the main menu.
+ # "Configure Backends", not the main menu.
info = self._info()
with patch.object(info, "setup_screen", return_value=1):
titles = self._drive(
@@ -3076,8 +3117,8 @@ class HubNavigationTests(unittest.TestCase):
[self._status()], [info])
self.assertEqual(
titles,
- ["tts-audiobook-generator", "Configure backends",
- "Configure backends", "tts-audiobook-generator"])
+ ["tts-audiobook-generator", "Configure Backends",
+ "Configure Backends", "tts-audiobook-generator"])
def test_esc_on_install_picker_returns_to_configure(self):
registry = [self._info("audiocpp", "audio.cpp"),
@@ -3091,8 +3132,8 @@ class HubNavigationTests(unittest.TestCase):
statuses, registry)
self.assertEqual(
titles,
- ["tts-audiobook-generator", "Configure backends",
- "Install Backend", "Configure backends",
+ ["tts-audiobook-generator", "Configure Backends",
+ "Install Backend", "Configure Backends",
"tts-audiobook-generator"])
def test_esc_on_server_action_returns_one_screen_at_a_time(self):
@@ -3122,8 +3163,8 @@ class HubNavigationTests(unittest.TestCase):
# Esc steps back one screen at a time to the server list and main.
self.assertEqual(
titles,
- ["tts-audiobook-generator", "Start / Stop a server",
- "Start / Stop a server", "tts-audiobook-generator"])
+ ["tts-audiobook-generator", "Start / Stop A Server",
+ "Start / Stop A Server", "tts-audiobook-generator"])
def test_esc_on_main_menu_quits(self):
titles = self._drive([tui.Wizard.BACK], [], [])
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index eb6872a..916f434 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -267,7 +267,7 @@ class MenuTests(TuiTestCase):
def test_suffix_renders_in_its_theme_color(self):
# default_index=1 keeps the suffixed option unselected, so its
# segments keep their own colors instead of the cursor bar.
- options = [("Build audio.cpp server", "build",
+ options = [("Build audio.cpp Server", "build",
("[recommended]", "warn")), ("other", "other")]
screen = FakeScreen(keys=[10])
tui.menu(screen, "Pick", options, default_index=1)
@@ -276,7 +276,7 @@ class MenuTests(TuiTestCase):
if drawn == text)
self.assertEqual(attr, tui._THEME["warn"])
label_attr = next(a for _, _, drawn, a in screen.strings
- if drawn == "Build audio.cpp server")
+ if drawn == "Build audio.cpp Server")
self.assertEqual(label_attr, tui._THEME["body"])
def test_selected_row_has_arrow_in_the_margin(self):
@@ -317,7 +317,7 @@ class MenuTableTests(TuiTestCase):
def test_name_column_left_aligned_at_margin(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
for name, _, _ in self.ROWS:
@@ -328,7 +328,7 @@ class MenuTableTests(TuiTestCase):
def test_status_column_aligned_at_one_fixed_offset(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
name_w = max(len(name) for name, _, _ in self.ROWS)
@@ -341,7 +341,7 @@ class MenuTableTests(TuiTestCase):
def test_status_text_uses_the_theme_kind_color(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"],
"ok": tui._THEME["ok"]}
for _, status, kind in self.ROWS:
@@ -362,7 +362,7 @@ class MenuTableTests(TuiTestCase):
def test_three_element_rows_default_to_body_names(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
for name, _, _ in self.ROWS:
attr = next(a for _, _, text, a in screen.strings
if text.rstrip() == name)
@@ -371,24 +371,24 @@ class MenuTableTests(TuiTestCase):
def test_table_title_is_dim_and_left_aligned(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
x, attr = next((x, a) for _, x, text, a in screen.strings
- if text == "Backend status")
+ if text == "Backend Status")
self.assertEqual(x, margin)
self.assertEqual(attr, tui._THEME["dim"])
def test_table_does_not_paint_over_the_border(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
self.assert_inside_border(screen)
def test_notice_line_is_red_and_above_the_table(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS,
+ table_title="Backend Status", table_rows=self.ROWS,
notice_lines=[("Warning: ffmpeg not installed!", "err")])
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
@@ -396,11 +396,11 @@ class MenuTableTests(TuiTestCase):
if text == "Warning: ffmpeg not installed!")
self.assertEqual(x, margin)
self.assertEqual(attr, tui._THEME["err"])
- # The notice sits above the table title ("Backend status").
+ # The notice sits above the table title ("Backend Status").
notice_y = next(y for y, _, text, _ in screen.strings
if text == "Warning: ffmpeg not installed!")
title_y = next(y for y, _, text, _ in screen.strings
- if text == "Backend status")
+ if text == "Backend Status")
self.assertLess(notice_y, title_y)
self.assert_inside_border(screen)
@@ -1283,6 +1283,88 @@ class FlashTests(TuiTestCase):
self.assertIsNone(frame.status)
+class TextViewerTests(TuiTestCase):
+ """tui.text_viewer: a scrollable read-only dialog; Esc/q/Enter closes."""
+
+ LINES = ["first line", "second line", "third line"]
+
+ def test_lines_render_and_enter_closes(self):
+ marker = object()
+ screen = FakeScreen(keys=[10])
+ self.assertIs(
+ tui.text_viewer(screen, "Help", self.LINES, back_value=marker),
+ marker)
+ for line in self.LINES:
+ self.assertTrue(any(text == line for _, _, text, _
+ in screen.strings), line)
+ self.assert_inside_border(screen)
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.text_viewer(screen, "Help", self.LINES, back_value=marker),
+ marker)
+
+ def test_q_returns_back_value_like_esc(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ self.assertIs(
+ tui.text_viewer(screen, "Help", self.LINES, back_value=marker),
+ marker)
+
+ def test_esc_aborts_without_back_value(self):
+ screen = FakeScreen(keys=[27])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.text_viewer(screen, "Help", self.LINES)
+
+ def test_enter_aborts_without_back_value(self):
+ screen = FakeScreen(keys=[10])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.text_viewer(screen, "Help", self.LINES)
+
+ def test_no_cursor_bar_is_drawn(self):
+ # Read-only: no row is selectable, so even with the cursor on a
+ # line the cyan selection bar never paints.
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")])
+ tui.text_viewer(screen, "Help", self.LINES, back_value=object())
+ bars = [s for s in screen.strings
+ if s[3] == tui._THEME["bar"] and not s[2].strip()]
+ self.assertEqual(bars, [])
+
+ def test_end_scrolls_the_last_line_into_view(self):
+ # Content taller than the terminal: End shows the last line and
+ # the frame's scroll indicator appears in the border.
+ lines = [f"line {i}" for i in range(40)]
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ drawn = [text for _, _, text, _ in screen.strings]
+ self.assertIn("line 39", drawn)
+ self.assertTrue(any("/40" in text for text in drawn))
+
+ def test_home_returns_to_the_top(self):
+ lines = [f"line {i}" for i in range(40)]
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, FakeCurses.KEY_HOME,
+ ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ drawn = [text for _, _, text, _ in screen.strings]
+ self.assertIn("line 0", drawn)
+
+ def test_page_down_moves_a_full_page(self):
+ # PageDown from the top lands one page (17 visible rows) down:
+ # the first line scrolls off, the next one is at the top.
+ lines = [f"line {i}" for i in range(40)]
+ screen = FakeScreen(keys=[FakeCurses.KEY_NPAGE, ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ # Only the final frame counts: strings accumulate across redraws.
+ titles = [i for i, entry in enumerate(screen.strings)
+ if entry[2] == " Help "]
+ drawn = [entry[2] for entry in screen.strings[titles[-1]:]]
+ self.assertIn("line 1", drawn)
+ self.assertNotIn("line 0", drawn)
+ self.assertIn(" 2/40 ", drawn)
+
+
class WizardTests(unittest.TestCase):
"""The tui.Wizard screen-stack driver: Esc steps back one screen."""