forked from cleveragents/cleveragents-core
2434253c1a
## 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>
374 lines
12 KiB
Python
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]()
|