fix(ui): route ollama/omen submenus through interactive_menu; re-prompt pickers on invalid input

- ollama_attack: convert generation-mode menu to interactive_menu with
  dynamic items list (option 3 only present when cracked file exists);
  cancel via 99 or Escape; re-prompts in a while loop
- omen_attack: convert existing-model menu to interactive_menu; cancel
  via 99 or Escape (replaces hard-coded "3. Cancel"); re-prompts in loop
- _omen_pick_training_wordlist: replace abort-on-invalid with re-prompt
  loop; add explicit 'q' cancel key so users can exit without being
  trapped; callers already handle None correctly
- _markov_pick_training_source: same re-prompt-loop + 'q' cancel fix;
  callers already handle None correctly
- Update all affected tests to patch interactive_menu at
  hate_crack.attacks.interactive_menu (module-level import path) and
  drive follow-up prompts via builtins.input
- Add new tests: arrow-menu reach, Escape/99 cancel, re-prompt loop
  coverage for both pickers, and conditional option-3 key presence

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-24 18:31:04 -04:00
co-authored by Claude
parent 39e0dd956a
commit 07ca6ba7e3
3 changed files with 326 additions and 125 deletions
+40 -14
View File
@@ -277,8 +277,10 @@ class TestOmenAttackHandler:
def test_use_existing_model(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
self._setup_rules_dir(tmp_path)
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["1", "", "0"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["", "0"]),
):
from hate_crack.attacks import omen_attack
@@ -289,8 +291,10 @@ class TestOmenAttackHandler:
def test_train_new_model_with_wordlist_pick(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
self._setup_rules_dir(tmp_path)
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["2", "1", "", "0"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="2"),
patch("builtins.input", side_effect=["1", "", "0"]),
):
from hate_crack.attacks import omen_attack
@@ -302,8 +306,22 @@ class TestOmenAttackHandler:
def test_cancel_aborts(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["3"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="99"),
):
from hate_crack.attacks import omen_attack
omen_attack(ctx)
ctx.hcatOmenTrain.assert_not_called()
ctx.hcatOmen.assert_not_called()
def test_escape_cancels(self, tmp_path):
"""None from interactive_menu (Escape) cancels the attack."""
ctx = self._make_ctx(tmp_path, model_valid=True)
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value=None),
):
from hate_crack.attacks import omen_attack
@@ -348,8 +366,10 @@ class TestOmenAttackHandler:
def test_rules_passed_to_hcatOmen(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
self._setup_rules_dir(tmp_path, ["best64.rule"])
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["1", "", "1"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["", "1"]),
):
from hate_crack.attacks import omen_attack
@@ -361,8 +381,10 @@ class TestOmenAttackHandler:
def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"])
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["1", "", "1,2"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["", "1,2"]),
):
from hate_crack.attacks import omen_attack
@@ -372,8 +394,10 @@ class TestOmenAttackHandler:
def test_cancel_from_rules_aborts(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
self._setup_rules_dir(tmp_path, ["best64.rule"])
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["1", "", "99"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["", "99"]),
):
from hate_crack.attacks import omen_attack
@@ -383,8 +407,10 @@ class TestOmenAttackHandler:
def test_no_rules_passes_empty_chain(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
self._setup_rules_dir(tmp_path, ["best64.rule"])
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["1", "", "0"]
with (
patch("os.path.isfile", return_value=True),
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["", "0"]),
):
from hate_crack.attacks import omen_attack