fix(llm): fix stride sampling exactness, zero-cap crash, and test quality

- Defect 1 (main.py): Replace floating-point stride loop with exact
  floor(k * total / cap) index formula so sampling always returns
  EXACTLY cap items for any 1 <= cap <= total_usable, including the
  stride < 2 regime where the old approach returned cap-1 items.

- Defect 2 (main.py): Validate ollamaMaxSampleLines before computing
  the stride; values <= 0 are nonsensical and fall back to 500
  (same as the default) rather than raising ZeroDivisionError.

- Defect 3 (test_progress_spinner.py): Rewrite test_non_tty_no_extra_thread
  to patch threading.Thread and assert it was never called, so the test
  actually fails when the spinner starts a thread in the non-TTY path.

- Defect 4 (test_progress_spinner.py): Simplify test_non_tty_prints_message
  to use monkeypatch.setattr instead of the dead mock.patch.object +
  manual assign/restore layering; removes two # type: ignore comments.

- docs(README.md): Document ollamaMaxSampleLines alongside ollamaNumCtx /
  ollamaTimeout in the Ollama Configuration section.

- tests: Add boundary-case tests for stride < 2 (total==cap, total==cap+1,
  total=3/cap=2, cap=1) and zero-cap fallback; make
  test_ollama_max_sample_lines_default exercise behaviour rather than
  the ambient config value.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-24 17:57:09 -04:00
