Files
cleveragents-core/robot/helper_output_rendering.py
hurui200320 2434253c1a feat(cli): implement full output rendering framework (#812)
## Summary

Implement the 6 missing element handle types (Tree, Text, Code, Diff, Separator, ActionHint) and ensure all 6 materialization strategies (rich, color, table, plain, json, yaml) support all 10 element types. This completes the output rendering framework per the specification (§25417-27276).

Closes #550

## Changes

### Source (restructured into smaller modules)

#### `handles/` package (was `handles.py` — 1062 lines → 5 files, all ≤500 lines)
- **`_models.py`**: All Pydantic data models, constants (MAX_TREE_DEPTH, MAX_ELEMENTS_PER_SESSION, MAX_TABLE_ROWS), exceptions (ElementClosedError), event classes, ElementSnapshot union. P2-1: `DiffLine.type` renamed to `line_type` with backward-compatible alias.
- **`_base.py`**: Generic `ElementHandle[E]` base class with thread-safe `element_copy()` (lock-protected).
- **`_panel_table.py`**: PanelHandle, TableHandle, StatusHandle.
- **`_concrete.py`**: ProgressHandle (P1-1: validates `total` non-negative), TreeHandle, TextHandle (P3-2: close() delegates to super()), CodeHandle, DiffHandle, SeparatorHandle, ActionHintHandle. P3-8: `increment(delta=0)` now rejected.
- **`__init__.py`**: Re-exports all public symbols.

#### `_renderers.py` — plain renderers, sanitization, and shared helpers
- Plain render functions for all 10 element types.
- Terminal escape sanitization (`strip_terminal_escapes`).
- P2-3: `_sort_table_rows` now consults `ColumnDef.col_type` for numeric sorting.
- P2-6: `compute_column_widths()` shared helper extracted (DRY fix).

#### `_color_renderers.py` — ANSI colour renderers
- Colour-coded render functions for all 10 element types.
- P2-2: TextBlock now gets color treatment (`_render_text_color`) instead of falling through to plain.

#### `_boxdraw.py` — box-drawing renderers
- P2-5: Upgraded from ASCII `+-|` to Unicode box-drawing `╭─╮│╰╯` with rounded corners per spec §26821.

#### `_ids.py` — ID generation helpers
- P3-1: Session and handle IDs now use separate counters, making IDs monotonic within their namespace.

#### `materializers.py` — strategy protocol and 6 concrete strategies
- P1-2: `_snapshot_to_dict` now includes `timing` field in JSON/YAML output per spec §27022.
- P2-4: `_column_def_to_dict` always includes all fields unconditionally for stable JSON schemas.

#### `selection.py` — materializer selection with fallback
- P1-4: `NO_COLOR` environment variable now respected (https://no-color.org/). When set, all visual formats fall back to plain. Precedence: explicit flag > NO_COLOR > terminal capability fallback.

#### `session.py`
- P1-1: `session.progress()` factory validates `total >= 0`.
- P1-2: `snapshot()` includes `timing` when available.

#### `__init__.py` — package docstring
- P1-3: SD-29 corrected to reflect actual Table → Color → Plain fallback chain.
- SD-14 marked as implemented (NO_COLOR support added).

### Spec Deviations (Documented)
28 deliberate deviations documented in `__init__.py` module docstring (SD-1 through SD-29, with SD-14 now implemented). SD-29 corrected.

### Tests (updated)
- **+23 new BDD scenarios** covering: P1-1 total validation, P1-4 NO_COLOR, P2-1 DiffLine.line_type alias, P2-2 text color, P2-3 numeric sorting, P2-4 ColumnDef serialization, P2-5 Unicode box-drawing, P3-3 add_rows limit, P3-5 10-thread stress test, P3-6 summary truncation, P3-7 explicit format for color/table, P3-8 zero delta.
- **Robot tests**: Updated box-drawing assertion for Unicode chars.

## Verification

| Check | Result |
|-------|--------|
| Pyright | 0 errors, 1 pre-existing warning |
| Ruff lint | All passed |
| Unit tests | 393 features, 11,344 scenarios, 0 failures |
| Integration tests | All passed |
| E2E tests | All passed |
| Coverage | 97% overall (threshold: 97%) |

## Review Fixes Applied (Luis Review #2412)

| ID | Severity | Fix |
|----|----------|-----|
| P1-1 | High | `set_progress()` and `session.progress()` validate `total >= 0` |
| P1-2 | High | `_snapshot_to_dict` includes `timing` field |
| P1-3 | High | SD-29 documentation corrected |
| P1-4 | High | `NO_COLOR` env var respected |
| P2-1 | Medium | `DiffLine.type` → `line_type` with alias |
| P2-2 | Medium | TextBlock gets color treatment |
| P2-3 | Medium | Numeric column sorting |
| P2-4 | Medium | ColumnDef always serializes all fields |
| P2-5 | Medium | Unicode box-drawing characters |
| P2-6 | Medium | Shared `compute_column_widths()` helper |
| P3-1 | Low | Separate ID counters |
| P3-2 | Low | TextHandle.close() delegates to super() |
| P3-3 | Low | add_rows batch limit test |
| P3-5 | Low | 10-thread stress test |
| P3-6 | Low | Summary truncation test |
| P3-7 | Low | Explicit format tests for color/table |
| P3-8 | Low | Zero delta rejected |

### Deferred Items
| ID | Reason |
|----|--------|
| P3-9 | snapshot() lock scope — acceptable correctness trade-off |
| P3-10 | CLEVERAGENTS_FORMAT env var — documented as SD-15, requires CLI framework changes |

Reviewed-on: cleveragents/cleveragents-core#812
Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-authored-by: Rui Hu <rui.hu@cleverthis.com>
Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
2026-03-19 08:46:46 +00:00

374 lines
12 KiB
Python

"""Robot Framework helper for output rendering end-to-end tests."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import yaml
# Ensure the local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.cli.formatting import format_output # noqa: E402
from cleveragents.cli.output.handles import DiffLine # noqa: E402
from cleveragents.cli.output.materializers import ( # noqa: E402
ColorMaterializer,
JsonMaterializer,
PlainMaterializer,
RichMaterializer,
TableMaterializer,
YamlMaterializer,
)
from cleveragents.cli.output.selection import ( # noqa: E402
TerminalCapabilities,
select_materializer,
)
from cleveragents.cli.output.session import OutputSession # noqa: E402
ANSI_RE = re.compile(r"\033\[")
def test_plain_panel() -> None:
"""Plain format renders panel without ANSI codes."""
strategy = PlainMaterializer()
with OutputSession(format="plain", command="test", strategy=strategy) as session:
panel = session.panel("Project Info")
panel.set_entry("Name", "my-project")
panel.set_entry("Version", "1.0.0")
panel.close()
output = strategy.get_output()
assert "Project Info" in output
assert "Name: my-project" in output
assert not ANSI_RE.search(output), "Plain output should not contain ANSI codes"
print("output-rendering-plain-panel-ok")
def test_json_output() -> None:
"""JSON format produces valid JSON with elements."""
strategy = JsonMaterializer()
with OutputSession(format="json", command="test", strategy=strategy) as session:
panel = session.panel("Details")
panel.set_entry("Key", "Value")
panel.close()
table = session.table(columns=["Col1", "Col2"])
table.add_row({"Col1": "a", "Col2": "b"})
table.close()
output = strategy.get_output()
data = json.loads(output)
assert "elements" in data
assert len(data["elements"]) == 2
print("output-rendering-json-output-ok")
def test_yaml_output() -> None:
"""YAML format produces valid YAML with elements."""
strategy = YamlMaterializer()
with OutputSession(format="yaml", command="test", strategy=strategy) as session:
panel = session.panel("YAML Details")
panel.set_entry("Alpha", "Beta")
panel.close()
output = strategy.get_output()
data = yaml.safe_load(output)
assert "elements" in data
print("output-rendering-yaml-output-ok")
def test_table_boxdraw() -> None:
"""Table format uses Unicode box-drawing characters (P2-5 upgrade)."""
strategy = TableMaterializer()
with OutputSession(format="table", command="test", strategy=strategy) as session:
table = session.table(columns=["Name", "Status"])
table.add_row({"Name": "svc", "Status": "up"})
table.close()
output = strategy.get_output()
assert "" in output or "" in output, (
"Table output should contain Unicode box chars"
)
assert "svc" in output
print("output-rendering-table-boxdraw-ok")
def test_color_ansi() -> None:
"""Color format includes ANSI escape codes."""
strategy = ColorMaterializer()
with OutputSession(format="color", command="test", strategy=strategy) as session:
panel = session.panel("Coloured Title")
panel.set_entry("Key", "val")
panel.close()
output = strategy.get_output()
assert ANSI_RE.search(output), "Color output should contain ANSI codes"
print("output-rendering-color-ansi-ok")
def test_json_error() -> None:
"""Error envelope in JSON mode has correct structure."""
mat = JsonMaterializer()
output = mat.get_error_output("NOT_FOUND", "Item not found", {"id": "123"})
data = json.loads(output)
assert "error" in data
assert data["error"]["code"] == "NOT_FOUND"
assert data["error"]["message"] == "Item not found"
assert data["error"]["details"]["id"] == "123"
print("output-rendering-json-error-ok")
def test_backward_compat() -> None:
"""format_output still works for all formats.
For machine-readable formats (json, yaml, plain) ``format_output``
writes directly to ``sys.stdout`` and returns ``""``. We capture
stdout so the assertion checks the *produced* output rather than
just the return value.
"""
import sys as _sys
from io import StringIO as _SIO
data = {"Name": "test-proj", "Status": "active"}
for fmt in ("json", "yaml", "plain", "table", "rich"):
buf = _SIO()
old_stdout = _sys.stdout
_sys.stdout = buf
try:
result = format_output(data, fmt)
finally:
_sys.stdout = old_stdout
# Combine: either the return value has content, or stdout was written to
effective = result or buf.getvalue().rstrip("\n")
assert effective, f"format_output({fmt}) produced no output"
print("output-rendering-backward-compat-ok")
def test_selection_fallback() -> None:
"""Materializer selection with fallback logic."""
no_tty = TerminalCapabilities(
is_tty=False, supports_ansi=False, supports_cursor=False, term=""
)
# Rich falls back to plain when no TTY
mat = select_materializer("rich", capabilities=no_tty)
assert isinstance(mat, PlainMaterializer)
# JSON always works
mat = select_materializer("json", capabilities=no_tty)
assert isinstance(mat, JsonMaterializer)
# Explicit overrides fallback
mat = select_materializer("rich", capabilities=no_tty, explicit=True)
assert isinstance(mat, RichMaterializer)
print("output-rendering-selection-fallback-ok")
def test_tree_rendering() -> None:
"""Tree element renders across all formats."""
for fmt, cls in [("plain", PlainMaterializer), ("json", JsonMaterializer)]:
strategy = cls()
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
tree = session.tree("Root")
tree.add_child("Root", "src")
tree.add_child("Root", "tests")
tree.add_child("Root/src", "main.py")
tree.close()
output = strategy.get_output()
assert "Root" in output, f"Tree root not in {fmt} output"
assert "src" in output, f"Tree child 'src' not in {fmt} output"
print("output-rendering-tree-ok")
def test_code_rendering() -> None:
"""Code element renders with line numbers."""
strategy = PlainMaterializer()
with OutputSession(format="plain", command="bench", strategy=strategy) as session:
code = session.code("x = 1\ny = 2", language="python", line_numbers=True)
code.close()
output = strategy.get_output()
assert "1 |" in output, "Code should have line numbers"
assert "x = 1" in output
print("output-rendering-code-ok")
def test_diff_rendering() -> None:
"""Diff element renders unified diff format."""
strategy = PlainMaterializer()
with OutputSession(format="plain", command="bench", strategy=strategy) as session:
diff = session.diff(file_a="old.py", file_b="new.py")
diff.add_hunk(
"@@ -1,3 +1,4 @@",
[
DiffLine(type="context", content="import os"),
DiffLine(type="remove", content="x = 1"),
DiffLine(type="add", content="x = 2"),
DiffLine(type="add", content="y = 3"),
],
)
diff.set_stats(2, 1)
diff.close()
output = strategy.get_output()
assert "--- old.py" in output
assert "+++ new.py" in output
assert "+x = 2" in output
assert "-x = 1" in output
assert "2 insertion(s)" in output
print("output-rendering-diff-ok")
def test_text_separator_hint() -> None:
"""Text, separator, and action hint render correctly."""
strategy = PlainMaterializer()
with OutputSession(format="plain", command="bench", strategy=strategy) as session:
text = session.text("Hello World", indent=2)
text.close()
session.separator("line")
session.action_hint(["agents list", "agents deploy"], "Try these:")
output = strategy.get_output()
assert "Hello World" in output
assert "----" in output
assert "agents list" in output
print("output-rendering-text-sep-hint-ok")
def test_json_all_elements() -> None:
"""JSON format serializes all 10 element types."""
strategy = JsonMaterializer()
with OutputSession(format="json", command="all", strategy=strategy) as session:
# Panel
p = session.panel("P")
p.set_entry("k", "v")
p.close()
# Table
t = session.table(columns=["A"])
t.add_row({"A": "1"})
t.close()
# Status
s = session.status("ok")
s.close()
# Progress
pr = session.progress("go", total=10)
pr.set_progress(5, 10)
pr.close()
# Tree
tr = session.tree("Root")
tr.add_child("Root", "leaf")
tr.close()
# Text
tx = session.text("hello")
tx.close()
# Code
co = session.code("x=1", language="py")
co.close()
# Diff
d = session.diff(file_a="a", file_b="b")
d.add_hunk("@@", [DiffLine(type="add", content="new")])
d.close()
# Separator
session.separator("line")
# ActionHint
session.action_hint(["cmd"])
output = strategy.get_output()
data = json.loads(output)
types = [e["type"] for e in data["elements"]]
expected = [
"panel",
"table",
"status",
"progress",
"tree",
"text",
"code",
"diff",
"separator",
"action_hint",
]
assert types == expected, f"Expected {expected}, got {types}"
print("output-rendering-json-all-ok")
def test_yaml_all_elements() -> None:
"""YAML format serializes all 10 element types (M5 fix — Luis review)."""
strategy = YamlMaterializer()
with OutputSession(format="yaml", command="all", strategy=strategy) as session:
# Panel
p = session.panel("P")
p.set_entry("k", "v")
p.close()
# Table
t = session.table(columns=["A"])
t.add_row({"A": "1"})
t.close()
# Status
s = session.status("ok")
s.close()
# Progress
pr = session.progress("go", total=10)
pr.set_progress(5, 10)
pr.close()
# Tree
tr = session.tree("Root")
tr.add_child("Root", "leaf")
tr.close()
# Text
tx = session.text("hello")
tx.close()
# Code
co = session.code("x=1", language="py")
co.close()
# Diff
d = session.diff(file_a="a", file_b="b")
d.add_hunk("@@", [DiffLine(type="add", content="new")])
d.close()
# Separator
session.separator("line")
# ActionHint
session.action_hint(["cmd"])
output = strategy.get_output()
data = yaml.safe_load(output)
types = [e["type"] for e in data["elements"]]
expected = [
"panel",
"table",
"status",
"progress",
"tree",
"text",
"code",
"diff",
"separator",
"action_hint",
]
assert types == expected, f"Expected {expected}, got {types}"
print("output-rendering-yaml-all-ok")
_DISPATCH = {
"plain-panel": test_plain_panel,
"json-output": test_json_output,
"yaml-output": test_yaml_output,
"table-boxdraw": test_table_boxdraw,
"color-ansi": test_color_ansi,
"json-error": test_json_error,
"backward-compat": test_backward_compat,
"selection-fallback": test_selection_fallback,
"tree": test_tree_rendering,
"code": test_code_rendering,
"diff": test_diff_rendering,
"text-sep-hint": test_text_separator_hint,
"json-all": test_json_all_elements,
"yaml-all": test_yaml_all_elements,
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <test-name>", file=sys.stderr)
sys.exit(1)
test_name = sys.argv[1]
if test_name not in _DISPATCH:
print(f"Unknown test: {test_name}", file=sys.stderr)
sys.exit(1)
_DISPATCH[test_name]()