Files
cleveragents-core/features/steps/output_rendering_registry_steps.py
HAL9000 7a2760b85f test(cli): boost coverage for registry.py render dispatch and edge cases
Add 16 BDD scenarios covering:
- PlainElementRenderer.render_element dispatch for all 9 element types
- _BaseElementRenderer.serialize() via PlainElementRenderer
- ColorElementRenderer.render_element dispatch for all element types
- TableElementRenderer can_render (True/False) and render_element dispatch
- RichElementRenderer.render_element dispatch + can_render True path
- JsonElementRenderer.render_element dispatch (all render_* return "")
- YamlElementRenderer.render_element dispatch (all render_* return "")
- RendererRegistry.resolve() unknown format fallback to plain
- RendererRegistry.resolve() ValueError when no plain fallback registered

Raises cli/output/registry.py coverage from 60.9% to pass the 96.5%
overall threshold.

ISSUES CLOSED: #917
2026-06-02 16:48:22 -04:00

594 lines
19 KiB
Python

"""Behave step definitions for the RendererRegistry / ElementRenderer
architecture (PR #1193).
Step patterns here are additive — they do not collide with the existing
step definitions in ``output_rendering_steps.py``. Two step functions
that read the JSON envelope use the master HEAD schema key ``"data"``
(post-M4 rename) rather than the legacy key ``"elements"``.
"""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.cli.output.handles import ColumnDef
from cleveragents.cli.output.materializers import (
JsonMaterializer,
PlainMaterializer,
)
from cleveragents.cli.output.selection import TerminalCapabilities
# ===========================================================================
# RendererRegistry tests
# ===========================================================================
@given("the default renderer registry")
def step_default_registry(context: Context) -> None:
from cleveragents.cli.output.registry import default_registry
context.registry = default_registry
@then('the registry has format "{fmt}" registered')
def step_registry_has_format(context: Context, fmt: str) -> None:
assert context.registry.is_registered(fmt), f"Format {fmt!r} not registered"
@then('the registry does not have format "{fmt}" registered')
def step_registry_no_format(context: Context, fmt: str) -> None:
assert not context.registry.is_registered(fmt), f"Format {fmt!r} is registered"
@then('the available formats are "{expected}"')
def step_available_formats(context: Context, expected: str) -> None:
expected_list = sorted(expected.split(","))
actual = context.registry.available_formats()
assert actual == expected_list, f"Expected {expected_list}, got {actual}"
@when('I resolve format "{fmt}" from the registry')
def step_resolve_format(context: Context, fmt: str) -> None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm-256color",
)
strategy, renderer = context.registry.resolve(fmt, caps)
context.resolved_strategy = strategy
context.resolved_renderer = renderer
@when('I resolve format "{fmt}" from the registry with terminal caps')
def step_resolve_format_with_caps(context: Context, fmt: str) -> None:
caps = getattr(context, "terminal_caps", None)
if caps is None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm-256color",
)
strategy, renderer = context.registry.resolve(fmt, caps)
context.resolved_strategy = strategy
context.resolved_renderer = renderer
@then('the resolved renderer format name is "{expected}"')
def step_resolved_renderer_name(context: Context, expected: str) -> None:
assert context.resolved_renderer.format_name == expected, (
f"Expected {expected!r}, got {context.resolved_renderer.format_name!r}"
)
@given("a new empty renderer registry")
def step_empty_registry(context: Context) -> None:
from cleveragents.cli.output.registry import RendererRegistry
context.registry = RendererRegistry()
@when('I register a custom "{fmt}" format in the registry')
def step_register_custom_format(context: Context, fmt: str) -> None:
from cleveragents.cli.output.registry import PlainElementRenderer
context.registry.register(
fmt,
strategy_factory=lambda caps: PlainMaterializer(),
renderer_factory=lambda caps: PlainElementRenderer(),
fallback="plain",
)
# ===========================================================================
# ElementRenderer tests
# ===========================================================================
@given("a PlainElementRenderer")
def step_plain_renderer(context: Context) -> None:
from cleveragents.cli.output.registry import PlainElementRenderer
context.element_renderer = PlainElementRenderer()
@given("a ColorElementRenderer")
def step_color_renderer(context: Context) -> None:
from cleveragents.cli.output.registry import ColorElementRenderer
context.element_renderer = ColorElementRenderer()
@given("a RichElementRenderer")
def step_rich_renderer(context: Context) -> None:
from cleveragents.cli.output.registry import RichElementRenderer
context.element_renderer = RichElementRenderer()
@given("a JsonElementRenderer")
def step_json_renderer(context: Context) -> None:
from cleveragents.cli.output.registry import JsonElementRenderer
context.element_renderer = JsonElementRenderer()
@given("a YamlElementRenderer")
def step_yaml_renderer(context: Context) -> None:
from cleveragents.cli.output.registry import YamlElementRenderer
context.element_renderer = YamlElementRenderer()
@when('I render a panel with title "{title}" through the renderer')
def step_render_panel_via_renderer(context: Context, title: str) -> None:
from cleveragents.cli.output.handles import Panel
panel = Panel(title=title)
context.renderer_output = context.element_renderer.render_panel(panel)
@then('the renderer output contains "{text}"')
def step_renderer_output_contains(context: Context, text: str) -> None:
assert text in context.renderer_output, (
f"Expected {text!r} in renderer output, got: {context.renderer_output!r}"
)
@then("the renderer can_render returns True for any terminal")
def step_renderer_can_render_true(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=False,
supports_ansi=False,
supports_cursor_movement=False,
term_program="",
)
assert context.element_renderer.can_render(caps) is True
@then("the renderer can_render returns False without ANSI")
def step_renderer_can_render_no_ansi(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=False,
supports_cursor_movement=False,
term_program="dumb",
)
assert context.element_renderer.can_render(caps) is False
@then("the renderer can_render returns False without cursor movement")
def step_renderer_can_render_no_cursor(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=False,
term_program="ansi",
)
assert context.element_renderer.can_render(caps) is False
@when("I serialize a StructuredOutput through the renderer")
def step_serialize_via_renderer(context: Context) -> None:
from cleveragents.cli.output.session import StructuredOutput
output = StructuredOutput(
command="test",
session_id="ses-test",
elements=[],
exit_code=0,
)
context.serialized_output = context.element_renderer.serialize(output)
@then("the serialized output is valid JSON")
def step_serialized_json(context: Context) -> None:
import json
json.loads(context.serialized_output)
@then("the serialized output is valid YAML")
def step_serialized_yaml(context: Context) -> None:
import yaml
yaml.safe_load(context.serialized_output)
@then("the serialized output has sorted keys")
def step_serialized_sorted_keys(context: Context) -> None:
import yaml
data = yaml.safe_load(context.serialized_output)
if isinstance(data, dict):
keys = list(data.keys())
assert keys == sorted(keys), f"Keys not sorted: {keys}"
# ===========================================================================
# TerminalCapabilities extended fields
# ===========================================================================
@when("I create a full TerminalCapabilities")
def step_full_caps(context: Context) -> None:
context.full_caps = TerminalCapabilities(
is_tty=True,
width=120,
height=40,
supports_ansi=True,
supports_256_color=True,
supports_truecolor=True,
supports_unicode=True,
supports_cursor_movement=True,
supports_alternate_screen=True,
no_color=False,
term_program="iTerm2",
)
@then("the capabilities has width {value:d}")
def step_caps_width(context: Context, value: int) -> None:
assert context.full_caps.width == value
@then("the capabilities has height {value:d}")
def step_caps_height(context: Context, value: int) -> None:
assert context.full_caps.height == value
@then("the capabilities has supports_256_color {value}")
def step_caps_256color(context: Context, value: str) -> None:
expected = value == "True"
assert context.full_caps.supports_256_color == expected
@then("the capabilities has supports_truecolor {value}")
def step_caps_truecolor(context: Context, value: str) -> None:
expected = value == "True"
assert context.full_caps.supports_truecolor == expected
@then("the capabilities has supports_unicode {value}")
def step_caps_unicode(context: Context, value: str) -> None:
expected = value == "True"
assert context.full_caps.supports_unicode == expected
@then("the capabilities has supports_alternate_screen {value}")
def step_caps_alt_screen(context: Context, value: str) -> None:
expected = value == "True"
assert context.full_caps.supports_alternate_screen == expected
@then("the capabilities has no_color {value}")
def step_caps_no_color(context: Context, value: str) -> None:
expected = value == "True"
assert context.full_caps.no_color == expected
@then('the capabilities has term_program "{value}"')
def step_caps_term_program(context: Context, value: str) -> None:
assert context.full_caps.term_program == value
@then("the capabilities supports_cursor property matches supports_cursor_movement")
def step_caps_cursor_alias(context: Context) -> None:
assert (
context.full_caps.supports_cursor == context.full_caps.supports_cursor_movement
)
@then("the capabilities term property matches term_program")
def step_caps_term_alias(context: Context) -> None:
assert context.full_caps.term == (context.full_caps.term_program or "")
# ===========================================================================
# ColumnDef.type alias
# ===========================================================================
@when('I create a ColumnDef with type alias "{type_val}"')
def step_create_coldef_type_alias(context: Context, type_val: str) -> None:
context.coldef = ColumnDef(type=type_val, name="test")
@then('the ColumnDef col_type is "{expected}"')
def step_coldef_col_type(context: Context, expected: str) -> None:
assert context.coldef.col_type == expected
# ===========================================================================
# Progress omitted from JSON — reads master HEAD's "data" envelope key
# ===========================================================================
@then('the json output does not contain element type "{etype}"')
def step_json_no_element_type(context: Context, etype: str) -> None:
import json
output = context.strategy.get_output()
data = json.loads(output)
for elem in data.get("data", []):
assert elem.get("type") != etype, f"Found element type {etype!r} in JSON output"
# ===========================================================================
# MaterializationStrategy.bind()
# ===========================================================================
@given("a PlainMaterializer instance")
def step_plain_mat_instance(context: Context) -> None:
context.bind_mat = PlainMaterializer()
@given("a JsonMaterializer instance")
def step_json_mat_instance(context: Context) -> None:
context.bind_mat = JsonMaterializer()
@when("I call bind on the materializer")
def step_call_bind(context: Context) -> None:
from cleveragents.cli.output.registry import PlainElementRenderer
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm",
)
context.bind_mat.bind(PlainElementRenderer(), terminal_caps=caps)
context.bind_error = None
@when("I call bind on the json materializer")
def step_call_bind_json(context: Context) -> None:
from cleveragents.cli.output.registry import JsonElementRenderer
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm",
)
context.bind_mat.bind(JsonElementRenderer(), terminal_caps=caps)
context.bind_error = None
@then("no error is raised")
def step_no_bind_error(context: Context) -> None:
assert getattr(context, "bind_error", None) is None
# ===========================================================================
# ColumnDef "type" key assertion — reads master HEAD's "data" envelope key
# ===========================================================================
@then("the json output ColumnDef uses type key not col_type")
def step_json_coldef_type_key(context: Context) -> None:
import json
output = context.strategy.get_output()
data = json.loads(output)
for elem in data.get("data", []):
if elem.get("type") == "table":
for col in elem.get("columns", []):
assert "type" in col, f"Missing 'type' key in ColumnDef: {col}"
assert "col_type" not in col, (
f"Found 'col_type' key (should be 'type'): {col}"
)
# ===========================================================================
# YAML does not contain assertion
# ===========================================================================
@then('the yaml output does not contain "{text}"')
def step_yaml_not_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
assert text not in output, f"Found {text!r} in YAML output: {output[:200]}"
# ===========================================================================
# TableElementRenderer given
# ===========================================================================
@given("a TableElementRenderer")
def step_table_renderer(context: Context) -> None:
from cleveragents.cli.output.registry import TableElementRenderer
context.element_renderer = TableElementRenderer()
# ===========================================================================
# Generic render_element dispatch — covers all render_* methods
# ===========================================================================
@when("I call render_element with each element type")
def step_render_all_element_types(context: Context) -> None:
from cleveragents.cli.output.handles import (
ActionHint,
CodeBlock,
DiffBlock,
ProgressIndicator,
Separator,
StatusMessage,
Table,
TextBlock,
Tree,
TreeNode,
)
elements = [
TextBlock(content="hello world"),
StatusMessage(message="all ok"),
CodeBlock(content="x = 1"),
DiffBlock(),
Separator(),
ActionHint(commands=["ls", "pwd"]),
Table(title="data"),
Tree(root=TreeNode(label="root")),
ProgressIndicator(label="loading"),
]
context.render_dispatch_error = None
try:
for elem in elements:
context.element_renderer.render_element(elem)
except Exception as exc:
context.render_dispatch_error = exc
@then("no render dispatch error occurred")
def step_no_dispatch_error(context: Context) -> None:
err = getattr(context, "render_dispatch_error", None)
assert err is None, f"Unexpected render error: {err}"
# ===========================================================================
# _BaseElementRenderer.serialize() via PlainElementRenderer
# ===========================================================================
@when("I call base serialize with a text element")
def step_base_serialize_text(context: Context) -> None:
from cleveragents.cli.output.handles import TextBlock
from cleveragents.cli.output.session import StructuredOutput
output = StructuredOutput(
command="test-base",
session_id="ses-base-test",
elements=[TextBlock(content="hello from base serialize")],
exit_code=0,
)
context.base_serialized = context.element_renderer.serialize(output)
@then("the base serialized output is a string")
def step_base_serialized_is_string(context: Context) -> None:
assert isinstance(context.base_serialized, str)
# ===========================================================================
# can_render True paths
# ===========================================================================
@then("color renderer can_render is True with ANSI support")
def step_color_can_render_true(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm-256color",
)
assert context.element_renderer.can_render(caps) is True
@then("table renderer can_render is True on a TTY terminal")
def step_table_can_render_true(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm",
)
assert context.element_renderer.can_render(caps) is True
@then("table renderer can_render is False on a non-TTY terminal")
def step_table_can_render_false(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=False,
supports_ansi=False,
supports_cursor_movement=False,
term_program="",
)
assert context.element_renderer.can_render(caps) is False
@then("rich renderer can_render is True with cursor movement")
def step_rich_can_render_true(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm",
)
assert context.element_renderer.can_render(caps) is True
# ===========================================================================
# RendererRegistry error paths
# ===========================================================================
@given("a registry with no plain and a non-renderable format")
def step_registry_no_plain_non_renderable(context: Context) -> None:
from cleveragents.cli.output.materializers import PlainMaterializer
from cleveragents.cli.output.registry import RendererRegistry
class _NeverRenders:
format_name = "norender"
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
return False
context.registry = RendererRegistry()
context.registry.register(
"norender",
strategy_factory=lambda caps: PlainMaterializer(),
renderer_factory=lambda caps: _NeverRenders(),
fallback=None,
)
context.no_plain_format = "norender"
@when("I try to resolve the non-renderable format")
def step_resolve_non_renderable(context: Context) -> None:
caps = TerminalCapabilities(
is_tty=False,
supports_ansi=False,
supports_cursor_movement=False,
term_program="",
)
context.resolve_error = None
try:
context.registry.resolve(context.no_plain_format, caps)
except ValueError as exc:
context.resolve_error = exc
@then("a ValueError is raised from resolve")
def step_valueerror_raised(context: Context) -> None:
assert context.resolve_error is not None, "Expected ValueError but none was raised"
assert isinstance(context.resolve_error, ValueError)