The journey-level nav restructure moved Home to a standalone "Start here" entry and Reconcile into the "Finance" group, leaving the "analysis" section with zero tools. Two registry tests encoded the old layout and failed: - test_every_section_has_at_least_one_tool[analysis] (empty section) - test_reconciler_present (asserted section == "analysis") Drop "analysis" from the Section literal, SECTION_LABELS, and app.py's by_section bucket — it's genuinely dead now (home isn't a registry Tool). Update the presence tests to assert Reconcile + PDF to CSV live in "finance". The section-invariant tests (every section non-empty, has a label, no orphan labels) are preserved and pass. Full suite: 2441 passed, 91 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
9.7 KiB
Python
254 lines
9.7 KiB
Python
"""Tests for src.gui.tools_registry — the per-tool manifest.
|
|
|
|
The registry is loaded at import time by the home page sidebar nav,
|
|
the home grid, and the findings panel's "Open Tool" links. A broken
|
|
entry would surface as a sidebar disappearance, a missing card, or a
|
|
``KeyError`` in the findings rendering. We pin the invariants those
|
|
call sites rely on:
|
|
|
|
- Every page_slug points at a file that actually exists.
|
|
- Every tool_id is unique (the analyzer keys findings on it).
|
|
- Every section is one of the declared literals.
|
|
- ``tool_by_id`` round-trips, ``display_name`` falls back gracefully.
|
|
- ``section_label`` resolves localized labels.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import get_args
|
|
|
|
import pytest
|
|
|
|
from src.gui.tools_registry import (
|
|
SECTION_LABELS,
|
|
TOOLS,
|
|
Section,
|
|
Tier,
|
|
Tool,
|
|
display_name,
|
|
section_label,
|
|
tool_by_id,
|
|
tool_description,
|
|
tool_name,
|
|
tools_for_tier,
|
|
tools_in_section,
|
|
)
|
|
|
|
PAGES_DIR = Path(__file__).resolve().parent.parent / "src" / "gui" / "pages"
|
|
|
|
|
|
class TestRegistryInvariants:
|
|
def test_all_tool_ids_are_unique(self):
|
|
ids = [t.tool_id for t in TOOLS]
|
|
assert len(ids) == len(set(ids)), (
|
|
f"duplicate tool_id in TOOLS: {sorted(ids)}"
|
|
)
|
|
|
|
def test_all_page_slugs_point_at_real_files(self):
|
|
for tool in TOOLS:
|
|
page_file = PAGES_DIR / f"{tool.page_slug}.py"
|
|
assert page_file.exists(), (
|
|
f"{tool.tool_id} → {tool.page_slug}.py does not exist"
|
|
)
|
|
|
|
def test_all_sections_are_declared_literals(self):
|
|
valid = set(get_args(Section))
|
|
for tool in TOOLS:
|
|
assert tool.section in valid, (
|
|
f"{tool.tool_id} has unknown section {tool.section!r}; "
|
|
f"valid: {sorted(valid)}"
|
|
)
|
|
|
|
def test_all_tiers_are_declared_literals(self):
|
|
valid = set(get_args(Tier))
|
|
for tool in TOOLS:
|
|
assert tool.tier in valid, (
|
|
f"{tool.tool_id} has unknown tier {tool.tier!r}; "
|
|
f"valid: {sorted(valid)}"
|
|
)
|
|
|
|
def test_every_section_has_a_display_label(self):
|
|
for section in get_args(Section):
|
|
assert section in SECTION_LABELS, (
|
|
f"section {section!r} has no SECTION_LABELS entry"
|
|
)
|
|
|
|
def test_no_orphan_section_labels(self):
|
|
# The other direction: a SECTION_LABELS key that isn't a
|
|
# declared Section literal is dead config.
|
|
valid = set(get_args(Section))
|
|
for key in SECTION_LABELS:
|
|
assert key in valid, (
|
|
f"SECTION_LABELS has stray key {key!r} not in Section"
|
|
)
|
|
|
|
|
|
class TestToolLookups:
|
|
def test_tool_by_id_round_trips_every_entry(self):
|
|
for tool in TOOLS:
|
|
found = tool_by_id(tool.tool_id)
|
|
assert found is tool, (
|
|
f"tool_by_id({tool.tool_id!r}) returned {found!r}"
|
|
)
|
|
|
|
def test_tool_by_id_returns_none_for_unknown(self):
|
|
assert tool_by_id("not_a_real_tool_id") is None
|
|
|
|
def test_display_name_falls_back_to_id(self):
|
|
# Documented behavior: unknown id returns the id itself so the
|
|
# bug is visible in the UI rather than crashing.
|
|
assert display_name("not_a_real_tool_id") == "not_a_real_tool_id"
|
|
|
|
def test_display_name_resolves_known_tool(self):
|
|
# Pick a tool we know ships in every build.
|
|
assert display_name("02_text_cleaner") == "Clean Text"
|
|
|
|
|
|
class TestTierAndSectionFilters:
|
|
def test_tools_for_tier_empty_returns_all(self):
|
|
assert tools_for_tier() == list(TOOLS)
|
|
|
|
def test_tools_for_tier_filters(self):
|
|
# Every tool is tier="core" today, so an explicit core filter
|
|
# should still match the full set. A "pro"-only call should
|
|
# return an empty list.
|
|
assert tools_for_tier("core") == list(TOOLS)
|
|
assert tools_for_tier("pro") == []
|
|
|
|
def test_tools_in_section_preserves_registry_order(self):
|
|
cleaners = tools_in_section("cleaners")
|
|
in_full_order = [t for t in TOOLS if t.section == "cleaners"]
|
|
assert cleaners == in_full_order
|
|
|
|
@pytest.mark.parametrize("section", list(get_args(Section)))
|
|
def test_every_section_has_at_least_one_tool(self, section):
|
|
assert tools_in_section(section), (
|
|
f"section {section!r} has zero tools — sidebar group would be empty"
|
|
)
|
|
|
|
|
|
class TestLocalizedAccessors:
|
|
def test_tool_name_falls_back_to_registry_default(self):
|
|
# An unknown tool id should return the literal id, not crash.
|
|
assert tool_name("not_a_real_tool_id") == "not_a_real_tool_id"
|
|
|
|
def test_tool_name_returns_localized_when_pack_has_key(self):
|
|
# The lang packs ship a "tools.{id}.name" key for every shipped
|
|
# tool. We don't assert the exact translation here (the lang
|
|
# pack parity test pins that); we just check the helper returns
|
|
# something non-empty and not the literal lookup key.
|
|
name = tool_name("02_text_cleaner")
|
|
assert name and name != "tools.02_text_cleaner.name"
|
|
|
|
def test_tool_description_returns_localized_or_fallback(self):
|
|
desc = tool_description("02_text_cleaner")
|
|
assert desc and desc != "tools.02_text_cleaner.description"
|
|
|
|
def test_tool_description_for_unknown_returns_empty(self):
|
|
# Unknown ids return the registry fallback (""), not a
|
|
# lookup-key string. The home grid avoids rendering empty
|
|
# descriptions, so this contract matters.
|
|
assert tool_description("not_a_real_tool_id") == ""
|
|
|
|
@pytest.mark.parametrize("section", list(get_args(Section)))
|
|
def test_section_label_returns_non_empty(self, section):
|
|
label = section_label(section)
|
|
assert label and label != f"nav.section_{section}"
|
|
|
|
|
|
class TestDescriptionCopy:
|
|
"""The post-jargon-strip descriptions are intentionally tight one-
|
|
liners. Pin them so future drift toward bloated marketing copy
|
|
(or an accidentally-empty string) is caught by CI."""
|
|
|
|
# Roomy upper bound; the tightest description today is ~60 chars
|
|
# and the longest is just over 90. ~120 leaves headroom for minor
|
|
# copy tweaks without inviting paragraph-length card bodies.
|
|
_MAX_DESCRIPTION_CHARS = 120
|
|
|
|
def test_every_description_is_non_empty(self):
|
|
empty = [t.tool_id for t in TOOLS if not t.description.strip()]
|
|
assert not empty, f"tools with empty descriptions: {empty}"
|
|
|
|
def test_every_description_under_max_chars(self):
|
|
too_long = [
|
|
(t.tool_id, len(t.description))
|
|
for t in TOOLS
|
|
if len(t.description) > self._MAX_DESCRIPTION_CHARS
|
|
]
|
|
assert not too_long, (
|
|
f"tool descriptions exceed {self._MAX_DESCRIPTION_CHARS} chars: "
|
|
f"{too_long}"
|
|
)
|
|
|
|
|
|
class TestRenderToolHeaderSmoke:
|
|
"""``render_tool_header`` is the helper every tool page now calls in
|
|
place of ``st.title(...) + st.caption(...)``. We can't render it
|
|
without a Streamlit script context, but we CAN verify it imports
|
|
cleanly via the public ``src.gui.components`` surface and resolves
|
|
the expected i18n keys for a known tool id."""
|
|
|
|
def test_importable_from_public_components_package(self):
|
|
from src.gui.components import render_tool_header
|
|
|
|
assert callable(render_tool_header)
|
|
|
|
def test_listed_in_public_all(self):
|
|
# The public ``__all__`` is what per-tool builds key off; a
|
|
# removal here would silently break tool pages that import
|
|
# from ``src.gui.components`` directly.
|
|
from src.gui import components as components_pkg
|
|
|
|
assert "render_tool_header" in components_pkg.__all__
|
|
|
|
def test_resolves_expected_i18n_keys_for_known_tool(self):
|
|
# The helper reads four pack keys per render:
|
|
# ``tools.<id>.page_title``, ``tools.<id>.page_caption``,
|
|
# ``tools.<id>.help_md``, plus shared ``help.button_label`` /
|
|
# ``help.missing_body``. We don't invoke the helper (no script
|
|
# context) — we verify the keys it would touch resolve to
|
|
# non-empty strings in both packs.
|
|
from src.i18n import t as _t
|
|
|
|
tool_id = "02_text_cleaner"
|
|
for lang in ("en", "es"):
|
|
for suffix in ("page_title", "page_caption", "help_md"):
|
|
key = f"tools.{tool_id}.{suffix}"
|
|
value = _t(key, lang)
|
|
assert value and value != key, (
|
|
f"render_tool_header({tool_id!r}) "
|
|
f"would render the literal key {key!r} in {lang!r}"
|
|
)
|
|
for key in ("help.button_label", "help.missing_body"):
|
|
value = _t(key, lang)
|
|
assert value and value != key, (
|
|
f"render_tool_header would render the literal key "
|
|
f"{key!r} in {lang!r}"
|
|
)
|
|
|
|
|
|
class TestReconcilerAndPdfArePresent:
|
|
"""The two newest pages were the most likely to be forgotten in
|
|
the registry — pin them explicitly so a regression flagging
|
|
"Ready" tools as missing from nav is loud."""
|
|
|
|
def test_pdf_extractor_present(self):
|
|
tool = tool_by_id("10_pdf_extractor")
|
|
assert tool is not None
|
|
assert tool.page_slug == "10_PDF_Extractor"
|
|
assert tool.status == "Ready"
|
|
# PDF to CSV + Reconcile live in the "Finance" group (outside the
|
|
# cleaning flow) per DECISIONS.md 2026-06-08.
|
|
assert tool.section == "finance"
|
|
|
|
def test_reconciler_present(self):
|
|
tool = tool_by_id("11_reconciler")
|
|
assert tool is not None
|
|
assert tool.page_slug == "11_Reconciler"
|
|
assert tool.status == "Ready"
|
|
# Reconcile sits in the "Finance" group (see DECISIONS.md
|
|
# 2026-06-08); if that section disappears the sidebar goes empty.
|
|
assert tool.section == "finance"
|