7a2760b85f
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
594 lines
19 KiB
Python
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)
|