diff --git a/generate_it/constants.py b/generate_it/constants.py index 0eb6e67..295bbfc 100644 --- a/generate_it/constants.py +++ b/generate_it/constants.py @@ -67,7 +67,7 @@ SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?/" # Username-related character sets. -USERNAME_ALPHANUMERIC = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +USERNAME_ALPHANUMERIC = "abcdefghijklmnopqrstuvwxyz0123456789" USERNAME_SEPARATORS = frozenset(["_", "-"]) # Username styles diff --git a/generate_it/tui.py b/generate_it/tui.py index ba28539..4f1adf5 100644 --- a/generate_it/tui.py +++ b/generate_it/tui.py @@ -2063,9 +2063,9 @@ def _gen_pwd(): return 0 -# Imported last to avoid a circular import: tui_render aliases a few -# state-label helpers (e.g. _selected_category_count) that are defined -# above in this module. +# Imported last to avoid a circular import: tui_render now redefines a few +# state-label helpers (e.g. _selected_category_count) locally rather +# than importing them from this module. from . import tui_render as R from . import tui_flow diff --git a/generate_it/tui_render.py b/generate_it/tui_render.py index 3411f1b..6402a39 100644 --- a/generate_it/tui_render.py +++ b/generate_it/tui_render.py @@ -772,16 +772,6 @@ def _render_info_box( inner_w = max(0, w - 4) row = y + 1 - bits = _estimate_entropy_bits(state, wordlist_size) - label, kind = _strength_label(bits) - - if kind == "bad": - kind_attr = theme.bad - elif kind == "warn": - kind_attr = theme.warn - else: - kind_attr = theme.ok - mode_str = ( "characters" if state.mode == "chars" @@ -838,26 +828,33 @@ def _render_info_box( ) row += 1 - # Strength bar - _addstr_safe(stdscr, row, x + 2, f"Entropy: ~{bits:0.1f} bits"[:inner_w], theme.dim) - row += 1 + if state.mode != "username": + bits = _estimate_entropy_bits(state, wordlist_size) + label, kind = _strength_label(bits) - prefix = "Strength: [" - suffix = f"] {label}" - bar_w = max(0, inner_w - len(prefix) - len(suffix)) - if state.mode == "chars": - max_bits = float(generator.MAX_PASSWORD_CHARS) * math.log2( - len(generator.LETTERS) + len(generator.NUMBERS) + len(generator.SPECIAL_CHARACTERS) - ) - elif state.mode == "words": - max_bits = ( - float(generator.MAX_PASSPHRASE_WORDS) * math.log2(max(2, wordlist_size)) - + 3.0 * math.log2(10.0) - + math.log2(len(generator.PASSPHRASE_SPECIALS)) - ) - else: - max_bits = float(generator.MAX_PASSWORD_CHARS) * math.log2( - len(generator.USERNAME_ALPHANUMERIC) - ) - bar = _bar(min(bits, max_bits), max_bits, bar_w) - _addstr_safe(stdscr, row, x + 2, f"{prefix}{bar}{suffix}"[:inner_w], kind_attr) + if kind == "bad": + kind_attr = theme.bad + elif kind == "warn": + kind_attr = theme.warn + else: + kind_attr = theme.ok + + # Strength bar + _addstr_safe(stdscr, row, x + 2, f"Entropy: ~{bits:0.1f} bits"[:inner_w], theme.dim) + row += 1 + + prefix = "Strength: [" + suffix = f"] {label}" + bar_w = max(0, inner_w - len(prefix) - len(suffix)) + if state.mode == "chars": + max_bits = float(generator.MAX_PASSWORD_CHARS) * math.log2( + len(generator.LETTERS) + len(generator.NUMBERS) + len(generator.SPECIAL_CHARACTERS) + ) + elif state.mode == "words": + max_bits = ( + float(generator.MAX_PASSPHRASE_WORDS) * math.log2(max(2, wordlist_size)) + + 3.0 * math.log2(10.0) + + math.log2(len(generator.PASSPHRASE_SPECIALS)) + ) + bar = _bar(min(bits, max_bits), max_bits, bar_w) + _addstr_safe(stdscr, row, x + 2, f"{prefix}{bar}{suffix}"[:inner_w], kind_attr) diff --git a/tests/test_constants_sync.py b/tests/test_constants_sync.py new file mode 100644 index 0000000..553e814 --- /dev/null +++ b/tests/test_constants_sync.py @@ -0,0 +1,117 @@ +"""Test that every constant duplicated across modules matches its counterpart. + +This test currently guards a single duplication surface: + +**``constants.py`` ↔ ``generator.py``** — generator-level constants +(length bounds, character sets, wordlists, entropy minimums). This is the +original drift surface; ``USERNAME_ALPHANUMERIC`` was caught diverging here +(``review-REJECTED-consolidated-remediation-20260816-2305.md`` §3). + +If a test here fails, the two modules are out of sync. Fix the source of +truth (first decide which one it is, then update the other) and add the +correction to this test so future drift is caught. + +-------------------------------------------------------------------- +Known architectural debt — not yet guarded by this test suite +-------------------------------------------------------------------- + +**Policy-tuple duplication** — ``constants.py`` ↔ ``tui.py`` + ``CLIPBOARD_AUTO_CLEAR_OPTIONS`` and ``AUTO_LOCK_OPTIONS`` are defined + independently in both modules (``tui.py`` at lines 52, 62 is the runtime + authority; ``constants.py`` at lines 10, 20 is imported by + ``tui_render.py``). These are verified byte-identical at the time of + PR #3 remediation but are *not* guarded by a sync test — they could + silently diverge. + +**Constants single-source-of-truth migration** + Making ``generator.py`` and ``tui.py`` import from ``constants.py`` + (rather than defining independent copies) is the intended resolution for + *all* duplication surfaces above. The sync test is a pragmatic interim + guard; the full migration is deferred for scope-risk reasons. When the + migration lands, this file should be updated to verify the *remaining* + guards rather than the duplication surfaces. +""" + +from __future__ import annotations + +import string + +from generate_it import constants +from generate_it import generator + + +def _as_str(value: object) -> str: + """Normalise a constant to a string for comparison. + + Both modules may express string constants as plain ``str``, via + ``string.ascii_letters``, or as ``frozenset``. Normalise everything + to a plain ``str`` for unambiguous comparison. + """ + if isinstance(value, frozenset): + return str(sorted(value)) + return str(value) + + +class TestConstantsSync: + """Verify every shared constant matches between ``constants`` and ``generator``.""" + + # --- Integer/bool constants ----------------------------------------- + + def test_min_password_chars(self) -> None: + assert constants.MIN_PASSWORD_CHARS == generator.MIN_PASSWORD_CHARS + + def test_max_password_chars(self) -> None: + assert constants.MAX_PASSWORD_CHARS == generator.MAX_PASSWORD_CHARS + + def test_min_passphrase_words(self) -> None: + assert constants.MIN_PASSPHRASE_WORDS == generator.MIN_PASSPHRASE_WORDS + + def test_max_passphrase_words(self) -> None: + assert constants.MAX_PASSPHRASE_WORDS == generator.MAX_PASSPHRASE_WORDS + + def test_min_username_length(self) -> None: + assert constants.MIN_USERNAME_LENGTH == generator.MIN_USERNAME_LENGTH + + def test_max_username_length(self) -> None: + assert constants.MAX_USERNAME_LENGTH == generator.MAX_USERNAME_LENGTH + + def test_min_username_words(self) -> None: + assert constants.MIN_USERNAME_WORDS == generator.MIN_USERNAME_WORDS + + def test_max_username_words(self) -> None: + assert constants.MAX_USERNAME_WORDS == generator.MAX_USERNAME_WORDS + + def test_min_passphrase_entropy_bits(self) -> None: + assert constants._MIN_PASSPHRASE_ENTROPY_BITS == generator._MIN_PASSPHRASE_ENTROPY_BITS + + # --- Character-set string constants ---------------------------------- + + def test_letters(self) -> None: + assert _as_str(constants.LETTERS) == _as_str(generator.LETTERS) + + def test_numbers(self) -> None: + assert _as_str(constants.NUMBERS) == _as_str(generator.NUMBERS) + + def test_special_characters(self) -> None: + assert _as_str(constants.SPECIAL_CHARACTERS) == _as_str(generator.SPECIAL_CHARACTERS) + + def test_username_alphanumeric(self) -> None: + """This was the constant that diverged — PR #3 review caught it.""" + assert ( + _as_str(constants.USERNAME_ALPHANUMERIC) + == _as_str(generator.USERNAME_ALPHANUMERIC) + ) + # The canonical value — both must be lowercase-only + expected = string.ascii_lowercase + string.digits + assert _as_str(constants.USERNAME_ALPHANUMERIC) == expected + + def test_username_separators(self) -> None: + assert _as_str(constants.USERNAME_SEPARATORS) == _as_str(generator.USERNAME_SEPARATORS) + + def test_passphrase_specials(self) -> None: + assert _as_str(constants.PASSPHRASE_SPECIALS) == _as_str(generator.PASSPHRASE_SPECIALS) + + # --- Constant collections (lists / frozensets) ----------------------- + + def test_default_wordlist(self) -> None: + assert constants.DEFAULT_WORDLIST == generator.DEFAULT_WORDLIST