Files
datatools-dev/tests/test_tools_registry.py
Michael 1895074b8f test+fix(gui): retire the now-empty "analysis" nav section
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>
2026-06-08 17:11:02 +00:00

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"