aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 14:49:17 -0400
committerhistoria <historiavg@proton.me>2026-08-24 14:49:17 -0400
commit7ee1d4bb63c12982ec4900ec870ad96baba4b22b (patch)
tree3f4261201b1bf05546c7c9241f7f3bcb4ea42b5e /app/tests
parente7a3d65f68659d17f37b79e8bfefea19d7ac0648 (diff)
downloadtts-audiobook-generator-7ee1d4bb63c12982ec4900ec870ad96baba4b22b.tar.gz
feat: combine wizard menus into a single generate options menu
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_hub.py239
-rw-r--r--app/tests/test_tui.py144
2 files changed, 312 insertions, 71 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 1b7630e..d244144 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -17,13 +17,15 @@ from ui import hub, tui
class _ScriptedTUI:
"""Stand-in for the tui widget module: answers each menu/line_edit/
- confirm call from a scripted answer list and records every prompt."""
+ confirm/form call from a scripted answer list and records every prompt."""
def __init__(self):
self.script = []
self.prompts = []
self.options_seen = []
self.flashes = []
+ self.form_script = [] # values dicts / None / sentinel for tui.form
+ self.forms_seen = [] # (title, fields, kwargs) for tui.form
def _next(self, prompt, options=None):
self.prompts.append(prompt)
@@ -41,6 +43,10 @@ class _ScriptedTUI:
def confirm(self, stdscr, question, **kwargs):
return self._next(question)
+ def form(self, stdscr, title, fields, **kwargs):
+ self.forms_seen.append((title, fields, kwargs))
+ return self.form_script.pop(0)
+
def flash(self, stdscr, text, kind="warn"):
self.flashes.append(text)
@@ -418,19 +424,30 @@ class SubmenuStatusTableTests(unittest.TestCase):
class ConvertFlowTests(unittest.TestCase):
- """_convert_audiocpp / _convert_faster: local-config menus vs. live
- queries against a running remote server."""
+ """_convert_audiocpp / _convert_qwen / _convert_faster: each backend
+ collects its settings on a single form (local config or live remote
+ queries)."""
def setUp(self):
self.tui = _ScriptedTUI()
- for name in ("menu", "line_edit", "confirm", "flash"):
+ for name in ("menu", "line_edit", "confirm", "form", "flash"):
patcher = patch.object(hub.tui, name, getattr(self.tui, name))
patcher.start()
self.addCleanup(patcher.stop)
- def _answer_common_options(self):
- # Output format, speed, single-file, debug.
- self.tui.script += ["m4b", "1.5", False, False]
+ def _form_values(self, **overrides):
+ """A fully-populated form result, with sensible defaults."""
+ values = {"output_format": "m4b", "speed": "1.0",
+ "single_file": False, "debug": False}
+ values.update(overrides)
+ return values
+
+ def _answer_form(self, **overrides):
+ self.tui.form_script.append(self._form_values(**overrides))
+
+ def _field(self, key, form_index=-1):
+ _, fields, _ = self.tui.forms_seen[form_index]
+ return next(f for f in fields if f["key"] == key)
# ------------------------------------------------------------------
# audio.cpp: remote server (no local checkout / server.json)
@@ -450,90 +467,121 @@ class ConvertFlowTests(unittest.TestCase):
patcher.start()
self.addCleanup(patcher.stop)
- def test_audiocpp_remote_queries_live_models_and_voices(self):
+ def test_audiocpp_remote_builds_one_form(self):
self._patch_remote(
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=["narrator"])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self.tui.script += ["higgs", "narrator", ""]
- self._answer_common_options()
- cmd = hub._convert_audiocpp(None, [])
+ self._answer_form(model_id="higgs", voice="narrator",
+ instructions="", speed="1.5")
+ cmd = hub._convert_audiocpp(None)
self.assertEqual(cmd[0], "convert")
self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP)
kwargs = cmd[2]
self.assertEqual(kwargs["model_id"], "higgs")
self.assertEqual(kwargs["voice"], "narrator")
self.assertIsNone(kwargs["instructions"])
- # The model menu was fed from the live query.
- self.assertEqual(self.tui.options_seen[0],
+ self.assertEqual(kwargs["output_format"], "m4b")
+ self.assertEqual(kwargs["speed"], 1.5)
+ self.assertFalse(kwargs["single_file"])
+ self.assertFalse(kwargs["debug"])
+ # 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, "Convert with audio.cpp")
+ self.assertEqual([f["key"] for f in fields],
+ ["model_id", "voice", "instructions",
+ "output_format", "speed", "single_file", "debug"])
+ self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel"))
+ self.assertTrue(form_kwargs["start_on_buttons"])
+ # The model menu was fed from the live query (label, id).
+ self.assertEqual(fields[0]["choices"],
[("higgs (higgs_audio_tts, tts)", "higgs")])
- def test_audiocpp_remote_qwen3_tts_offers_builtin_speaker_first(self):
+ def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self):
self._patch_remote(
[{"id": "qwen", "family": "qwen3_tts", "task": "tts"}],
voices=["narrator"])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self.tui.script += ["qwen", None, ""]
- self._answer_common_options()
- cmd = hub._convert_audiocpp(None, [])
+ self._answer_form(model_id="qwen", voice="(built-in speaker)",
+ instructions="")
+ cmd = hub._convert_audiocpp(None)
+ # The sentinel maps to "no voice" (built-in speaker).
self.assertIsNone(cmd[2]["voice"])
- self.assertEqual(self.tui.options_seen[1],
- [("(built-in speaker)", None), ("narrator", "narrator")])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("voice")
+ choices = voice_field["choices"](fields)
+ self.assertEqual(choices,
+ [("(built-in speaker)", "(built-in speaker)"),
+ ("narrator", "narrator")])
def test_audiocpp_remote_missing_family_treated_as_qwen3_tts(self):
# Legacy servers omit family/task; the converter defaults them to
- # qwen3_tts/tts and so must the menus (voice optional).
+ # qwen3_tts/tts and so must the form (voice optional).
self._patch_remote([{"id": "legacy", "family": "", "task": ""}],
voices=[])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- # Empty server voices: no Voice menu, built-in speaker implied.
- self.tui.script += ["legacy", ""]
- self._answer_common_options()
- cmd = hub._convert_audiocpp(None, [])
+ self._answer_form(model_id="legacy",
+ voice="(built-in speaker)", instructions="")
+ cmd = hub._convert_audiocpp(None)
self.assertIsNotNone(cmd)
self.assertIsNone(cmd[2]["voice"])
- def test_audiocpp_remote_vdes_needs_instructions_not_voice(self):
+ def test_audiocpp_vdes_hides_voice_and_requires_instructions(self):
self._patch_remote(
[{"id": "design", "family": "qwen3_tts", "task": "vdes"}])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self.tui.script += ["design", "A warm British narrator"]
- self._answer_common_options()
- cmd = hub._convert_audiocpp(None, [])
+ self._answer_form(model_id="design", voice=None,
+ instructions="A warm British narrator")
+ cmd = hub._convert_audiocpp(None)
self.assertIsNone(cmd[2]["voice"])
self.assertEqual(cmd[2]["instructions"], "A warm British narrator")
- # No voice prompt happened at all.
- self.assertNotIn("Voice", [p for p in self.tui.prompts])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("voice")
+ self.assertFalse(voice_field["visible"](fields))
+ instr = self._field("instructions")
+ self.assertIsNotNone(instr["validate"](""))
+ self.assertIsNone(instr["validate"]("describe me"))
+
+ def test_audiocpp_required_voice_validates(self):
+ # A non-qwen3_tts family needs a --voice; a blank value refuses.
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(model_id="higgs", voice="narrator",
+ instructions="")
+ hub._convert_audiocpp(None)
+ voice_field = self._field("voice")
+ self.assertIsNotNone(voice_field["validate"](""))
+ self.assertIsNone(voice_field["validate"]("narrator"))
def test_audiocpp_remote_unreachable_models_flash_and_abort(self):
self._patch_remote(None) # endpoint did not answer valid JSON
- cmd = hub._convert_audiocpp(None, [])
+ cmd = hub._convert_audiocpp(None)
self.assertIsNone(cmd)
self.assertIn("Could not list models", self.tui.flashes[0])
def test_audiocpp_remote_empty_models_flash_and_abort(self):
self._patch_remote([])
- cmd = hub._convert_audiocpp(None, [])
+ cmd = hub._convert_audiocpp(None)
self.assertIsNone(cmd)
self.assertIn("hosts no model entries", self.tui.flashes[0])
- def test_audiocpp_remote_no_server_voices_for_clone_model_aborts(self):
+ def test_audiocpp_remote_no_server_voices_leaves_voice_blank(self):
+ # No voices listed for a required-voice model: the form still opens
+ # with an empty Voice field (Generate-time validation reports it).
self._patch_remote(
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=[])
- self.tui.script += ["higgs"]
- cmd = hub._convert_audiocpp(None, [])
- self.assertIsNone(cmd)
- self.assertIn("lists none", self.tui.flashes[0])
-
- def test_audiocpp_remote_failed_voices_query_aborts(self):
- self._patch_remote(
- [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
- voices=None)
- self.tui.script += ["higgs"]
- cmd = hub._convert_audiocpp(None, [])
- self.assertIsNone(cmd)
- self.assertIn("Could not list voices", self.tui.flashes[0])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(model_id="higgs", voice="", instructions="")
+ cmd = hub._convert_audiocpp(None)
+ self.assertIsNotNone(cmd)
+ self.assertIsNone(cmd[2]["voice"])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("voice")
+ self.assertEqual(voice_field["choices"](fields), [])
# ------------------------------------------------------------------
# audio.cpp: local managed setup keeps reading its server.json
@@ -559,29 +607,87 @@ class ConvertFlowTests(unittest.TestCase):
patch.object(hub.audiocpp_backend, "fetch_server_models",
must_not_query), \
patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self.tui.script += ["qwen", "Narrator", ""]
- self._answer_common_options()
- cmd = hub._convert_audiocpp(None, [])
+ self._answer_form(model_id="qwen", voice="Narrator",
+ instructions="")
+ cmd = hub._convert_audiocpp(None)
self.assertEqual(queried, [])
self.assertIsNotNone(cmd)
self.assertEqual(cmd[2]["model_id"], "qwen")
self.assertEqual(cmd[2]["voice"], "Narrator")
# ------------------------------------------------------------------
+ # common fields: output format, speed, single-file, debug
+ # ------------------------------------------------------------------
+
+ def test_common_fields_hide_combine_for_m4b(self):
+ fields = hub._common_fields()
+ fmt = next(f for f in fields if f["key"] == "output_format")
+ single = next(f for f in fields if f["key"] == "single_file")
+ fmt["value"] = "m4b"
+ self.assertFalse(single["visible"](fields))
+ fmt["value"] = "mp3"
+ self.assertTrue(single["visible"](fields))
+
+ # ------------------------------------------------------------------
+ # qwen: speaker or clone
+ # ------------------------------------------------------------------
+
+ def test_qwen_builds_speaker_and_clone_form(self):
+ with patch.object(hub.qwen_backend, "QWEN_SPEAKERS",
+ ["Vivian", "Serena"]), \
+ patch.object(hub.config, "SPEAKER", "Vivian"):
+ self._answer_form(mode="custom", speaker="Serena", clone="")
+ with patch.object(hub.common, "update_config_value") as mk_update:
+ cmd = hub._convert_qwen(None)
+ speaker_in_memory = hub.config.SPEAKER
+ self.assertEqual(cmd[0], "convert")
+ self.assertEqual(cmd[1], hub.BACKEND_QWEN)
+ self.assertIsNone(cmd[2]["clone"])
+ # The speaker choice is persisted for future runs too.
+ mk_update.assert_called_once_with("SPEAKER", "Serena")
+ self.assertEqual(speaker_in_memory, "Serena")
+ fields = self.tui.forms_seen[0][1]
+ self.assertEqual([f["key"] for f in fields],
+ ["mode", "speaker", "clone", "output_format",
+ "speed", "single_file", "debug"])
+ mode_field = self._field("mode")
+ self.assertEqual(mode_field["choices"],
+ [("Built-in speaker", "custom"),
+ ("Clone from a .wav file", "clone")])
+ speaker_field = self._field("speaker")
+ clone_field = self._field("clone")
+ # Speaker shows in custom mode; the .wav path shows in clone mode.
+ self.assertTrue(speaker_field["visible"](fields))
+ self.assertFalse(clone_field["visible"](fields))
+ mode_field["value"] = "clone"
+ self.assertFalse(speaker_field["visible"](fields))
+ self.assertTrue(clone_field["visible"](fields))
+
+ def test_qwen_clone_mode_passes_path_and_keeps_speaker(self):
+ with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \
+ patch.object(hub.config, "SPEAKER", "Vivian"):
+ self._answer_form(mode="clone", speaker="Vivian",
+ clone="/tmp/ref.wav")
+ with patch.object(hub.common, "update_config_value") as mk_update:
+ cmd = hub._convert_qwen(None)
+ self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav")
+ # Clone mode does not touch the global speaker.
+ mk_update.assert_not_called()
+
+ # ------------------------------------------------------------------
# faster: remote server (no local voices.json)
# ------------------------------------------------------------------
- def test_faster_remote_prompts_for_a_voice_name(self):
+ def test_faster_remote_uses_a_text_voice_field(self):
with tempfile.TemporaryDirectory() as td:
with patch.object(hub.faster_backend, "_checkout",
return_value=Path(td)):
- self.tui.script += ["obama"]
- self._answer_common_options()
+ self._answer_form(voice="obama")
cmd = hub._convert_faster(None)
self.assertEqual(cmd[0], "convert")
self.assertEqual(cmd[1], "faster")
self.assertEqual(cmd[2]["voice"], "obama")
- self.assertIn("Server-side voice", self.tui.prompts[0])
+ self.assertEqual(self._field("voice")["kind"], "text")
def test_faster_local_still_lists_voices_json(self):
with tempfile.TemporaryDirectory() as td:
@@ -590,12 +696,13 @@ class ConvertFlowTests(unittest.TestCase):
json.dumps({"default": {}, "obama": {}}), encoding="utf-8")
with patch.object(hub.faster_backend, "_checkout",
return_value=checkout):
- self.tui.script += ["obama"]
- self._answer_common_options()
+ self._answer_form(voice="obama")
cmd = hub._convert_faster(None)
self.assertEqual(cmd[2]["voice"], "obama")
- # The voice came from a menu over voices.json, not a text field.
- self.assertIn("Select the voice to clone", self.tui.prompts[0])
+ voice_field = self._field("voice")
+ self.assertEqual(voice_field["kind"], "choice")
+ self.assertEqual(voice_field["choices"],
+ [("default", "default"), ("obama", "obama")])
class SelectSpecTests(unittest.TestCase):
@@ -690,15 +797,7 @@ class RunConversionTests(unittest.TestCase):
class AddAutostartTests(unittest.TestCase):
- """_add_autostart: offers to start the server when it isn't running."""
-
- def setUp(self):
- tui._THEME.clear()
- self.curses = FakeCurses()
- self._patcher = patch.dict("sys.modules", {"curses": self.curses})
- self._patcher.start()
- self.addCleanup(self._patcher.stop)
- self.addCleanup(tui._THEME.clear)
+ """_add_autostart: always starts the server when it isn't running."""
def _status(self):
spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
@@ -706,20 +805,18 @@ class AddAutostartTests(unittest.TestCase):
configured=True, running=False,
servers=[spec])
- def test_sets_autostart_when_user_confirms(self):
- screen = FakeScreen(keys=[10]) # Enter = Yes
+ def test_sets_autostart_when_not_running(self):
cmd = ("convert", "qwen", {"clone": None})
with patch.object(hub, "detect_all", return_value=[self._status()]), \
patch("backends.common.server_running", return_value=False):
- hub._add_autostart(screen, cmd, [self._status()])
+ hub._add_autostart(cmd, [self._status()])
self.assertEqual(cmd[2]["autostart"], "qwen-custom")
def test_no_autostart_when_server_already_running(self):
- screen = FakeScreen(keys=[10])
cmd = ("convert", "qwen", {"clone": None})
with patch.object(hub, "detect_all", return_value=[self._status()]), \
patch("backends.common.server_running", return_value=True):
- hub._add_autostart(screen, cmd, [self._status()])
+ hub._add_autostart(cmd, [self._status()])
self.assertNotIn("autostart", cmd[2])
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index d121c9a..ce408af 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -542,6 +542,150 @@ class FormTests(TuiTestCase):
# math highlighted the blank line).
self.assertEqual(texts[cursor], "Chunk: 250")
+ def test_custom_button_labels_and_start_on_buttons(self):
+ # buttons= overrides the labels; start_on_buttons=True means Enter
+ # alone (with no navigation) accepts the defaults.
+ screen = FakeScreen(keys=[10])
+ result = tui.form(screen, "Convert", self._fields(),
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True)
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+ texts = [text for _, _, text, _ in screen.strings]
+ self.assertIn("[ Generate! ]", texts)
+ self.assertIn("[ Cancel ]", texts)
+ self.assertNotIn("[ Save ]", texts)
+
+ def test_bool_field_toggles_with_enter(self):
+ fields = [{"key": "combine", "label": "Combine", "kind": "bool",
+ "value": False}]
+ # Enter toggles the bool to True, Tab -> button, Enter saves.
+ screen = FakeScreen(keys=[10, 9, 10])
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"combine": True})
+
+ def test_bool_field_toggles_with_space(self):
+ fields = [{"key": "combine", "label": "Combine", "kind": "bool",
+ "value": False}]
+ screen = FakeScreen(keys=[ord(" "), 9, 10])
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"combine": True})
+
+ def test_hidden_field_keeps_value_and_is_not_drawn(self):
+ fields = self._fields()
+ fields.append({"key": "hidden", "label": "Secret", "kind": "text",
+ "value": "kept", "visible": False})
+ screen = FakeScreen(keys=[9, 10])
+ result = tui.form(screen, "Settings", fields)
+ # The hidden field's value is preserved and returned.
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250",
+ "hidden": "kept"})
+ texts = [text for _, _, text, _ in screen.strings]
+ self.assertNotIn("Secret:", texts)
+
+ def test_visible_callable_hides_row_after_change(self):
+ fields = [
+ {"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "mp3", "choices": ["mp3", "m4b"]},
+ {"key": "combine", "label": "Combine", "kind": "bool",
+ "value": False,
+ "visible": lambda fs: next(
+ f["value"] for f in fs if f["key"] == "fmt") != "m4b"},
+ ]
+ snapshots = []
+ original_draw = tui.Frame.draw
+
+ def spy(frame):
+ original_draw(frame)
+ snapshots.append([row["text"] if row["segments"] is None
+ else "".join(t for t, _ in row["segments"])
+ for row in frame.rows])
+
+ # Enter on Format, Down to m4b, Enter; Tab -> button, Enter.
+ screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10])
+ with patch.object(tui.Frame, "draw", spy):
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"fmt": "m4b", "combine": False})
+ self.assertTrue(any("Combine:" in row for row in snapshots[0]))
+ self.assertFalse(any("Combine:" in row for row in snapshots[-1]))
+
+ def test_callable_choices_resolve_at_open(self):
+ fields = [{"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "a", "choices": lambda fs: ["a", "b", "c"]}]
+ screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10])
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"fmt": "b"})
+
+ def test_on_change_fires_after_value_change(self):
+ calls = []
+ fields = [{"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "a", "choices": ["a", "b"],
+ "on_change": lambda fs: calls.append("changed")}]
+ screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10])
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"fmt": "b"})
+ self.assertEqual(calls, ["changed"])
+
+ def test_choice_field_validation_refuses_save(self):
+ fields = [
+ {"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "bad", "choices": ["good", "bad"],
+ "validate": lambda v: None if v == "good" else "pick good"},
+ {"key": "other", "label": "Other", "kind": "text", "value": "x"},
+ ]
+ # Tab -> Save fails on fmt; the flash consumes the next key; then
+ # re-open fmt, Down to 'good', Enter; Tab -> Save, Enter.
+ keys = [9, 10, 10, 10, FakeCurses.KEY_DOWN, 10, 9, 10]
+ screen = FakeScreen(keys=keys)
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"fmt": "good", "other": "x"})
+
+ def _two_text_fields(self):
+ return [{"key": "first", "label": "First", "kind": "text",
+ "value": "a"},
+ {"key": "second", "label": "Second", "kind": "text",
+ "value": "b"}]
+
+ def test_up_from_buttons_lands_on_last_field(self):
+ # Focus starts on Generate!; Up wraps up through it onto the last
+ # field (the list wraps: Up from the first button -> the bottom).
+ screen = FakeScreen(keys=[
+ FakeCurses.KEY_UP, 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Convert", self._two_text_fields(),
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True)
+ self.assertEqual(result, {"first": "a", "second": "bx"})
+
+ def test_down_from_buttons_lands_on_first_field(self):
+ # Down wraps down through the buttons back onto the first field.
+ screen = FakeScreen(keys=[
+ FakeCurses.KEY_DOWN, 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Convert", self._two_text_fields(),
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True)
+ self.assertEqual(result, {"first": "ax", "second": "b"})
+
+ def test_k_from_buttons_lands_on_last_field(self):
+ screen = FakeScreen(keys=[ord("k"), 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Convert", self._two_text_fields(),
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True)
+ self.assertEqual(result, {"first": "a", "second": "bx"})
+
+ def test_tab_from_buttons_lands_on_first_field(self):
+ screen = FakeScreen(keys=[9, 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Convert", self._two_text_fields(),
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True)
+ self.assertEqual(result, {"first": "ax", "second": "b"})
+
+ def test_btab_from_buttons_lands_on_last_field(self):
+ screen = FakeScreen(keys=[FakeCurses.KEY_BTAB, 10, ord("x"), 10,
+ 9, 10])
+ result = tui.form(screen, "Convert", self._two_text_fields(),
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True)
+ self.assertEqual(result, {"first": "a", "second": "bx"})
+
def _accept_audio_cpp(entry: Path):
"""auto_select callback that accepts an 'audio.cpp' checkout root."""