feat(devpulse): compass v2 P2 — drone @devpulse compass command (DPLAN-0212)
Thin command layer over the P1 storage core: add/query/stats/rate/archive/review via drone @devpulse compass. Ratings shown in query output ([GOOD]/[BAD]/...), --db flag for testing, auto-discovered (no devpulse.py change). 18 cmd tests, seedgo 29/29, no regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0df8fee947
commit
0d042dcb2f
@@ -0,0 +1,422 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: compass.py
|
||||
# Description: Compass Module — drone command for devpulse's rated decision store
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-16
|
||||
# Modified: 2026-06-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Compass Module — command routing for devpulse's rated decision store.
|
||||
|
||||
Compass is the truth-store of decisions: short, *rated* choices
|
||||
(``good | bad | impressive | interesting``) that devpulse consults at a fork.
|
||||
The rating IS the signal — repeat the good, avoid the bad. See DPLAN-0212.
|
||||
|
||||
This module is the thin command layer (FPLAN P2). It parses args, calls the
|
||||
``compass`` storage handler (FPLAN P1), and renders results to the console.
|
||||
No business logic lives here — that's the handler's job.
|
||||
|
||||
Subcommands:
|
||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]
|
||||
query "question" [--rating R] [--limit N]
|
||||
stats
|
||||
rate <id> <rating>
|
||||
archive <id>
|
||||
review
|
||||
|
||||
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
||||
testing and power use; omitted, it uses the real store.
|
||||
|
||||
Auto-discovered by devpulse.py via the handle_command() convention.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import err_console, error, warning
|
||||
from aipass.devpulse.apps.handlers import compass
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
console = err_console
|
||||
|
||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "review")
|
||||
|
||||
# Console colour per rating — the rating is the signal, so make it pop.
|
||||
_RATING_STYLE = {
|
||||
"good": "bold green",
|
||||
"bad": "bold red",
|
||||
"impressive": "bold magenta",
|
||||
"interesting": "bold yellow",
|
||||
}
|
||||
|
||||
HELP_TEXT = """\
|
||||
[bold cyan]compass[/bold cyan] — devpulse rated decision store
|
||||
|
||||
[bold]Usage:[/bold]
|
||||
compass add "context" "decision" --rating R [opts] Store a rated decision
|
||||
compass query "question" [--rating R] [--limit N] Search (rating shown)
|
||||
compass stats Counts by rating/status
|
||||
compass rate <id> <rating> Re-rate a decision
|
||||
compass archive <id> Archive a decision
|
||||
compass review Surface one to review
|
||||
compass --help Show this help
|
||||
|
||||
[bold]Ratings:[/bold] good | bad | impressive | interesting
|
||||
[bold]Sources:[/bold] devpulse | patrick
|
||||
|
||||
[bold]Options (add):[/bold]
|
||||
--rating R Required. One of the ratings above.
|
||||
--note "..." Optional human observation.
|
||||
--tags a,b,c Optional comma-separated tags.
|
||||
--source S Optional. devpulse (default) or patrick.
|
||||
|
||||
[bold]Options (all subcommands):[/bold]
|
||||
--db PATH Use an alternate SQLite store (testing / power use).
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
||||
drone @devpulse compass query "auth" --rating good --limit 3
|
||||
drone @devpulse compass stats
|
||||
drone @devpulse compass rate 4 bad
|
||||
drone @devpulse compass archive 4
|
||||
drone @devpulse compass review
|
||||
|
||||
See DPLAN-0212 (design) and the compass handler (apps/handlers/compass/).
|
||||
"""
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]compass Module[/bold cyan]")
|
||||
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
||||
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, review[/cyan]")
|
||||
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Route compass subcommands to the storage handler.
|
||||
|
||||
Auto-discovered by devpulse.py module loader.
|
||||
|
||||
Args:
|
||||
command: The primary command string.
|
||||
args: Additional arguments after the command.
|
||||
|
||||
Returns:
|
||||
True if the command was handled, False otherwise.
|
||||
"""
|
||||
if command != "compass":
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
console.print(HELP_TEXT)
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
sub_args = args[1:]
|
||||
|
||||
if subcommand not in _VALID_SUBCOMMANDS:
|
||||
error(f"Unknown compass subcommand: {subcommand}", suggestion="Use 'compass --help' for usage")
|
||||
return True
|
||||
|
||||
logger.info("[compass] subcommand=%s args=%s", subcommand, sub_args)
|
||||
json_handler.log_operation("compass_command", {"subcommand": subcommand})
|
||||
|
||||
if subcommand == "add":
|
||||
return _handle_add(sub_args)
|
||||
if subcommand == "query":
|
||||
return _handle_query(sub_args)
|
||||
if subcommand == "stats":
|
||||
return _handle_stats(sub_args)
|
||||
if subcommand == "rate":
|
||||
return _handle_rate(sub_args)
|
||||
if subcommand == "archive":
|
||||
return _handle_archive(sub_args)
|
||||
if subcommand == "review":
|
||||
return _handle_review(sub_args)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ARG PARSING HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _extract_flag(args: List[str], flag: str) -> tuple[List[str], Optional[str]]:
|
||||
"""Pull a single ``--flag VALUE`` pair out of args.
|
||||
|
||||
Returns the remaining args (flag + value removed) and the value (or None
|
||||
if the flag was absent). Raises ValueError if the flag is given without a
|
||||
following value — errors must fail loud, never silent.
|
||||
"""
|
||||
value: Optional[str] = None
|
||||
remaining: List[str] = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == flag:
|
||||
if i + 1 >= len(args):
|
||||
raise ValueError(f"{flag} requires a value")
|
||||
value = args[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
remaining.append(args[i])
|
||||
i += 1
|
||||
return remaining, value
|
||||
|
||||
|
||||
def _extract_db_path(args: List[str]) -> tuple[List[str], Optional[str]]:
|
||||
"""Pull the optional ``--db PATH`` flag out of args."""
|
||||
return _extract_flag(args, "--db")
|
||||
|
||||
|
||||
def _rating_tag(rating: str) -> str:
|
||||
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
||||
style = _RATING_STYLE.get(rating, "bold white")
|
||||
return f"[{style}]\\[{(rating or '?').upper()}][/{style}]"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBCOMMAND HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _handle_add(sub_args: List[str]) -> bool:
|
||||
"""Parse and dispatch ``compass add "context" "decision" --rating R [opts]``."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
rest, rating = _extract_flag(rest, "--rating")
|
||||
rest, note = _extract_flag(rest, "--note")
|
||||
rest, tags = _extract_flag(rest, "--tags")
|
||||
rest, source = _extract_flag(rest, "--source")
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add arg-parse error: %s", exc)
|
||||
error(str(exc), suggestion="Use 'compass --help' for usage")
|
||||
return True
|
||||
|
||||
if len(rest) < 2:
|
||||
error('Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]')
|
||||
return True
|
||||
if rating is None:
|
||||
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
||||
return True
|
||||
|
||||
context = rest[0]
|
||||
decision = rest[1]
|
||||
|
||||
try:
|
||||
new_id = compass.add_decision(
|
||||
context,
|
||||
decision,
|
||||
rating,
|
||||
note=note,
|
||||
tags=tags,
|
||||
source=source if source is not None else "devpulse",
|
||||
db_path=db_path,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add rejected: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
console.print(f"[green]Added decision[/green] {_rating_tag(rating)} [bold]#{new_id}[/bold]")
|
||||
console.print(f" [cyan]context:[/cyan] {context}")
|
||||
console.print(f" [cyan]decision:[/cyan] {decision}")
|
||||
if note:
|
||||
console.print(f" [cyan]note:[/cyan] {note}")
|
||||
if tags:
|
||||
console.print(f" [cyan]tags:[/cyan] {tags}")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_query(sub_args: List[str]) -> bool:
|
||||
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
rest, rating = _extract_flag(rest, "--rating")
|
||||
rest, limit_raw = _extract_flag(rest, "--limit")
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] query arg-parse error: %s", exc)
|
||||
error(str(exc), suggestion="Use 'compass --help' for usage")
|
||||
return True
|
||||
|
||||
if not rest:
|
||||
error('Usage: compass query "question" [--rating R] [--limit N]')
|
||||
return True
|
||||
|
||||
query_text = rest[0]
|
||||
|
||||
limit = 5
|
||||
if limit_raw is not None:
|
||||
try:
|
||||
limit = int(limit_raw)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] query bad --limit %r: %s", limit_raw, exc)
|
||||
error(f"--limit must be an integer, got {limit_raw!r}")
|
||||
return True
|
||||
|
||||
try:
|
||||
results = compass.query_decisions(query_text, rating=rating, limit=limit, db_path=db_path)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] query rejected: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
_render_query_results(query_text, rating, results)
|
||||
return True
|
||||
|
||||
|
||||
def _render_query_results(query_text: str, rating: Optional[str], results: List[dict]) -> None:
|
||||
"""Render query results — rating shown prominently, most relevant first."""
|
||||
filt = f" [dim](rating={rating})[/dim]" if rating else ""
|
||||
console.print(f"[bold]Compass[/bold] — {len(results)} result(s) for [cyan]{query_text!r}[/cyan]{filt}")
|
||||
|
||||
if not results:
|
||||
console.print("[dim]No matching decisions.[/dim]")
|
||||
return
|
||||
|
||||
console.print()
|
||||
for r in results:
|
||||
tag = _rating_tag(r.get("rating", "?"))
|
||||
console.print(f"{tag} [bold]#{r.get('id', '?')}[/bold] [dim]{r.get('created', '?')}[/dim]")
|
||||
console.print(f" [cyan]context:[/cyan] {r.get('context', '')}")
|
||||
console.print(f" [cyan]decision:[/cyan] {r.get('decision', '')}")
|
||||
if r.get("note"):
|
||||
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
||||
if r.get("tags"):
|
||||
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
||||
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
||||
console.print(f" [dim]{meta}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _handle_stats(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass stats`` and render readable counts."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] stats arg-parse error: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
if rest:
|
||||
error(f"compass stats takes no positional args, got: {' '.join(rest)}")
|
||||
return True
|
||||
|
||||
data = compass.stats(db_path=db_path)
|
||||
|
||||
console.print("[bold]Compass Stats[/bold]")
|
||||
console.print(f" Total decisions: [bold]{data.get('total', 0)}[/bold]")
|
||||
console.print(" [yellow]By rating:[/yellow]")
|
||||
for rating, count in (data.get("by_rating") or {}).items():
|
||||
console.print(f" {_rating_tag(rating)} {count}")
|
||||
console.print(" [yellow]By status:[/yellow]")
|
||||
for status, count in (data.get("by_status") or {}).items():
|
||||
console.print(f" [cyan]{status:<10}[/cyan] {count}")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_rate(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass rate <id> <rating>``."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] rate arg-parse error: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
if len(rest) < 2:
|
||||
error("Usage: compass rate <id> <rating>")
|
||||
return True
|
||||
|
||||
try:
|
||||
decision_id = int(rest[0])
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] rate bad id %r: %s", rest[0], exc)
|
||||
error(f"<id> must be an integer, got {rest[0]!r}")
|
||||
return True
|
||||
|
||||
rating = rest[1]
|
||||
try:
|
||||
changed = compass.rate(decision_id, rating, db_path=db_path)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] rate rejected: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
if changed:
|
||||
console.print(f"[green]Re-rated[/green] [bold]#{decision_id}[/bold] -> {_rating_tag(rating)}")
|
||||
else:
|
||||
warning(f"No decision with id {decision_id} — nothing changed.")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_archive(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass archive <id>``."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] archive arg-parse error: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
if not rest:
|
||||
error("Usage: compass archive <id>")
|
||||
return True
|
||||
|
||||
try:
|
||||
decision_id = int(rest[0])
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] archive bad id %r: %s", rest[0], exc)
|
||||
error(f"<id> must be an integer, got {rest[0]!r}")
|
||||
return True
|
||||
|
||||
changed = compass.archive(decision_id, db_path=db_path)
|
||||
if changed:
|
||||
console.print(
|
||||
f"[green]Archived[/green] [bold]#{decision_id}[/bold] [dim](kept as avoid-list, not deleted)[/dim]"
|
||||
)
|
||||
else:
|
||||
warning(f"No decision with id {decision_id} — nothing changed.")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_review(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass review`` — surface one active decision to review."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] review arg-parse error: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
if rest:
|
||||
error(f"compass review takes no positional args, got: {' '.join(rest)}")
|
||||
return True
|
||||
|
||||
result = compass.review(db_path=db_path)
|
||||
if result is None:
|
||||
console.print("[dim]No active decisions to review.[/dim]")
|
||||
return True
|
||||
|
||||
tag = _rating_tag(result.get("rating", "?"))
|
||||
console.print(f"[bold]Compass Review[/bold] {tag} [bold]#{result.get('id', '?')}[/bold]")
|
||||
console.print(f" [cyan]context:[/cyan] {result.get('context', '')}")
|
||||
console.print(f" [cyan]decision:[/cyan] {result.get('decision', '')}")
|
||||
if result.get("note"):
|
||||
console.print(f" [cyan]note:[/cyan] {result['note']}")
|
||||
if result.get("tags"):
|
||||
console.print(f" [cyan]tags:[/cyan] {result['tags']}")
|
||||
console.print(
|
||||
f" [dim]created={result.get('created', '?')} last_reviewed={result.get('last_reviewed', '?')} "
|
||||
f"surfaced={result.get('times_surfaced', 0)}[/dim]"
|
||||
)
|
||||
console.print("[dim]Tip: re-rate with 'compass rate <id> <rating>' or retire with 'compass archive <id>'.[/dim]")
|
||||
return True
|
||||
@@ -0,0 +1,277 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_compass_command.py
|
||||
# Description: Tests for the compass module command router (FPLAN P2)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-16
|
||||
# Modified: 2026-06-16
|
||||
# =============================================
|
||||
|
||||
"""Tests for the compass command router (FPLAN-0212 P2).
|
||||
|
||||
These exercise the thin command layer (``apps/modules/compass.py``) end to
|
||||
end against a real temp SQLite store via the ``--db`` flag — the same path the
|
||||
live ``drone @devpulse compass`` invocation takes. Everything goes through the
|
||||
module entry point (``handle_command``): the round-trip (add -> query -> see
|
||||
rating) is driven and asserted entirely via the command's own console output,
|
||||
so the storage handler is never reached into directly.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.devpulse.apps.modules import compass as compass_cmd
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> str:
|
||||
"""A temp DB path string, passed through to the command via --db."""
|
||||
return str(tmp_path / "compass_cmd_test.db")
|
||||
|
||||
|
||||
def _output(capsys) -> str:
|
||||
"""Combined stdout+stderr (err_console / error() route to stderr)."""
|
||||
captured = capsys.readouterr()
|
||||
return captured.out + captured.err
|
||||
|
||||
|
||||
def _add(capsys, db, context, decision, rating, *extra) -> int:
|
||||
"""Drive an add through the command entry point; return the new id.
|
||||
|
||||
The id is parsed from the command's own ``#<id>`` confirmation line — we
|
||||
never reach into the storage handler.
|
||||
"""
|
||||
capsys.readouterr() # isolate this add's output
|
||||
compass_cmd.handle_command("compass", ["add", context, decision, "--rating", rating, "--db", db, *extra])
|
||||
out = _output(capsys)
|
||||
match = re.search(r"#(\d+)", out)
|
||||
assert match, f"add did not report a new id; output was: {out!r}"
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def _query_out(capsys, db, query, *extra) -> str:
|
||||
"""Run a query via the command and return its captured output (drained)."""
|
||||
capsys.readouterr() # drop anything pending so we only see this query
|
||||
compass_cmd.handle_command("compass", ["query", query, "--db", db, *extra])
|
||||
return _output(capsys)
|
||||
|
||||
|
||||
def _stats_out(capsys, db) -> str:
|
||||
"""Run stats via the command and return its captured output (drained)."""
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command("compass", ["stats", "--db", db])
|
||||
return _output(capsys)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing basics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rejects_unrelated_command():
|
||||
"""Router returns False for commands that aren't 'compass'."""
|
||||
assert compass_cmd.handle_command("watchdog", []) is False
|
||||
|
||||
|
||||
def test_no_args_shows_introspection(capsys):
|
||||
"""Bare 'compass' shows introspection that mentions compass + subcommands."""
|
||||
assert compass_cmd.handle_command("compass", []) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "compass" in out
|
||||
assert "add" in out and "query" in out
|
||||
|
||||
|
||||
def test_help_flag_shows_usage(capsys):
|
||||
"""--help prints usage covering every subcommand."""
|
||||
assert compass_cmd.handle_command("compass", ["--help"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "usage" in out
|
||||
for sub in ("add", "query", "stats", "rate", "archive", "review"):
|
||||
assert sub in out
|
||||
|
||||
|
||||
def test_unknown_subcommand_errors(capsys):
|
||||
"""Unknown subcommand surfaces a clean error, still returns True."""
|
||||
assert compass_cmd.handle_command("compass", ["bogus"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "bogus" in out or "unknown" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add -> query round-trip (rating must be visible)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_then_query_shows_rating(capsys, db):
|
||||
"""add stores a decision; query surfaces it with the [GOOD] rating tag."""
|
||||
assert (
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "auth fork", "chose JWT over sessions", "--rating", "good", "--note", "worked", "--db", db],
|
||||
)
|
||||
is True
|
||||
)
|
||||
add_out = _output(capsys)
|
||||
assert "GOOD" in add_out # rating shown on add too
|
||||
|
||||
assert compass_cmd.handle_command("compass", ["query", "JWT", "--db", db]) is True
|
||||
q_out = _output(capsys)
|
||||
assert "GOOD" in q_out # the rating tag is the whole point
|
||||
assert "chose JWT over sessions" in q_out
|
||||
assert "auth fork" in q_out
|
||||
|
||||
|
||||
def test_query_rating_filter(capsys, db):
|
||||
"""--rating filters query results to the matching rating only."""
|
||||
compass_cmd.handle_command("compass", ["add", "ctx good", "good choice here", "--rating", "good", "--db", db])
|
||||
compass_cmd.handle_command("compass", ["add", "ctx bad", "bad choice here", "--rating", "bad", "--db", db])
|
||||
capsys.readouterr() # drain add output
|
||||
|
||||
assert compass_cmd.handle_command("compass", ["query", "choice", "--rating", "bad", "--db", db]) is True
|
||||
out = _output(capsys)
|
||||
assert "bad choice here" in out
|
||||
assert "good choice here" not in out
|
||||
|
||||
|
||||
def test_add_persists_to_store(capsys, db):
|
||||
"""add persists to the store; a later query surfaces it with its rating."""
|
||||
_add(capsys, db, "persist ctx", "persist decision", "interesting")
|
||||
|
||||
out = _query_out(capsys, db, "persist")
|
||||
assert "INTERESTING" in out
|
||||
assert "persist decision" in out
|
||||
assert "1 result(s)" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stats_reports_counts(capsys, db):
|
||||
"""stats shows total plus by-rating / by-status breakdown."""
|
||||
compass_cmd.handle_command("compass", ["add", "c1", "d1", "--rating", "good", "--db", db])
|
||||
compass_cmd.handle_command("compass", ["add", "c2", "d2", "--rating", "bad", "--db", db])
|
||||
capsys.readouterr()
|
||||
|
||||
assert compass_cmd.handle_command("compass", ["stats", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "total" in out
|
||||
assert "2" in out
|
||||
assert "good" in out and "bad" in out
|
||||
assert "active" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rate_changes_rating(capsys, db):
|
||||
"""rate <id> <rating> re-rates an existing decision."""
|
||||
new_id = _add(capsys, db, "rate ctx", "rate decision", "good")
|
||||
|
||||
assert compass_cmd.handle_command("compass", ["rate", str(new_id), "bad", "--db", db]) is True
|
||||
out = _output(capsys)
|
||||
assert "BAD" in out
|
||||
|
||||
# Confirm the new rating sticks: querying the row now shows [BAD], not [GOOD].
|
||||
q_out = _query_out(capsys, db, "rate")
|
||||
assert "BAD" in q_out
|
||||
assert "GOOD" not in q_out
|
||||
|
||||
|
||||
def test_rate_missing_id_warns(capsys, db):
|
||||
"""rate on a non-existent id reports 'nothing changed', does not crash."""
|
||||
assert compass_cmd.handle_command("compass", ["rate", "999", "good", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "999" in out and ("nothing changed" in out or "no decision" in out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# archive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_archive_removes_from_query(capsys, db):
|
||||
"""archive flips status; archived rows drop out of query, stats reflect it."""
|
||||
new_id = _add(capsys, db, "arch ctx", "arch decision", "good")
|
||||
|
||||
assert compass_cmd.handle_command("compass", ["archive", str(new_id), "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "archived" in out
|
||||
|
||||
# Archived rows no longer surface in query...
|
||||
q_out = _query_out(capsys, db, "arch")
|
||||
assert "0 result(s)" in q_out
|
||||
assert "arch decision" not in q_out
|
||||
# ...but stats still count them under archived.
|
||||
s_out = _stats_out(capsys, db).lower()
|
||||
assert "archived" in s_out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_review_surfaces_a_decision(capsys, db):
|
||||
"""review surfaces an active decision with its rating shown."""
|
||||
_add(capsys, db, "review ctx", "review decision", "impressive")
|
||||
|
||||
capsys.readouterr()
|
||||
assert compass_cmd.handle_command("compass", ["review", "--db", db]) is True
|
||||
out = _output(capsys)
|
||||
assert "IMPRESSIVE" in out
|
||||
assert "review decision" in out
|
||||
|
||||
|
||||
def test_review_empty_store(capsys, db):
|
||||
"""review on an empty store reports nothing to review (no crash)."""
|
||||
assert compass_cmd.handle_command("compass", ["review", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "no active" in out or "nothing" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error surfacing — must fail loud, never silent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bad_rating_error_surfaces(capsys, db):
|
||||
"""add with an invalid rating surfaces the handler's ValueError message."""
|
||||
assert compass_cmd.handle_command("compass", ["add", "ctx", "decision", "--rating", "terrible", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "rating" in out and "terrible" in out
|
||||
# Nothing should have been stored — stats reports total 0.
|
||||
assert "total decisions: 0" in _stats_out(capsys, db).lower()
|
||||
|
||||
|
||||
def test_add_requires_rating(capsys, db):
|
||||
"""add without --rating fails loud."""
|
||||
assert compass_cmd.handle_command("compass", ["add", "ctx", "decision", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "rating" in out
|
||||
|
||||
|
||||
def test_add_missing_positionals(capsys, db):
|
||||
"""add with too few positional args shows usage, stores nothing."""
|
||||
assert compass_cmd.handle_command("compass", ["add", "only-context", "--rating", "good", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "usage" in out
|
||||
assert "total decisions: 0" in _stats_out(capsys, db).lower()
|
||||
|
||||
|
||||
def test_query_bad_limit_errors(capsys, db):
|
||||
"""query with a non-integer --limit fails loud."""
|
||||
assert compass_cmd.handle_command("compass", ["query", "anything", "--limit", "abc", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "limit" in out
|
||||
|
||||
|
||||
def test_flag_without_value_errors(capsys, db):
|
||||
"""A flag given without a following value fails loud (no silent swallow)."""
|
||||
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "rating" in out and "value" in out
|
||||
Reference in New Issue
Block a user