co-authored by Claude
parent e5e1107359
commit fef606dbf4
4 changed files with 137 additions and 29 deletions
+112 -3
View File
@@ -63,9 +63,47 @@ def env(tmp_path):
# ---------------------------------------------------------------------------
def test_ollama_max_sample_lines_default():
"""ollamaMaxSampleLines must default to 500."""
assert hc_main.ollamaMaxSampleLines == 500
def test_ollama_max_sample_lines_default(env, capsys):
"""ollamaMaxSampleLines fallback behaviour is 500.
Rather than asserting on the ambient live value (which a developer can
override in their local config.json), we verify the *behaviour*: a
wordlist with exactly 500 usable lines is loaded in full (no capping),
while one with 501 lines is capped to 500.
"""
# 500 lines → no capping, uses the "Loaded N" path
wl500 = env.tmp_path / "w500.txt"
wl500.write_text("\n".join(f"w{i:04d}" for i in range(500)) + "\n")
with mock.patch.object(hc_main, "ollamaMaxSampleLines", 500), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
) as gen500, mock.patch.object(
hc_main, "ollamaUrl", OLLAMA_URL
), mock.patch.object(
hc_main, "ollamaModel", MODEL
), mock.patch.object(
hc_main, "ollamaNumCtx", 2048
), mock.patch.object(
hc_main, "hcatBin", "/usr/bin/hashcat"
), mock.patch.object(
hc_main, "hcatTuning", ""
), mock.patch.object(
hc_main, "hcatPotfilePath", ""
), mock.patch.object(
hc_main, "rulesDirectory", str(env.tmp_path / "rules")
), mock.patch(
"hate_crack.main.generate_session_id", return_value="s"
), mock.patch(
"subprocess.Popen", return_value=_make_proc()
):
import os as _os
_os.makedirs(str(env.tmp_path / "rules"), exist_ok=True)
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl500))
captured = capsys.readouterr()
assert "Loaded 500" in captured.out
sample_lines = gen500.call_args[0][4]["sample"].splitlines()
assert len(sample_lines) == 500
# ---------------------------------------------------------------------------
@@ -165,6 +203,77 @@ def test_large_wordlist_covers_full_range(env):
assert max(indices) >= 900, "sample should include entries from the end of the file"
# ---------------------------------------------------------------------------
# Stride < 2 regime: boundary cases for small total_usable values
# ---------------------------------------------------------------------------
def _sample_count(env, total: int, cap: int) -> int:
"""Helper: write a wordlist with *total* lines, run hcatOllama capped to *cap*,
return the number of lines that reached the LLM."""
wl = env.tmp_path / f"wl_{total}_{cap}.txt"
wl.write_text("\n".join(f"p{i:04d}" for i in range(total)) + "\n")
captured_ctx: list[dict] = []
def _capture(*args, **kwargs):
captured_ctx.append(args[4])
return ["x"]
with _ollama_globals(env.tmp_path, max_sample=cap), mock.patch.object(
hc_main.llm, "generate_candidates", side_effect=_capture
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
if not captured_ctx:
return 0
return len(captured_ctx[0]["sample"].splitlines())
def test_stride_exact_total_equals_cap(env) -> None:
"""total == cap: all lines must be returned (no capping branch taken)."""
assert _sample_count(env, 5, 5) == 5
def test_stride_total_one_above_cap(env) -> None:
"""total == cap + 1: exactly cap items must be sampled (stride < 2)."""
assert _sample_count(env, 4, 3) == 3
def test_stride_tiny_three_cap_two(env) -> None:
"""total=3, cap=2: exactly 2 items must be sampled (stride = 1.5 < 2)."""
assert _sample_count(env, 3, 2) == 2
def test_stride_cap_one(env) -> None:
"""cap=1 over a larger file must return exactly 1 item."""
assert _sample_count(env, 10, 1) == 1
# ---------------------------------------------------------------------------
# Defect 2: ZeroDivisionError guard — cap=0 falls back to 500
# ---------------------------------------------------------------------------
def test_zero_cap_falls_back_to_default(env, capsys) -> None:
"""ollamaMaxSampleLines=0 must not crash; it falls back to cap=500.
A 10-line wordlist with cap=0 (invalid) should load all 10 lines using
the "no capping needed" path since 10 <= 500 (the fallback).
"""
wl = env.tmp_path / "zero_cap.txt"
wl.write_text("\n".join(f"pw{i}" for i in range(10)) + "\n")
with _ollama_globals(env.tmp_path, max_sample=0), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
captured = capsys.readouterr()
# Must not raise; must load all 10 words (10 <= 500 fallback)
assert "Loaded 10" in captured.out
assert len(gen.call_args[0][4]["sample"].splitlines()) == 10
# ---------------------------------------------------------------------------
# hash:password splitting and blank-line skipping
# ---------------------------------------------------------------------------
+13 -19
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import io
import sys
import threading
import time
from unittest import mock
@@ -18,35 +17,30 @@ from hate_crack.progress import spinner
# ---------------------------------------------------------------------------
def test_non_tty_prints_message(capsys: pytest.CaptureFixture[str]) -> None:
def test_non_tty_prints_message(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""In a non-TTY environment the message is printed once and no thread runs."""
with mock.patch.object(sys, "stdout", wraps=sys.stdout) as m_stdout:
m_stdout.isatty.return_value = False # type: ignore[attr-defined]
# Use actual capsys for the print capture but override isatty
original_isatty = sys.stdout.isatty
sys.stdout.isatty = lambda: False # type: ignore[method-assign]
try:
with spinner("Working..."):
pass
finally:
sys.stdout.isatty = original_isatty # type: ignore[method-assign]
monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
with spinner("Working..."):
pass
captured = capsys.readouterr()
assert "Working..." in captured.out
def test_non_tty_no_extra_thread() -> None:
"""Spinner in non-TTY context should not start a background thread."""
"""Spinner in non-TTY context must never construct a Thread."""
buf = io.StringIO()
buf.isatty = lambda: False # type: ignore[attr-defined]
threads_before = threading.active_count()
with mock.patch("sys.stdout", buf):
with mock.patch("sys.stdout", buf), mock.patch(
"hate_crack.progress.threading.Thread"
) as mock_thread:
with spinner("No threads please"):
peak = threading.active_count()
# The thread count during the body should not exceed baseline + 1
# (pytest itself may add threads; we just want no extra daemon spinner thread).
assert peak <= threads_before + 1
pass
mock_thread.assert_not_called()
# ---------------------------------------------------------------------------