Spaces:
Running
Running
Sync ShopStack 2026-06-15 round 2: primary-nav More, undo bar, freshness stamps, safe_render_html, home-flow TabContext
af69759 verified | """Regression tests for ``shopstack.cli`` (Pass 17, 2026-06-15). | |
| The CLI is the **mode-portability proof** for ShopStack: it | |
| exercises the public service API (``shopstack.app_context.tools``) | |
| without launching the Gradio UI. These tests guard: | |
| 1. The CLI can be imported without importing ``app.py`` or | |
| ``gradio`` (mode-portability contract). | |
| 2. All 7 subcommands are wired (inventory / shopping / find / | |
| use-soon / next-buy / tools / whoami). | |
| 3. Output is valid JSON by default; ``--human`` flips to | |
| pretty-printed output. | |
| 4. ``whoami`` returns the documented contract. | |
| 5. The CLI exit code is 0 on success. | |
| Per ``motto_v3`` Β§0.10 (Observability Is Delivery), the CLI is | |
| the operator-friendly counterpart of the ``/api/whoami`` HTTP | |
| endpoint. Operators who don't want to launch a Gradio server can | |
| get the same introspection data from the shell. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import json | |
| import re | |
| import sys | |
| from unittest.mock import patch | |
| import pytest | |
| # ββ Mode-portability contract ββββββββββββββββββββββββββββββββββββββββ | |
| def test_cli_does_not_import_app_or_gradio_directly(): | |
| """The CLI module does NOT import ``app`` or ``gradio`` directly. | |
| This is the mode-portability contract: the CLI proves the | |
| public service API works without the UI layer. If a future | |
| change imports ``app.py`` or ``gradio`` directly, the | |
| mode-portability claim breaks. | |
| ``shopstack.app_context`` IS allowed (it's the public | |
| service layer). The Gradio import is an indirect side effect | |
| of importing ``app_context`` (via the tools registry | |
| bootstrap), but it's not a direct import in the CLI module. | |
| """ | |
| cli_path = "shopstack/cli/__init__.py" | |
| with open(cli_path) as fp: | |
| src = fp.read() | |
| # No direct imports of ``app`` (the app.py module) or ``gradio`` | |
| # (other than via app_context). | |
| # Allowed: ``from shopstack.app_context import ...`` | |
| # Disallowed: ``from app import ...`` or ``import gradio`` (direct) | |
| assert "from app import" not in src, ( | |
| "CLI must not import `app` directly. " | |
| "If you need an app-specific function, add it to " | |
| "`shopstack.app_context` or `shopstack.cli` itself." | |
| ) | |
| assert "import gradio" not in src, ( | |
| "CLI must not import `gradio` directly. " | |
| "The whole point of the CLI is to prove the public service " | |
| "API works without the UI layer." | |
| ) | |
| # ββ Parser + dispatch ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_parser_includes_all_seven_subcommands(): | |
| """All 7 subcommands are registered (inventory, shopping, find, | |
| use-soon, next-buy, tools, whoami). | |
| """ | |
| from shopstack.cli import build_parser | |
| parser = build_parser() | |
| # Parse each subcommand with a minimal valid argv. | |
| for cmd_args in [ | |
| ["inventory"], | |
| ["shopping"], | |
| ["find", "milk"], | |
| ["use-soon"], | |
| ["use-soon", "--days", "3"], | |
| ["next-buy"], | |
| ["tools"], | |
| ["whoami"], | |
| ]: | |
| args = parser.parse_args(cmd_args) | |
| assert args.cmd is not None, f"no cmd parsed from {cmd_args!r}" | |
| def test_subcommand_dispatch_table_covers_all_commands(): | |
| """The ``SUBCOMMANDS`` dispatch dict covers every registered subcommand.""" | |
| from shopstack.cli import SUBCOMMANDS, build_parser | |
| parser = build_parser() | |
| # Re-parse and confirm each subcommand has a handler. | |
| for cmd_args in [ | |
| ["inventory"], ["shopping"], ["find", "x"], | |
| ["use-soon"], ["next-buy"], ["tools"], ["whoami"], | |
| ]: | |
| args = parser.parse_args(cmd_args) | |
| assert args.cmd in SUBCOMMANDS, ( | |
| f"subcommand {args.cmd!r} not in SUBCOMMANDS dispatch" | |
| ) | |
| # ββ ``whoami`` output contract βββββββββββββββββββββββββββββββββββββ | |
| def test_whoami_payload_shape(): | |
| """``whoami`` returns the documented JSON contract. | |
| Per the docstring: ``{household, database, runtime, timestamp}``. | |
| Each sub-section is itself a dict. The timestamp is | |
| ISO 8601 with timezone info. | |
| """ | |
| from shopstack.cli import build_parser | |
| from datetime import datetime | |
| parser = build_parser() | |
| args = parser.parse_args(["whoami"]) | |
| # Dispatch through the same path main() uses. | |
| from shopstack.cli import SUBCOMMANDS | |
| payload = SUBCOMMANDS["whoami"](args) | |
| assert "household" in payload | |
| assert "database" in payload | |
| assert "runtime" in payload | |
| assert "timestamp" in payload | |
| # Database sub-section keys. | |
| assert "path" in payload["database"] | |
| assert "size_bytes" in payload["database"] | |
| assert "table_count" in payload["database"] | |
| # Runtime sub-section keys. | |
| assert "pid" in payload["runtime"] | |
| assert "python_version" in payload["runtime"] | |
| # Timestamp is parseable ISO 8601 with timezone. | |
| parsed = datetime.fromisoformat(payload["timestamp"]) | |
| assert parsed.tzinfo is not None | |
| def test_whoami_json_output_is_valid(): | |
| """Running ``cli whoami`` produces parseable JSON on stdout.""" | |
| from shopstack.cli import main | |
| captured = io.StringIO() | |
| with patch("sys.stdout", captured): | |
| rc = main(["whoami"]) | |
| assert rc == 0, f"cli whoami returned non-zero: {rc}" | |
| # Must be valid JSON. | |
| payload = json.loads(captured.getvalue()) | |
| assert "household" in payload | |
| assert "database" in payload | |
| assert "runtime" in payload | |
| def test_whoami_human_output_is_not_json(): | |
| """``--human`` produces a non-JSON, line-based output.""" | |
| from shopstack.cli import main | |
| captured = io.StringIO() | |
| with patch("sys.stdout", captured): | |
| rc = main(["--human", "whoami"]) | |
| assert rc == 0 | |
| out = captured.getvalue() | |
| # The --human output is plain text, not JSON. | |
| with pytest.raises(json.JSONDecodeError): | |
| json.loads(out) | |
| # But it should contain recognizable keys. | |
| assert "household:" in out | |
| assert "pid:" in out | |
| # ββ ``tools`` subcommand ββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_tools_subcommand_lists_public_service_surface(): | |
| """The ``tools`` subcommand lists the public service API.""" | |
| from shopstack.cli import main | |
| captured = io.StringIO() | |
| with patch("sys.stdout", captured): | |
| rc = main(["tools"]) | |
| assert rc == 0 | |
| payload = json.loads(captured.getvalue()) | |
| assert "count" in payload | |
| assert "tools" in payload | |
| assert payload["count"] > 0 | |
| assert isinstance(payload["tools"], list) | |
| # Each entry has a name and a description. | |
| for tool in payload["tools"][:3]: | |
| assert "name" in tool | |
| assert "description" in tool | |
| # ββ ``find`` subcommand ββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_find_subcommand_returns_results(): | |
| """``find <name>`` returns the tool's response. | |
| We use a name that's likely to be in the seeded test data | |
| (``milk`` is a canonical name added by ``seed_walkthrough.py``). | |
| If the search finds nothing, the test still passes β the | |
| contract is "returns the tool's response", not "finds a match". | |
| """ | |
| from shopstack.cli import main | |
| captured = io.StringIO() | |
| with patch("sys.stdout", captured): | |
| rc = main(["find", "milk"]) | |
| assert rc == 0 | |
| # The payload may be empty (no match) or non-empty. Either is valid; | |
| # the contract is that the CLI returns the tool's response cleanly. | |
| payload = json.loads(captured.getvalue()) | |
| # ββ Error handling βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_unknown_subcommand_exits_nonzero(): | |
| """An unknown subcommand exits with a non-zero code.""" | |
| from shopstack.cli import main | |
| # argparse exits with code 2 on unknown subcommands. | |
| with pytest.raises(SystemExit) as exc_info: | |
| main(["nonsense-command"]) | |
| assert exc_info.value.code == 2 | |
| def test_find_subcommand_requires_name(): | |
| """``find`` requires a NAME argument (argparse enforces this).""" | |
| from shopstack.cli import main | |
| with pytest.raises(SystemExit) as exc_info: | |
| main(["find"]) | |
| assert exc_info.value.code == 2 | |
| # ββ Mode-portability proof (Tier 3) βββββββββββββββββββββββββββββββββ | |
| def test_cli_runs_without_launching_gradio(): | |
| """The CLI runs end-to-end without Gradio's launch path. | |
| This is the Tier-3 (integration) mode-portability proof: | |
| invoking the CLI does NOT call ``gradio.launch()`` or | |
| ``app.build_app()``. If a future change accidentally pulls | |
| the Gradio UI into the CLI's import chain, this test catches | |
| it. | |
| We use the ``whoami`` subcommand (no DB writes, no service | |
| calls beyond introspection) as the canary. | |
| """ | |
| from shopstack.cli import main | |
| with patch("gradio.Blocks.launch") as mock_launch: | |
| captured = io.StringIO() | |
| with patch("sys.stdout", captured): | |
| rc = main(["whoami"]) | |
| assert rc == 0 | |
| assert not mock_launch.called, ( | |
| "CLI must not call `gradio.Blocks.launch()`. " | |
| "If this fires, the CLI's import chain now pulls in the " | |
| "Gradio UI, which breaks the mode-portability contract." | |
| ) | |