From 65c6f737f1545ef225768af897acd20f163a4fb4 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 20:43:05 -0400 Subject: fix: settings menu only prompts to save after change --- app/tests/test_backends_audiocpp.py | 73 +++++++++++++++ app/tests/test_hub.py | 171 +++++++++++++++++++++++++++++++++++- app/tests/test_tui.py | 26 ++++++ 3 files changed, 266 insertions(+), 4 deletions(-) (limited to 'app/tests') diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index d364c06..ab405aa 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -406,6 +406,18 @@ class FindLocalCheckoutTests(unittest.TestCase): self.assertIsNone(make_server.build.find_local_checkout()) +def _add_options_to_spec(checkout: Path, family: str, *, + options=None) -> None: + """Rewrite one family spec with an (optional) options block.""" + path = checkout / "model_specs" / f"{family}.json" + spec = json.loads(path.read_text(encoding="utf-8")) + if options is not None: + spec["options"] = options + elif "options" in spec: + del spec["options"] + path.write_text(json.dumps(spec), encoding="utf-8") + + class LoadModelCatalogTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -468,6 +480,67 @@ class LoadModelCatalogTests(unittest.TestCase): make_server.catalog.load_model_catalog(empty) +class RequestOptionsFamiliesTests(unittest.TestCase): + """request_options_families: which specs declare request options.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = _make_checkout(Path(self._td.name)) + _add_options_to_spec( + self.checkout, "higgs_audio_tts", + options={"request": [{"id": "temperature", "default": 0.8}, + {"id": "speed"}]}) + + def tearDown(self): + self._td.cleanup() + + def test_family_with_request_options_listed_with_display_name(self): + families = make_server.request_options_families(self.checkout) + self.assertEqual(families.get("higgs_audio_tts"), + {"display_name": "Higgs Audio v3 TTS 4B"}) + + def test_family_without_options_block_absent(self): + families = make_server.request_options_families(self.checkout) + self.assertNotIn("qwen3_tts", families) + self.assertNotIn("voxcpm2", families) + + def test_empty_request_list_does_not_count_as_support(self): + _add_options_to_spec(self.checkout, "supertonic", + options={"request": []}) + families = make_server.request_options_families(self.checkout) + self.assertNotIn("supertonic", families) + + def test_missing_specs_dir_yields_empty_map(self): + self.assertEqual(make_server.request_options_families( + Path(self._td.name)), {}) + + def test_unparsable_spec_skipped(self): + (self.checkout / "model_specs" / "broken.json").write_text( + "{not json", encoding="utf-8") + families = make_server.request_options_families(self.checkout) + self.assertNotIn("broken", families) + self.assertIn("higgs_audio_tts", families) + + +class SupportsRequestOptionsTests(unittest.TestCase): + """supports_request_options: True / False / unknown tri-state.""" + + FAMILIES = {"higgs_audio_tts": {"display_name": "Higgs"}} + + def test_true_only_for_a_listed_family(self): + self.assertTrue(make_server.supports_request_options( + self.FAMILIES, "higgs_audio_tts")) + + def test_false_for_a_read_but_unlisted_family(self): + self.assertFalse(make_server.supports_request_options( + self.FAMILIES, "qwen3_tts")) + + def test_none_when_no_local_specs_exist(self): + self.assertIsNone(make_server.supports_request_options({}, "any")) + # An entry with no family at all is unclassifiable too. + self.assertIsNone(make_server.supports_request_options({}, "")) + + class DetectBackendTests(unittest.TestCase): """Backend detection from audio.cpp build directory names.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 7e77642..48cef0b 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1083,6 +1083,116 @@ class ConvertFlowTests(unittest.TestCase): # back to no options instead of crashing the mapper. self.assertEqual(cmd[2]["request_options"], {}) + # ------------------------------------------------------------------ + # audio.cpp: Request options gated by model_specs option support + # ------------------------------------------------------------------ + + def _specs_checkout(self, families_with_options=()): + """A fake checkout whose specs mark FAMILIES_WITH_OPTIONS supportive.""" + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + specs = root / "model_specs" + specs.mkdir() + for family in ("higgs_audio_tts", "qwen3_tts"): + request = ([{"id": "temperature"}] + if family in families_with_options else []) + spec = {"family": family, "display_name": family.title(), + "packages": [{"id": f"{family}_q8_0", "default": True, + "format": "gguf", + "target_directory": f"{family}-GGUF"}]} + if request: + spec["options"] = {"request": request} + (specs / f"{family}.json").write_text(json.dumps(spec), + encoding="utf-8") + return root + + def test_options_field_visible_when_spec_proves_support(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=self._specs_checkout( + ("higgs_audio_tts",))), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + self.assertTrue(self._field("request_options")["visible"](fields)) + self.assertIsNotNone(cmd) + + def test_options_field_hidden_when_spec_lacks_the_family(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + # A checkout exists but only qwen3_tts declares request options: + # higgs is provably unsupported -> hidden. + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=self._specs_checkout( + ("qwen3_tts",))), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + self.assertFalse(self._field("request_options")["visible"](fields)) + + def test_options_field_hidden_without_a_local_checkout(self): + # Unknown support (no specs anywhere) hides the field — strict. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + self.assertFalse(self._field("request_options")["visible"](fields)) + + def test_instructions_help_is_short_and_shared(self): + # One compact static help text for every capability: two lines, + # naming style instructions, partial clone-model support, and an + # example. (Design entries enforce their requirement by validation.) + self._patch_remote([ + {"id": "design", "family": "qwen3_tts", "task": "vdes"}, + {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}, + ], voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="design", + audiocpp_voice=None, + instructions="A warm British narrator") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + instr = self._field("instructions") + self.assertEqual(instr["help"], [ + "TTS style instructions. Supported by some clone models. Example:", + '"Speak in a calm, soothing, and happy tone."', + ]) + + def test_options_help_is_two_lines_with_examples(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=self._specs_checkout( + ("qwen3_tts", "higgs_audio_tts"))), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + options = self._field("request_options") + fields = self.tui.forms_seen[0][1] + self.assertTrue(options["visible"](fields)) + self.assertEqual(len(options["help"]), 2) + help_text = "\n".join(options["help"]) + self.assertIn("KEY=VALUE", help_text) + self.assertIn("emotion=neutral", help_text) + def test_language_passes_through_normalized(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.config, "SPEAKER", "Vivian"): @@ -1453,11 +1563,13 @@ class ConvertFlowTests(unittest.TestCase): "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) # The form opens on the configured default (audio.cpp): its fields - # show, the other backend's hide. (Instructions shows too: optional - # style/delivery control even on the clone-only higgs entry.) - for key in ("model_id", "audiocpp_voice", "instructions", - "request_options"): + # show, the other backend's hide. Instructions shows too (optional + # style/delivery control even on the clone-only higgs entry), while + # Request options stays hidden — higgs has no option-supporting + # spec on this machine's checkout, so its support is unknown. + for key in ("model_id", "audiocpp_voice", "instructions"): self.assertTrue(self._field(key)["visible"](fields)) + self.assertFalse(self._field("request_options")["visible"](fields)) # Language shows for every backend except faster entries. self.assertTrue(self._field("language")["visible"](fields)) for key in ("mode", "speaker", "clone"): @@ -1976,7 +2088,10 @@ class SettingsTests(unittest.TestCase): self.assertNotIn("flash", captured) def test_settings_menu_cancel_does_not_apply(self): + # An actual edit triggers the save prompt; "no" discards it. def fake_form(stdscr, title, fields, back_value=None): + next(f for f in fields + if f["key"] == "chunk_size")["value"] = "300" return back_value # user pressed Cancel / q / Esc applied = [] @@ -1992,6 +2107,54 @@ class SettingsTests(unittest.TestCase): mk_prompt.assert_called_once_with(None, "Save settings?") self.assertEqual(applied, []) + def test_settings_menu_exit_without_changes_skips_prompt(self): + # Leaving with untouched fields never asks about saving. + def fake_form(stdscr, title, fields, back_value=None): + return back_value # user pressed Cancel / q / Esc + + applied = [] + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub.tui, "confirm_yn_cancel") as mk_prompt, \ + patch.object(hub, "_apply_settings", + lambda values: applied.append(values)): + hub._Hub(None).screen_settings() + mk_prompt.assert_not_called() + self.assertEqual(applied, []) + + def test_settings_menu_reverted_edit_skips_the_prompt(self): + # Typing a value and typing it back leaves nothing to save. + def fake_form(stdscr, title, fields, back_value=None): + field = next(f for f in fields if f["key"] == "chunk_size") + untouched = field["value"] + field["value"] = "300" + field["value"] = untouched + return back_value + + applied = [] + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub.tui, "confirm_yn_cancel") as mk_prompt, \ + patch.object(hub, "_apply_settings", + lambda values: applied.append(values)): + hub._Hub(None).screen_settings() + mk_prompt.assert_not_called() + self.assertEqual(applied, []) + + def test_settings_menu_whitespace_edit_skips_the_prompt(self): + # Surrounding whitespace alone is not a change: _apply_settings + # trims text values, so saving would be a no-op. + def fake_form(stdscr, title, fields, back_value=None): + field = next(f for f in fields if f["key"] == "language") + field["value"] = " " + field["value"] + " " + return back_value + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub.tui, "confirm_yn_cancel") as mk_prompt, \ + patch.object(hub, "_apply_settings", lambda values: None): + hub._Hub(None).screen_settings() + mk_prompt.assert_not_called() + def test_settings_menu_exit_yes_applies_the_edited_fields(self): # Leaving via Esc and answering Yes applies a values dict built # from the (edited) field list. diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index cd4e535..c836178 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -662,6 +662,32 @@ class FormTests(TuiTestCase): if "Voice of base" in text] self.assertTrue(titles) + def test_help_lines_render_inside_the_edit_dialog(self): + # A field's optional "help" list appears as dim lines in its line + # editor (and callables resolve against the field list). + fields = [ + {"key": "opt", "kind": "text", "value": "", + "label": "Options", + "help": lambda fs: ["First hint.", f"Value: {fs[0]['value']!r}"]}, + {"key": "plain", "label": "Plain", "kind": "text", + "value": "", "help": ["Static hint."]}, + ] + # Down to the second field, Enter opens it, Esc backs out; Tab -> + # Save, Enter. The first field's editor is never opened, so only + # the static second field's hints must appear. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10, 27, 9, 10]) + tui.form(screen, "Settings", fields) + texts = [text for _, _, text, _ in screen.strings] + self.assertIn("Static hint.", texts) + self.assertNotIn("First hint.", texts) + # Open the first field's editor: dynamic help resolves per redraw. + screen = FakeScreen(keys=[10, 27, 9, 10]) + tui.form(screen, "Settings", fields) + texts = [text for _, _, text, _ in screen.strings] + self.assertIn("First hint.", texts) + self.assertIn("Value: ''", texts) + self.assert_inside_border(screen) + def test_field_note_renders_and_save(self): fields = self._fields() fields[1]["note"] = "A short section note" -- cgit v1.2.3