Files
cleveragents-core/features/steps/output_rendering_steps.py
T
2026-06-02 16:48:22 -04:00

2775 lines
93 KiB
Python

"""Step definitions for the output rendering framework feature tests."""
from __future__ import annotations
import contextlib
import json
import re
import threading
import time
from typing import Any, cast
import yaml
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from cleveragents.cli.formatting import (
format_output,
format_output_session,
)
from cleveragents.cli.output.handles import (
ActionHint,
CodeBlock,
ColumnDef,
DiffBlock,
DiffLine,
ElementClosedError,
ElementHandle,
ElementUpdated,
Panel,
PanelEntry,
ProgressIndicator,
StatusMessage,
Table,
TextBlock,
Tree,
)
from cleveragents.cli.output.materializers import (
ColorMaterializer,
JsonMaterializer,
PlainMaterializer,
RichMaterializer,
TableMaterializer,
YamlMaterializer,
strip_terminal_escapes,
)
from cleveragents.cli.output.selection import (
TerminalCapabilities,
detect_terminal_capabilities,
select_materializer,
)
from cleveragents.cli.output.session import OutputSession
# ---- ANSI detection helper ----
ANSI_RE = re.compile(r"\033\[")
# ===========================================================================
# OutputSession lifecycle
# ===========================================================================
@given('an OutputSession with format "{fmt}"')
def step_given_output_session(context: Context, fmt: str) -> None:
strategy = PlainMaterializer()
context.session = OutputSession(format=fmt, command="test", strategy=strategy)
context.strategy = strategy
@when("the session is closed")
def step_session_close(context: Context) -> None:
context.session_result = context.session.close()
@then('the session state is "{state}"')
def step_session_state(context: Context, state: str) -> None:
assert context.session._state == state
@when('I use an OutputSession context manager with format "{fmt}"')
def step_session_context_manager(context: Context, fmt: str) -> None:
strategy = PlainMaterializer()
context.strategy = strategy
with OutputSession(format=fmt, command="test-ctx", strategy=strategy) as sess:
context.session = sess
context.cm_session_open = sess._state == "open"
@then("the session was closed automatically")
def step_session_auto_closed(context: Context) -> None:
assert context.cm_session_open is True
assert context.session._state == "closed"
@when("I use an OutputSession context manager that raises an exception")
def step_session_ctx_exception(context: Context) -> None:
strategy = PlainMaterializer()
context.strategy = strategy
try:
with OutputSession(
format="plain", command="test-exc", strategy=strategy
) as sess:
context.session = sess
raise RuntimeError("Intentional test error")
except RuntimeError:
pass
# The session should have been closed with exit_code 1 by __exit__.
@then("the session exit code is {code:d}")
def step_session_exit_code(context: Context, code: int) -> None:
assert context.session._state == "closed"
# L8 fix (Luis review): use public snapshot() to read exit_code
# instead of accessing private _exit_code attribute.
snap = context.session.snapshot()
assert snap.exit_code == code, f"Expected exit_code={code}, got {snap.exit_code}"
# ===========================================================================
# PanelHandle
# ===========================================================================
@when('I create a panel handle with title "{title}"')
def step_create_panel(context: Context, title: str) -> None:
context.panel_handle = context.session.panel(title)
@when('I set entry "{key}" to "{value}"')
def step_set_entry(context: Context, key: str, value: str) -> None:
context.panel_handle.set_entry(key, value)
@when("I close the panel handle")
def step_close_panel(context: Context) -> None:
context.panel_handle.close()
@then("the panel has {count:d} entries")
def step_panel_entry_count(context: Context, count: int) -> None:
panel = cast(Panel, context.panel_handle.element)
assert len(panel.entries) == count, f"Expected {count}, got {len(panel.entries)}"
@then('the panel entry "{key}" has value "{value}"')
def step_panel_entry_value(context: Context, key: str, value: str) -> None:
panel = cast(Panel, context.panel_handle.element)
for entry in panel.entries:
if entry.key == key:
assert entry.value == value, f"Expected {value!r}, got {entry.value!r}"
return
raise AssertionError(f"Entry {key!r} not found in panel")
@when('I batch set entries with keys "{keys}" and values "{values}"')
def step_batch_set_entries(context: Context, keys: str, values: str) -> None:
key_list = keys.split(",")
val_list = values.split(",")
entries = dict(zip(key_list, val_list, strict=True))
context.panel_handle.set_entries(entries)
@when('I remove entry "{key}"')
def step_remove_entry(context: Context, key: str) -> None:
context.panel_handle.remove_entry(key)
@when('I add line "{key}" with value "{val}"')
def step_add_line(context: Context, key: str, val: str) -> None:
context.panel_handle.add_line(key, val)
@then('setting entry "{key}" to "{value}" raises ElementClosedError')
def step_set_entry_closed_raises(context: Context, key: str, value: str) -> None:
try:
context.panel_handle.set_entry(key, value)
raise AssertionError("Expected ElementClosedError")
except ElementClosedError:
pass
# ===========================================================================
# TableHandle
# ===========================================================================
@when('I create a table handle with columns "{columns}"')
def step_create_table(context: Context, columns: str) -> None:
col_list = columns.split(",")
context.table_handle = context.session.table(columns=col_list)
@when('I add a dict row with Name "{name}" and Status "{status}"')
def step_add_dict_row(context: Context, name: str, status: str) -> None:
tbl = cast(Table, context.table_handle.element)
col_names = [c.name for c in tbl.columns]
row: dict[str, str] = {}
if len(col_names) >= 1:
row[col_names[0]] = name
if len(col_names) >= 2:
row[col_names[1]] = status
context.table_handle.add_row(row)
@when('I add a list row with values "{values}"')
def step_add_list_row(context: Context, values: str) -> None:
vals = values.split(",")
context.table_handle.add_row(vals)
@when("I add {count:d} batch rows")
def step_add_batch_rows(context: Context, count: int) -> None:
tbl = cast(Table, context.table_handle.element)
col_names = [c.name for c in tbl.columns]
rows: list[dict[str, str]] = []
for i in range(count):
row: dict[str, str] = {}
for col in col_names:
row[col] = f"val-{i}"
rows.append(row)
context.table_handle.add_rows(rows)
@then("the table has {count:d} rows")
def step_table_row_count(context: Context, count: int) -> None:
tbl = cast(Table, context.table_handle.element)
assert len(tbl.rows) == count, f"Expected {count}, got {len(tbl.rows)}"
@when('I set summary with total "{total}"')
def step_set_summary(context: Context, total: str) -> None:
context.table_handle.set_summary({"total": total})
@then("the table summary is set")
def step_table_summary_set(context: Context) -> None:
tbl = cast(Table, context.table_handle.element)
assert tbl.summary is not None
@when('I set sort key to "{column}"')
def step_set_sort_key(context: Context, column: str) -> None:
context.table_handle.set_sort_key(column)
@then('the table sort key is "{column}"')
def step_table_sort_key(context: Context, column: str) -> None:
tbl = cast(Table, context.table_handle.element)
assert tbl.sort_key == column
@when("I close the table handle")
def step_close_table(context: Context) -> None:
context.table_handle.close()
@then("adding a row raises ElementClosedError")
def step_add_row_closed_raises(context: Context) -> None:
try:
context.table_handle.add_row({"Name": "x"})
raise AssertionError("Expected ElementClosedError")
except ElementClosedError:
pass
# ===========================================================================
# StatusHandle
# ===========================================================================
@when('I create a status handle with message "{message}"')
def step_create_status(context: Context, message: str) -> None:
context.status_handle = context.session.status(message)
@when('I update the status message to "{message}"')
def step_update_status_message(context: Context, message: str) -> None:
context.status_handle.set_message(message)
@when('I update the status level to "{level}"')
def step_update_status_level(context: Context, level: str) -> None:
context.status_handle.set_level(level)
@when('I set the status detail to "{detail}"')
def step_set_status_detail(context: Context, detail: str) -> None:
context.status_handle.set_detail(detail)
@when("I close the status handle")
def step_close_status(context: Context) -> None:
context.status_handle.close()
@then('the status message is "{message}"')
def step_status_message_check(context: Context, message: str) -> None:
sm = cast(StatusMessage, context.status_handle.element)
assert sm.message == message
@then('the status level is "{level}"')
def step_status_level_check(context: Context, level: str) -> None:
sm = cast(StatusMessage, context.status_handle.element)
assert sm.level == level
@then('the status detail is "{detail}"')
def step_status_detail_check(context: Context, detail: str) -> None:
sm = cast(StatusMessage, context.status_handle.element)
assert sm.detail == detail
@then('setting status level to "{level}" raises ValueError')
def step_invalid_status_level(context: Context, level: str) -> None:
try:
context.status_handle.set_level(level)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# ProgressHandle
# ===========================================================================
use_step_matcher("re")
@when(
r'I create a progress handle labelled "(?P<label>[^"]+)" with total (?P<total>\d+)'
)
def step_create_progress_total(context: Context, label: str, total: str) -> None:
context.progress_handle = context.session.progress(label, total=int(total))
@when(
r'I create a progress handle labelled "(?P<label>[^"]+)" with steps "(?P<steps>[^"]+)"'
)
def step_create_progress_steps(context: Context, label: str, steps: str) -> None:
step_list = steps.split(",")
context.progress_handle = context.session.progress(label, steps=step_list)
@when(r'I create a progress handle labelled "(?P<label>[^"]+)"')
def step_create_progress(context: Context, label: str) -> None:
context.progress_handle = context.session.progress(label)
use_step_matcher("parse")
@when("I set progress to {current:d} of {total:d}")
def step_set_progress(context: Context, current: int, total: int) -> None:
context.progress_handle.set_progress(current, total)
@when("I tick the progress {count:d} times")
def step_tick_progress(context: Context, count: int) -> None:
for _ in range(count):
context.progress_handle.tick()
@when("I increment progress by {delta:d}")
def step_increment_progress(context: Context, delta: int) -> None:
context.progress_handle.increment(delta)
@when('I set progress label to "{label}"')
def step_set_progress_label(context: Context, label: str) -> None:
context.progress_handle.set_label(label)
@when("I set progress to indeterminate mode")
def step_set_indeterminate(context: Context) -> None:
context.progress_handle.set_indeterminate()
@when('I set step "{step}" status to "{status}"')
def step_set_step_status(context: Context, step: str, status: str) -> None:
context.progress_handle.set_step_status(step, status)
@when("I close the progress handle")
def step_close_progress(context: Context) -> None:
context.progress_handle.close()
@then("the progress current is {val:d}")
def step_progress_current(context: Context, val: int) -> None:
pi = cast(ProgressIndicator, context.progress_handle.element)
assert pi.current == val, f"Expected {val}, got {pi.current}"
@then("the progress total is {val:d}")
def step_progress_total(context: Context, val: int) -> None:
pi = cast(ProgressIndicator, context.progress_handle.element)
assert pi.total == val
@then('the progress label is "{label}"')
def step_progress_label(context: Context, label: str) -> None:
pi = cast(ProgressIndicator, context.progress_handle.element)
assert pi.label == label
@then("the progress is indeterminate")
def step_progress_indeterminate(context: Context) -> None:
pi = cast(ProgressIndicator, context.progress_handle.element)
assert pi.indeterminate is True
@then('step "{step}" has status "{status}"')
def step_check_step_status(context: Context, step: str, status: str) -> None:
pi = cast(ProgressIndicator, context.progress_handle.element)
assert pi.steps is not None
for s in pi.steps:
if s.label == step:
assert s.status == status, f"Expected {status}, got {s.status}"
return
raise AssertionError(f"Step {step!r} not found")
@then("setting progress to {val:d} raises ValueError")
def step_negative_progress(context: Context, val: int) -> None:
try:
context.progress_handle.set_progress(val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then('setting step "{step}" to status "{status}" raises ValueError')
def step_invalid_step_status(context: Context, step: str, status: str) -> None:
try:
context.progress_handle.set_step_status(step, status)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Materializer output tests
# ===========================================================================
@given('an OutputSession with format "{fmt}" using PlainMaterializer')
def step_session_plain(context: Context, fmt: str) -> None:
context.strategy = PlainMaterializer()
context.session = OutputSession(
format=fmt, command="test", strategy=context.strategy
)
@given('an OutputSession with format "{fmt}" using ColorMaterializer')
def step_session_color(context: Context, fmt: str) -> None:
context.strategy = ColorMaterializer()
context.session = OutputSession(
format=fmt, command="test", strategy=context.strategy
)
@given('an OutputSession with format "{fmt}" using TableMaterializer')
def step_session_table(context: Context, fmt: str) -> None:
context.strategy = TableMaterializer()
context.session = OutputSession(
format=fmt, command="test", strategy=context.strategy
)
@given('an OutputSession with format "{fmt}" using RichMaterializer')
def step_session_rich(context: Context, fmt: str) -> None:
context.strategy = RichMaterializer()
context.session = OutputSession(
format=fmt, command="test", strategy=context.strategy
)
@given('an OutputSession with format "{fmt}" using JsonMaterializer')
def step_session_json(context: Context, fmt: str) -> None:
context.strategy = JsonMaterializer()
context.session = OutputSession(
format=fmt, command="test", strategy=context.strategy
)
@given('an OutputSession with format "{fmt}" using YamlMaterializer')
def step_session_yaml(context: Context, fmt: str) -> None:
context.strategy = YamlMaterializer()
context.session = OutputSession(
format=fmt, command="test", strategy=context.strategy
)
# Plain output assertions
@then('the plain output contains "{text}"')
def step_plain_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
assert text in output, f"Expected {text!r} in output:\n{output}"
@then("the plain output does not contain ANSI codes")
def step_plain_no_ansi(context: Context) -> None:
output = context.strategy.get_output()
assert not ANSI_RE.search(output), f"Found ANSI codes in plain output:\n{output}"
# Color output assertions
@then("the color output contains ANSI codes")
def step_color_has_ansi(context: Context) -> None:
output = context.strategy.get_output()
assert ANSI_RE.search(output), f"No ANSI codes found in color output:\n{output}"
@then('the color output contains "{text}"')
def step_color_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
# Strip ANSI for text check
clean = ANSI_RE.sub("", output).replace("\033[0m", "").replace("\033", "")
assert text in output or text in clean, f"Expected {text!r} in output:\n{output}"
# Table output assertions
@then("the table output contains box-drawing characters")
def step_table_box_chars(context: Context) -> None:
output = context.strategy.get_output()
# P2-5: upgraded to Unicode box-drawing characters.
assert "" in output or "" in output, f"No box chars in:\n{output}"
@then('the table output contains "{text}"')
def step_table_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
assert text in output, f"Expected {text!r} in:\n{output}"
# Rich output assertions
@then('the rich output contains "{text}"')
def step_rich_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
clean = re.sub(r"\033\[[0-9;]*m", "", output)
assert text in output or text in clean, f"Expected {text!r} in:\n{output}"
# JSON output assertions
@then("the json output is valid JSON")
def step_json_valid(context: Context) -> None:
output = context.strategy.get_output()
context.json_data = json.loads(output)
@then('the json output contains element type "{etype}"')
def step_json_element_type(context: Context, etype: str) -> None:
data = context.json_data
elements = data.get("data", [])
types = [e.get("type") for e in elements]
assert etype in types, f"Expected element type {etype!r} in {types}"
@then('the json output contains "{text}"')
def step_json_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
assert text in output, f"Expected {text!r} in JSON output"
# YAML output assertions
@then("the yaml output is valid YAML")
def step_yaml_valid(context: Context) -> None:
output = context.strategy.get_output()
context.yaml_data = yaml.safe_load(output)
@then('the yaml output contains "{text}"')
def step_yaml_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
assert text in output, f"Expected {text!r} in YAML output"
# ===========================================================================
# Error envelope
# ===========================================================================
@given("a JsonMaterializer")
def step_json_materializer(context: Context) -> None:
context.json_mat = JsonMaterializer()
@when('I generate an error envelope with code "{code}" and message "{msg}"')
def step_json_error_envelope(context: Context, code: str, msg: str) -> None:
context.json_error_output = context.json_mat.get_error_output(code, msg)
@then('the json error output contains "{text}"')
def step_json_error_contains(context: Context, text: str) -> None:
assert text in context.json_error_output, (
f"Expected {text!r} in:\n{context.json_error_output}"
)
@given("a YamlMaterializer")
def step_yaml_materializer(context: Context) -> None:
context.yaml_mat = YamlMaterializer()
@when('I generate a yaml error envelope with code "{code}" and message "{msg}"')
def step_yaml_error_envelope(context: Context, code: str, msg: str) -> None:
context.yaml_error_output = context.yaml_mat.get_error_output(code, msg)
@then('the yaml error output contains "{text}"')
def step_yaml_error_contains(context: Context, text: str) -> None:
assert text in context.yaml_error_output, (
f"Expected {text!r} in:\n{context.yaml_error_output}"
)
# ===========================================================================
# Concurrent producers
# ===========================================================================
@when("I create two panels concurrently and close them out of order")
def step_concurrent_panels(context: Context) -> None:
panel_a = context.session.panel("Panel A")
panel_b = context.session.panel("Panel B")
# Simulate concurrent writes with threads
def write_a() -> None:
panel_a.set_entry("A-Key", "A-Val")
def write_b() -> None:
panel_b.set_entry("B-Key", "B-Val")
t1 = threading.Thread(target=write_a)
t2 = threading.Thread(target=write_b)
t1.start()
t2.start()
t1.join()
t2.join()
# Close out of declaration order (B before A)
panel_b.close()
panel_a.close()
@then("the plain output has panel A before panel B")
def step_output_order(context: Context) -> None:
output = context.strategy.get_output()
pos_a = output.find("Panel A")
pos_b = output.find("Panel B")
assert pos_a >= 0, f"Panel A not found in output:\n{output}"
assert pos_b >= 0, f"Panel B not found in output:\n{output}"
assert pos_a < pos_b, (
f"Panel A (pos={pos_a}) should appear before Panel B (pos={pos_b})"
)
# ===========================================================================
# Format fallback / materializer selection
# ===========================================================================
@given("a non-TTY terminal environment")
def step_non_tty(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=False, supports_ansi=False, supports_cursor=False, term=""
)
@given("a terminal without ANSI support")
def step_no_ansi(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=True, supports_ansi=False, supports_cursor=False, term="dumb"
)
@when('I select a materializer for format "{fmt}"')
def step_select_materializer(context: Context, fmt: str) -> None:
caps = getattr(context, "terminal_caps", None)
context.selected_mat = select_materializer(fmt, capabilities=caps)
@when('I select a materializer for format "{fmt}" with explicit flag')
def step_select_materializer_explicit(context: Context, fmt: str) -> None:
caps = getattr(context, "terminal_caps", None)
context.selected_mat = select_materializer(fmt, capabilities=caps, explicit=True)
@then("the selected materializer is PlainMaterializer")
def step_mat_is_plain(context: Context) -> None:
assert isinstance(context.selected_mat, PlainMaterializer)
@then("the selected materializer is JsonMaterializer")
def step_mat_is_json(context: Context) -> None:
assert isinstance(context.selected_mat, JsonMaterializer)
@then("the selected materializer is RichMaterializer")
def step_mat_is_rich(context: Context) -> None:
assert isinstance(context.selected_mat, RichMaterializer)
@when('I attempt to select a materializer for format "{fmt}"')
def step_select_invalid_format(context: Context, fmt: str) -> None:
context.selection_error = None
try:
select_materializer(fmt)
except ValueError as exc:
context.selection_error = exc
@when("I attempt to select a materializer with empty format")
def step_select_empty_format(context: Context) -> None:
context.selection_error = None
try:
select_materializer("")
except ValueError as exc:
context.selection_error = exc
@then("a ValueError is raised")
def step_value_error_raised(context: Context) -> None:
assert context.selection_error is not None
# ===========================================================================
# Backward compatibility
# ===========================================================================
@when('I call format_output with a dict and format "{fmt}"')
def step_call_format_output(context: Context, fmt: str) -> None:
from contextlib import redirect_stdout
from io import StringIO
data = {"Name": "test", "Status": "active"}
buf = StringIO()
with redirect_stdout(buf):
result = format_output(data, fmt)
context.format_result = result or buf.getvalue().rstrip("\n")
@then("the output contains key-value pairs")
def step_output_kv(context: Context) -> None:
assert "Name" in context.format_result
assert "test" in context.format_result
@then("the output is valid JSON")
def step_output_valid_json(context: Context) -> None:
json.loads(context.format_result)
@when('I call format_output_session with a dict and format "{fmt}"')
def step_call_format_output_session(context: Context, fmt: str) -> None:
data = {"Alpha": "one", "Beta": "two"}
context.session_format_result = format_output_session(data, fmt)
@then("the session output contains the dict keys")
def step_session_output_keys(context: Context) -> None:
assert "Alpha" in context.session_format_result
assert "Beta" in context.session_format_result
# ===========================================================================
# Handle context manager
# ===========================================================================
@when("I use a panel handle as a context manager")
def step_panel_ctx_manager(context: Context) -> None:
with context.session.panel("CTX Panel") as p:
p.set_entry("X", "Y")
context.panel_handle = p
@then("the handle is closed after the context exits")
def step_handle_closed(context: Context) -> None:
assert context.panel_handle.is_open is False
# ===========================================================================
# Snapshot
# ===========================================================================
@then("the session snapshot has {count:d} elements")
def step_snapshot_count(context: Context, count: int) -> None:
snap = context.session.snapshot()
assert len(snap.elements) == count, (
f"Expected {count} elements, got {len(snap.elements)}"
)
# ===========================================================================
# Coverage boost: handle validation guards
# ===========================================================================
@then("setting entry with empty key raises ValueError")
def step_set_entry_empty_key(context: Context) -> None:
try:
context.panel_handle.set_entry("", "value")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("batch setting None entries raises ValueError")
def step_batch_set_none(context: Context) -> None:
try:
none_val: Any = None
context.panel_handle.set_entries(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("removing entry with empty key raises ValueError")
def step_remove_empty_key(context: Context) -> None:
try:
context.panel_handle.remove_entry("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("adding a None row raises ValueError")
def step_add_none_row(context: Context) -> None:
try:
none_val: Any = None
context.table_handle.add_row(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("adding None rows raises ValueError")
def step_add_none_rows(context: Context) -> None:
try:
none_val: Any = None
context.table_handle.add_rows(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting None summary raises ValueError")
def step_set_none_summary(context: Context) -> None:
try:
none_val: Any = None
context.table_handle.set_summary(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting empty sort key raises ValueError")
def step_set_empty_sort_key(context: Context) -> None:
try:
context.table_handle.set_sort_key("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting empty status message raises ValueError")
def step_set_empty_status_msg(context: Context) -> None:
try:
context.status_handle.set_message("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting empty status level raises ValueError")
def step_set_empty_status_level(context: Context) -> None:
try:
context.status_handle.set_level("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting empty progress label raises ValueError")
def step_set_empty_progress_label(context: Context) -> None:
try:
context.progress_handle.set_label("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("incrementing by negative delta raises ValueError")
def step_incr_negative(context: Context) -> None:
try:
context.progress_handle.increment(-1)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting step status with empty label raises ValueError")
def step_set_step_empty_label(context: Context) -> None:
try:
context.progress_handle.set_step_status("", "complete")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Coverage boost: handle data paths
# ===========================================================================
@when('I batch add list rows with values "{r1}" and "{r2}"')
def step_batch_list_rows(context: Context, r1: str, r2: str) -> None:
rows = [r1.split(","), r2.split(",")]
context.table_handle.add_rows(rows)
@when("I set progress current to {val:d}")
def step_set_progress_current(context: Context, val: int) -> None:
context.progress_handle.set_progress(val)
# ===========================================================================
# Coverage boost: materializer step helpers
# ===========================================================================
@when("I create a table with no columns")
def step_create_empty_table(context: Context) -> None:
context.table_handle = context.session.table(columns=[])
@when('I set table title to "{title}"')
def step_set_table_title(context: Context, title: str) -> None:
tbl = cast(Table, context.table_handle.element)
tbl.title = title
@when('I set entry with key "{key}" value "{val}" and style hint "{hint}"')
def step_set_entry_style_hint(context: Context, key: str, val: str, hint: str) -> None:
panel = cast(Panel, context.panel_handle.element)
panel.entries.append(PanelEntry(key=key, value=val, style_hint=hint))
@when('I set entry with icon key "{key}" value "{val}" and icon "{icon}"')
def step_set_entry_icon(context: Context, key: str, val: str, icon: str) -> None:
panel = cast(Panel, context.panel_handle.element)
panel.entries.append(PanelEntry(key=key, value=val, icon=icon))
@when(
'I generate an error envelope with code "{code}" message "{msg}" and details "{det}"'
)
def step_json_error_with_details(
context: Context, code: str, msg: str, det: str
) -> None:
context.json_error_output = context.json_mat.get_error_output(
code, msg, details={"info": det}
)
@then('the strategy output contains "{text}"')
def step_strategy_output_contains(context: Context, text: str) -> None:
output = context.strategy.get_output()
assert text in output, f"Expected {text!r} in:\n{output}"
# ===========================================================================
# Coverage boost: format selection
# ===========================================================================
@given("a terminal with ANSI but no cursor support")
def step_ansi_no_cursor(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=True, supports_ansi=True, supports_cursor=False, term="xterm"
)
@given("a terminal with ANSI support")
def step_ansi_support(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=True, supports_ansi=True, supports_cursor=False, term="xterm"
)
@given("a TTY terminal with ANSI support")
def step_tty_ansi(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=True, supports_ansi=True, supports_cursor=True, term="xterm-256color"
)
@then("the selected materializer is ColorMaterializer")
def step_mat_is_color(context: Context) -> None:
assert isinstance(context.selected_mat, ColorMaterializer)
@then("the selected materializer is YamlMaterializer")
def step_mat_is_yaml(context: Context) -> None:
assert isinstance(context.selected_mat, YamlMaterializer)
@then("the selected materializer is TableMaterializer")
def step_mat_is_table(context: Context) -> None:
assert isinstance(context.selected_mat, TableMaterializer)
# ===========================================================================
# Coverage boost: session edge cases
# ===========================================================================
@then("creating a panel with empty title raises ValueError")
def step_panel_empty_title(context: Context) -> None:
try:
context.session.panel("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a status with empty message raises ValueError")
def step_status_empty_msg(context: Context) -> None:
try:
context.session.status("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a progress with empty label raises ValueError")
def step_progress_empty_label(context: Context) -> None:
try:
context.session.progress("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a panel on closed session raises RuntimeError")
def step_closed_session_panel(context: Context) -> None:
try:
context.session.panel("Should fail")
raise AssertionError("Expected RuntimeError")
except RuntimeError:
pass
# ===========================================================================
# ElementHandle validation guards
# ===========================================================================
@then("constructing a handle with empty handle_id raises ValueError")
def step_handle_empty_id(context: Context) -> None:
try:
ElementHandle(
handle_id="",
element_type="panel",
declaration_index=0,
session=context.session,
element=Panel(title="T"),
)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("constructing a handle with empty element_type raises ValueError")
def step_handle_empty_type(context: Context) -> None:
try:
ElementHandle(
handle_id="h1",
element_type="",
declaration_index=0,
session=context.session,
element=Panel(title="T"),
)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("constructing a handle with negative index raises ValueError")
def step_handle_negative_index(context: Context) -> None:
try:
ElementHandle(
handle_id="h1",
element_type="panel",
declaration_index=-1,
session=context.session,
element=Panel(title="T"),
)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("constructing a handle with None session raises ValueError")
def step_handle_none_session(context: Context) -> None:
try:
ElementHandle(
handle_id="h1",
element_type="panel",
declaration_index=0,
session=cast(Any, None),
element=Panel(title="T"),
)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("constructing a handle with None element raises ValueError")
def step_handle_none_element(context: Context) -> None:
try:
ElementHandle(
handle_id="h1",
element_type="panel",
declaration_index=0,
session=context.session,
element=cast(Any, None),
)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Handle close / __exit__ edge cases
# ===========================================================================
@when("I close the panel handle again")
def step_close_panel_again(context: Context) -> None:
context.panel_handle.close()
@then('the handle state is "{state}"')
def step_handle_state(context: Context, state: str) -> None:
handle = getattr(context, "panel_handle", None)
assert handle is not None
assert handle._state == state, f"Expected {state}, got {handle._state}"
@when('I use a panel handle as context manager with title "{title}"')
def step_use_context_manager(context: Context, title: str) -> None:
with context.session.panel(title) as handle:
handle.set_entry("key", "value")
context.panel_handle = handle
# ===========================================================================
# Table list rows
# ===========================================================================
# ===========================================================================
# Table with ColumnDef objects
# ===========================================================================
@when('I create a table with ColumnDef objects "{cols}"')
def step_table_columndef(context: Context, cols: str) -> None:
col_defs = [ColumnDef(name=c.strip()) for c in cols.split(",")]
context.table_handle = context.session.table(columns=col_defs)
# ===========================================================================
# Detect capabilities
# ===========================================================================
@when("I detect terminal capabilities")
def step_detect_caps(context: Context) -> None:
context.detected_caps = detect_terminal_capabilities()
# ===========================================================================
# Second panel handle for out-of-order flush
# ===========================================================================
@when('I create a second panel handle with title "{title}"')
def step_create_second_panel(context: Context, title: str) -> None:
context.panel_handle_2 = context.session.panel(title)
@when('I set entry "{key}" to "{val}" on handle 2')
def step_set_entry_handle2(context: Context, key: str, val: str) -> None:
context.panel_handle_2.set_entry(key, val)
@when("I close handle 2")
def step_close_handle2(context: Context) -> None:
context.panel_handle_2.close()
# ===========================================================================
# JSON serializer with no session
# ===========================================================================
@given("a JsonMaterializer with no session")
def step_json_no_session(context: Context) -> None:
context.strategy = JsonMaterializer()
@then("the serializer get_output returns empty string")
def step_serializer_empty(context: Context) -> None:
output = context.strategy.get_output()
assert output == "", f"Expected empty string, got: {output!r}"
# ===========================================================================
# TreeHandle
# ===========================================================================
use_step_matcher("re")
@when(
r'I create a tree handle with root label "(?P<label>[^"]+)" and show_guides false'
)
def step_create_tree_no_guides(context: Context, label: str) -> None:
context.tree_handle = context.session.tree(label, show_guides=False)
@when(
r'I create a tree handle with root label "(?P<label>[^"]+)" and max_depth_hint (?P<depth>\d+)'
)
def step_create_tree_max_depth(context: Context, label: str, depth: str) -> None:
context.tree_handle = context.session.tree(label, max_depth_hint=int(depth))
@when(r'I create a tree handle with root label "(?P<label>[^"]+)"')
def step_create_tree(context: Context, label: str) -> None:
context.tree_handle = context.session.tree(label)
use_step_matcher("parse")
@when('I add a tree child "{label}" under "{path}"')
def step_add_tree_child(context: Context, label: str, path: str) -> None:
context.tree_handle.add_child(path, label)
@when("I close the tree handle")
def step_close_tree(context: Context) -> None:
context.tree_handle.close()
@when('I set tree node style "{path}" to "{style}"')
def step_set_tree_node_style(context: Context, path: str, style: str) -> None:
context.tree_handle.set_node_style(path, style)
@then('the tree root label is "{label}"')
def step_tree_root_label(context: Context, label: str) -> None:
tree = cast(Tree, context.tree_handle.element)
assert tree.root.label == label, f"Expected {label!r}, got {tree.root.label!r}"
@then("the tree root has {count:d} children")
def step_tree_root_children(context: Context, count: int) -> None:
tree = cast(Tree, context.tree_handle.element)
actual = len(tree.root.children)
assert actual == count, f"Expected {count} children, got {actual}"
@then('the tree node "{path}" has {count:d} children')
def step_tree_node_children(context: Context, path: str, count: int) -> None:
node = context.tree_handle._find_node(path)
assert node is not None, f"Node {path!r} not found"
actual = len(node.children)
assert actual == count, f"Expected {count} children, got {actual}"
@then('the tree node "{path}" has style "{style}"')
def step_tree_node_style(context: Context, path: str, style: str) -> None:
node = context.tree_handle._find_node(path)
assert node is not None, f"Node {path!r} not found"
assert node.style_hint == style, f"Expected {style!r}, got {node.style_hint!r}"
@then("adding a tree child with empty label raises ValueError")
def step_tree_child_empty_label(context: Context) -> None:
try:
context.tree_handle.add_child("Root", "")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("adding a tree child under invalid path raises ValueError")
def step_tree_child_invalid_path(context: Context) -> None:
try:
context.tree_handle.add_child("NonExistent/Path", "child")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting tree node style with empty path raises ValueError")
def step_tree_style_empty_path(context: Context) -> None:
try:
context.tree_handle.set_node_style("", "bold")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("adding a tree child on closed handle raises ElementClosedError")
def step_tree_child_closed(context: Context) -> None:
try:
context.tree_handle.add_child("Root", "x")
raise AssertionError("Expected ElementClosedError")
except ElementClosedError:
pass
@then("creating a tree with empty root label raises ValueError")
def step_tree_empty_root(context: Context) -> None:
try:
context.session.tree("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# TextHandle
# ===========================================================================
use_step_matcher("re")
@when(
r'I create a text handle with content "(?P<content>[^"]+)" and indent (?P<indent>\d+)'
)
def step_create_text_indent(context: Context, content: str, indent: str) -> None:
context.text_handle = context.session.text(content, indent=int(indent))
@when(r'I create a text handle with content "(?P<content>[^"]*)"')
def step_create_text(context: Context, content: str) -> None:
context.text_handle = context.session.text(content)
use_step_matcher("parse")
@when('I append text "{text}"')
def step_append_text(context: Context, text: str) -> None:
context.text_handle.append(text)
@when('I set text content to "{content}"')
def step_set_text_content(context: Context, content: str) -> None:
context.text_handle.set_content(content)
@when("I close the text handle")
def step_close_text(context: Context) -> None:
context.text_handle.close()
@then('the text content is "{content}"')
def step_text_content(context: Context, content: str) -> None:
tb = cast(TextBlock, context.text_handle.element)
assert tb.content == content, f"Expected {content!r}, got {tb.content!r}"
@then("appending None text raises ValueError")
def step_append_none_text(context: Context) -> None:
try:
none_val: Any = None
context.text_handle.append(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting None text content raises ValueError")
def step_set_none_text(context: Context) -> None:
try:
none_val: Any = None
context.text_handle.set_content(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("appending to closed text handle raises ElementClosedError")
def step_append_text_closed(context: Context) -> None:
try:
context.text_handle.append("x")
raise AssertionError("Expected ElementClosedError")
except ElementClosedError:
pass
# ===========================================================================
# CodeHandle
# ===========================================================================
use_step_matcher("re")
@when(
r'I create a code handle with content "(?P<content>[^"]+)" and language "(?P<lang>[^"]+)"'
)
def step_create_code_lang(context: Context, content: str, lang: str) -> None:
context.code_handle = context.session.code(content, language=lang)
@when(r'I create a code handle with content "(?P<content>[^"]*)" and line_numbers true')
def step_create_code_lineno(context: Context, content: str) -> None:
# Behave escapes \n in feature files, so convert literal \n to newlines
content = content.replace("\\n", "\n")
context.code_handle = context.session.code(content, line_numbers=True)
@when(r'I create a code handle with content "(?P<content>[^"]*)"')
def step_create_code(context: Context, content: str) -> None:
content = content.replace("\\n", "\n")
context.code_handle = context.session.code(content)
use_step_matcher("parse")
@when('I set code content to "{content}"')
def step_set_code_content(context: Context, content: str) -> None:
context.code_handle.set_content(content)
@when('I set code language to "{lang}"')
def step_set_code_language(context: Context, lang: str) -> None:
context.code_handle.set_language(lang)
@when('I set code highlight lines to "{lines}"')
def step_set_code_highlight(context: Context, lines: str) -> None:
line_list = [int(x.strip()) for x in lines.split(",")]
context.code_handle.set_highlight_lines(line_list)
@when("I close the code handle")
def step_close_code(context: Context) -> None:
context.code_handle.close()
@then('the code content is "{content}"')
def step_code_content(context: Context, content: str) -> None:
cb = cast(CodeBlock, context.code_handle.element)
assert cb.content == content, f"Expected {content!r}, got {cb.content!r}"
@then('the code language is "{lang}"')
def step_code_language(context: Context, lang: str) -> None:
cb = cast(CodeBlock, context.code_handle.element)
assert cb.language == lang, f"Expected {lang!r}, got {cb.language!r}"
@then('the code highlight lines are "{lines}"')
def step_code_highlight_lines(context: Context, lines: str) -> None:
expected = [int(x.strip()) for x in lines.split(",")]
cb = cast(CodeBlock, context.code_handle.element)
assert cb.highlight_lines == expected, (
f"Expected {expected}, got {cb.highlight_lines}"
)
@then("setting None code content raises ValueError")
def step_set_none_code_content(context: Context) -> None:
try:
none_val: Any = None
context.code_handle.set_content(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting empty code language raises ValueError")
def step_set_empty_code_lang(context: Context) -> None:
try:
context.code_handle.set_language("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting None code highlight lines raises ValueError")
def step_set_none_code_highlight(context: Context) -> None:
try:
none_val: Any = None
context.code_handle.set_highlight_lines(none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting code content on closed handle raises ElementClosedError")
def step_set_code_closed(context: Context) -> None:
try:
context.code_handle.set_content("x")
raise AssertionError("Expected ElementClosedError")
except ElementClosedError:
pass
# ===========================================================================
# DiffHandle
# ===========================================================================
@when('I create a diff handle with file_a "{fa}" and file_b "{fb}"')
def step_create_diff(context: Context, fa: str, fb: str) -> None:
context.diff_handle = context.session.diff(file_a=fa, file_b=fb)
@when("I create a diff handle with no files")
def step_create_diff_no_files(context: Context) -> None:
context.diff_handle = context.session.diff()
use_step_matcher("re")
@when(
r'I add a diff hunk with header "(?P<header>[^"]+)" and context line "(?P<ctx>[^"]+)" and add line "(?P<add>[^"]+)"'
)
def step_add_diff_hunk(context: Context, header: str, ctx: str, add: str) -> None:
lines = [
DiffLine(type="context", content=ctx),
DiffLine(type="add", content=add),
]
context.diff_handle.add_hunk(header, lines)
@when(
r'I add a diff hunk with header "(?P<header>[^"]+)" and remove line "(?P<rem>[^"]+)"'
)
def step_add_diff_hunk_remove(context: Context, header: str, rem: str) -> None:
lines = [DiffLine(type="remove", content=rem)]
context.diff_handle.add_hunk(header, lines)
use_step_matcher("parse")
@when("I set diff stats with {ins:d} insertions and {dels:d} deletions")
def step_set_diff_stats(context: Context, ins: int, dels: int) -> None:
context.diff_handle.set_stats(ins, dels)
@when("I close the diff handle")
def step_close_diff(context: Context) -> None:
context.diff_handle.close()
@then('the diff file_a is "{fa}"')
def step_diff_file_a(context: Context, fa: str) -> None:
db = cast(DiffBlock, context.diff_handle.element)
assert db.file_a == fa, f"Expected {fa!r}, got {db.file_a!r}"
@then('the diff file_b is "{fb}"')
def step_diff_file_b(context: Context, fb: str) -> None:
db = cast(DiffBlock, context.diff_handle.element)
assert db.file_b == fb, f"Expected {fb!r}, got {db.file_b!r}"
@then("the diff has {count:d} hunks")
def step_diff_hunk_count(context: Context, count: int) -> None:
db = cast(DiffBlock, context.diff_handle.element)
actual = len(db.hunks)
assert actual == count, f"Expected {count} hunks, got {actual}"
@then("the diff stats insertions is {val:d}")
def step_diff_stats_ins(context: Context, val: int) -> None:
db = cast(DiffBlock, context.diff_handle.element)
assert db.stats is not None
assert db.stats["insertions"] == val
@then("the diff stats deletions is {val:d}")
def step_diff_stats_dels(context: Context, val: int) -> None:
db = cast(DiffBlock, context.diff_handle.element)
assert db.stats is not None
assert db.stats["deletions"] == val
@then("adding a diff hunk with empty header raises ValueError")
def step_diff_empty_header(context: Context) -> None:
try:
context.diff_handle.add_hunk("", [])
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("adding a diff hunk with None lines raises ValueError")
def step_diff_none_lines(context: Context) -> None:
try:
none_val: Any = None
context.diff_handle.add_hunk("@@ header", none_val)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting diff stats with negative insertions raises ValueError")
def step_diff_negative_ins(context: Context) -> None:
try:
context.diff_handle.set_stats(-1, 0)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("setting diff stats with negative deletions raises ValueError")
def step_diff_negative_dels(context: Context) -> None:
try:
context.diff_handle.set_stats(0, -1)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("adding a diff hunk on closed handle raises ElementClosedError")
def step_diff_hunk_closed(context: Context) -> None:
try:
context.diff_handle.add_hunk("@@ header", [])
raise AssertionError("Expected ElementClosedError")
except ElementClosedError:
pass
# ===========================================================================
# SeparatorHandle
# ===========================================================================
@when('I create a separator with style "{style}"')
def step_create_separator(context: Context, style: str) -> None:
context.separator_handle = context.session.separator(style)
@then("the separator handle is closed")
def step_separator_closed(context: Context) -> None:
assert context.separator_handle.is_open is False
@then("creating a separator with invalid style raises ValueError")
def step_separator_invalid_style(context: Context) -> None:
try:
context.session.separator("invalid_style")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# ActionHintHandle
# ===========================================================================
use_step_matcher("re")
@when(
r'I create an action hint with commands "(?P<cmds>[^"]+)" and description "(?P<desc>[^"]+)"'
)
def step_create_action_hint_desc(context: Context, cmds: str, desc: str) -> None:
cmd_list = cmds.split(",")
context.action_hint_handle = context.session.action_hint(cmd_list, desc)
@when(r'I create an action hint with commands "(?P<cmds>[^"]+)"')
def step_create_action_hint(context: Context, cmds: str) -> None:
cmd_list = cmds.split(",")
context.action_hint_handle = context.session.action_hint(cmd_list)
use_step_matcher("parse")
@then("the action hint handle is closed")
def step_action_hint_closed(context: Context) -> None:
assert context.action_hint_handle.is_open is False
@then("the action hint has {count:d} commands")
def step_action_hint_count(context: Context, count: int) -> None:
ah = cast(ActionHint, context.action_hint_handle.element)
actual = len(ah.commands)
assert actual == count, f"Expected {count} commands, got {actual}"
@then('the action hint description is "{desc}"')
def step_action_hint_desc(context: Context, desc: str) -> None:
ah = cast(ActionHint, context.action_hint_handle.element)
assert ah.description == desc, f"Expected {desc!r}, got {ah.description!r}"
@then("creating an action hint with empty commands raises ValueError")
def step_action_hint_empty_cmds(context: Context) -> None:
try:
context.session.action_hint([])
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Session completed without error (for blank separator etc.)
# ===========================================================================
@when('I add a tree child "{label}" under the root via empty path')
def step_add_tree_child_empty_path(context: Context, label: str) -> None:
context.tree_handle.add_child("", label)
@then("the session completed without error")
def step_session_no_error(context: Context) -> None:
# If we got here, no exception was raised
assert context.session._state == "closed"
# ===========================================================================
# N4: strip_terminal_escapes tests
# ===========================================================================
use_step_matcher("re")
@when(r'I strip terminal escapes from "(?P<raw>[^"]*)"')
def step_strip_escapes(context: Context, raw: str) -> None:
# Interpret Python escape sequences (e.g. \x1b) in the feature string
text = raw.encode("utf-8").decode("unicode_escape")
context.stripped_result = strip_terminal_escapes(text)
use_step_matcher("parse")
@when("I strip terminal escapes from text with 8-bit CSI")
def step_strip_8bit_csi(context: Context) -> None:
# 8-bit CSI is \x9b followed by parameters and a final byte
text = "before\x9b31mcoloured\x9b0m after"
context.stripped_result = strip_terminal_escapes(text)
@when("I strip terminal escapes from text with OSC title set")
def step_strip_osc(context: Context) -> None:
# OSC = \x1b] ... ST(\x1b\\) — e.g. setting terminal title
text = "before\x1b]0;My Title\x1b\\after"
context.stripped_result = strip_terminal_escapes(text)
@when("I strip terminal escapes from text with C1 control chars")
def step_strip_c1(context: Context) -> None:
# C1 control characters range from 0x80-0x9f
text = "hello\x80\x85\x8d\x8e\x8f\x90\x9fworld"
context.stripped_result = strip_terminal_escapes(text)
use_step_matcher("re")
@then(r'the stripped result is "(?P<expected>[^"]*)"')
def step_stripped_result_is(context: Context, expected: str) -> None:
exp = expected.encode("utf-8").decode("unicode_escape")
assert context.stripped_result == exp, (
f"Expected {exp!r}, got {context.stripped_result!r}"
)
use_step_matcher("parse")
@then("the stripped result does not contain bytes 0x9b")
def step_stripped_no_9b(context: Context) -> None:
assert "\x9b" not in context.stripped_result, (
f"Found 0x9b in: {context.stripped_result!r}"
)
@then("the stripped result does not contain OSC payload")
def step_stripped_no_osc(context: Context) -> None:
assert "My Title" not in context.stripped_result, (
f"Found OSC payload in: {context.stripped_result!r}"
)
assert "\x1b]" not in context.stripped_result, (
f"Found OSC escape in: {context.stripped_result!r}"
)
@then("the stripped result does not contain bytes 0x80 through 0x9f")
def step_stripped_no_c1(context: Context) -> None:
for byte_val in range(0x80, 0xA0):
ch = chr(byte_val)
assert ch not in context.stripped_result, (
f"Found C1 byte 0x{byte_val:02x} in: {context.stripped_result!r}"
)
@when("I strip terminal escapes from text with BEL-terminated OSC")
def step_strip_bel_osc(context: Context) -> None:
# OSC terminated by BEL (0x07) — e.g. setting terminal title
text = "\x1b]0;Evil Title\x07safetext"
context.stripped_result = strip_terminal_escapes(text)
@when("I strip terminal escapes from text with private-mode CSI")
def step_strip_private_csi(context: Context) -> None:
# Private-mode CSI — e.g. \x1b[?1049h (alternate screen)
text = "\x1b[?1049hcontent\x1b[?1049l"
context.stripped_result = strip_terminal_escapes(text)
@when("I strip terminal escapes from text with hyperlink OSC")
def step_strip_hyperlink_osc(context: Context) -> None:
# Hyperlink OSC injection: ESC]8;;url BEL text ESC]8;; BEL
text = "\x1b]8;;http://evil.com\x07click here\x1b]8;;\x07"
context.stripped_result = strip_terminal_escapes(text)
@when("I strip terminal escapes from text with mixed escapes")
def step_strip_mixed_escapes(context: Context) -> None:
# Multiple types of escapes mixed together
text = "\x1b[1mhello\x1b[0m \x1b]0;title\x07\x9b31mworld\x9b0m"
context.stripped_result = strip_terminal_escapes(text)
# ===========================================================================
# N13: Concurrency tests
# ===========================================================================
@when(
"I concurrently append {count:d} lines to the same text handle from {threads:d} threads"
)
def step_concurrent_append(context: Context, count: int, threads: int) -> None:
per_thread = count // threads
barrier = threading.Barrier(threads)
def worker(start: int, n: int) -> None:
barrier.wait()
for i in range(n):
context.text_handle.append(f"line-{start + i}\n")
thread_list = []
for t in range(threads):
th = threading.Thread(target=worker, args=(t * per_thread, per_thread))
thread_list.append(th)
th.start()
for th in thread_list:
th.join()
@then("the text handle has exactly {count:d} appended segments")
def step_text_append_count(context: Context, count: int) -> None:
tb = cast(TextBlock, context.text_handle.element)
# Each append added "line-N\n", so count occurrences
segments = tb.content.count("\n")
assert segments == count, f"Expected {count} segments, got {segments}"
@when("I concurrently create {count:d} panels from {threads:d} threads")
def step_concurrent_create_panels(context: Context, count: int, threads: int) -> None:
per_thread = count // threads
barrier = threading.Barrier(threads)
handles: list[Any] = []
lock = threading.Lock()
def worker(n: int) -> None:
barrier.wait()
for i in range(n):
h = context.session.panel(f"Panel-{threading.current_thread().name}-{i}")
h.set_entry("k", "v")
h.close()
with lock:
handles.append(h)
thread_list = []
for t in range(threads):
th = threading.Thread(target=worker, args=(per_thread,), name=f"t{t}")
thread_list.append(th)
th.start()
for th in thread_list:
th.join()
context.concurrent_handles = handles
@when("I race session close against handle writes")
def step_race_close_vs_write(context: Context) -> None:
context.race_exception = None
barrier = threading.Barrier(2)
def writer() -> None:
barrier.wait()
try:
for i in range(50):
try:
context.text_handle.append(f"race-{i}\n")
except (ElementClosedError, RuntimeError):
break
except Exception as exc:
context.race_exception = exc
def closer() -> None:
barrier.wait()
with contextlib.suppress(RuntimeError, ElementClosedError):
context.session.close()
t1 = threading.Thread(target=writer)
t2 = threading.Thread(target=closer)
t1.start()
t2.start()
t1.join(timeout=5)
t2.join(timeout=5)
@then("no unhandled exception was raised")
def step_no_exception(context: Context) -> None:
assert context.race_exception is None, (
f"Unhandled exception: {context.race_exception!r}"
)
# ===========================================================================
# N14: Progress throttling
# ===========================================================================
@when("I rapidly tick the progress {count:d} times within {ms:d}ms")
def step_rapid_tick(context: Context, count: int, ms: int) -> None:
"""H2 fix (Luis review): previously this test was vacuous because
``supports_incremental_updates = False`` meant ``_emit_update()``
returned immediately, so events never fired. We now temporarily
enable incremental updates on the strategy so events actually
reach the counting hook. Also fixes ``_handle_id`` → ``handle_id``
(the public attribute).
"""
context.progress_update_count = 0
original_on_updated = context.strategy.on_element_updated
# Temporarily enable incremental updates so events fire.
context.strategy.supports_incremental_updates = True
def counting_on_updated(event: Any) -> None:
if event.handle_id == context.progress_handle.handle_id:
context.progress_update_count += 1
original_on_updated(event)
context.strategy.on_element_updated = counting_on_updated
for _ in range(count):
context.progress_handle.tick()
# Brief sleep to let any final throttled emit fire
time.sleep(0.15)
# Restore original flag.
context.strategy.supports_incremental_updates = False
@then(
"fewer than {count:d} element-updated events were emitted for the progress handle"
)
def step_fewer_events(context: Context, count: int) -> None:
actual = context.progress_update_count
assert actual < count, f"Expected fewer than {count} events, got {actual}"
# ===========================================================================
# Luis review fixes — M2: TreeHandle label with slash
# ===========================================================================
@then("adding a tree child with slash in label raises ValueError")
def step_tree_child_slash_label(context: Context) -> None:
try:
context.tree_handle.add_child("Root", "src/main")
raise AssertionError("Expected ValueError for slash in label")
except ValueError:
pass
# ===========================================================================
# Luis review fixes — M3: MAX_ELEMENTS_PER_SESSION enforcement test
# ===========================================================================
@when("I create elements up to the session limit")
def step_create_to_limit(context: Context) -> None:
from cleveragents.cli.output.handles import MAX_ELEMENTS_PER_SESSION
# Create elements up to the limit — each separator auto-closes
for _i in range(MAX_ELEMENTS_PER_SESSION):
context.session.separator("line")
context.element_limit = MAX_ELEMENTS_PER_SESSION
@then("creating one more element raises ValueError")
def step_one_more_raises(context: Context) -> None:
try:
context.session.separator("line")
raise AssertionError("Expected ValueError for exceeding session element limit")
except ValueError:
pass
# ===========================================================================
# R2-C2 fix #15: MAX_TABLE_ROWS enforcement
# ===========================================================================
@when("I fill the table to its MAX_TABLE_ROWS limit")
def step_fill_table_to_limit(context: Context) -> None:
# Use a small limit override for test speed — we monkey-patch the
# constant on the module where enforcement happens.
import cleveragents.cli.output.handles._models as models_mod
import cleveragents.cli.output.handles._panel_table as pt_mod
original_models = models_mod.MAX_TABLE_ROWS
original_pt = pt_mod.MAX_TABLE_ROWS
models_mod.MAX_TABLE_ROWS = 5 # Small limit for test speed
pt_mod.MAX_TABLE_ROWS = 5
context._original_max_table_rows_models = original_models
context._original_max_table_rows_pt = original_pt
context._models_mod = models_mod
context._pt_mod = pt_mod
for i in range(5):
context.table_handle.add_row({"Name": f"row-{i}"})
@then("adding one more row raises ValueError")
def step_one_more_row_raises(context: Context) -> None:
try:
context.table_handle.add_row({"Name": "overflow"})
raise AssertionError("Expected ValueError for exceeding MAX_TABLE_ROWS")
except ValueError:
pass
finally:
# Restore original limits
context._models_mod.MAX_TABLE_ROWS = context._original_max_table_rows_models
context._pt_mod.MAX_TABLE_ROWS = context._original_max_table_rows_pt
# ===========================================================================
# Luis review fixes — M4: sort_descending rendering test
# ===========================================================================
@when('I set sort key to "{column}" descending')
def step_set_sort_key_desc(context: Context, column: str) -> None:
context.table_handle.set_sort_key(column, descending=True)
@then('the plain output has "{first}" before "{second}"')
def step_plain_output_ordering(context: Context, first: str, second: str) -> None:
output = context.strategy.get_output()
pos_first = output.find(first)
pos_second = output.find(second)
assert pos_first >= 0, f"{first!r} not found in output:\n{output}"
assert pos_second >= 0, f"{second!r} not found in output:\n{output}"
assert pos_first < pos_second, (
f"Expected {first!r} (pos={pos_first}) before "
f"{second!r} (pos={pos_second}) in output:\n{output}"
)
# ===========================================================================
# Luis review fixes — L4: TextBlock negative indent
# ===========================================================================
@then("creating a text handle with negative indent raises ValueError")
def step_text_negative_indent(context: Context) -> None:
try:
context.session.text("x", indent=-1)
raise AssertionError("Expected ValueError for negative indent")
except ValueError:
pass
# ===========================================================================
# Luis review fixes — L9: TextBlock with wrap=False
# ===========================================================================
use_step_matcher("re")
@when(
r'I create a text handle with content "(?P<content>[^"]*)" and wrap false and indent (?P<indent>\d+)'
)
def step_create_text_nowrap(context: Context, content: str, indent: str) -> None:
context.text_handle = context.session.text(content, wrap=False, indent=int(indent))
use_step_matcher("parse")
# ===========================================================================
# Review fix — collapsed tree child
# ===========================================================================
@when('I add a collapsed tree child "{label}" under "{path}"')
def step_add_collapsed_tree_child(context: Context, label: str, path: str) -> None:
context.tree_handle.add_child(path, label, collapsed=True)
# ===========================================================================
# Review fix — detect_terminal_capabilities actual assertion
# ===========================================================================
@then("the capabilities is_tty is a boolean")
def step_caps_tty_bool(context: Context) -> None:
assert isinstance(context.detected_caps.is_tty, bool)
@then("the capabilities supports_ansi is a boolean")
def step_caps_ansi_bool(context: Context) -> None:
assert isinstance(context.detected_caps.supports_ansi, bool)
@then("the capabilities supports_cursor is a boolean")
def step_caps_cursor_bool(context: Context) -> None:
assert isinstance(context.detected_caps.supports_cursor, bool)
@then("the capabilities term is a string")
def step_caps_term_str(context: Context) -> None:
assert isinstance(context.detected_caps.term, str)
# ===========================================================================
# Review fix — session double-close
# ===========================================================================
@when("the session is closed again")
def step_session_close_again(context: Context) -> None:
context.session_result_2 = context.session.close()
@then("no deadlock occurred and both closes returned snapshots")
def step_double_close_ok(context: Context) -> None:
assert context.session_result is not None
assert context.session_result_2 is not None
assert context.session._state == "closed"
# ===========================================================================
# Review fix — ColumnDef non-default field serialization
# ===========================================================================
@when("I create a table with non-default ColumnDef fields")
def step_create_table_non_default_coldef(context: Context) -> None:
col_defs = [
ColumnDef(
name="Name",
col_type="string",
alignment="center",
width_hint=20,
sortable=True,
style_hint="bold",
),
ColumnDef(name="Age", col_type="integer"),
]
context.table_handle = context.session.table(columns=col_defs)
# ===========================================================================
# P1-1: ProgressHandle total validation
# ===========================================================================
@then("setting progress total to negative raises ValueError")
def step_progress_negative_total(context: Context) -> None:
try:
context.progress_handle.set_progress(0, total=-5)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a progress with negative total raises ValueError")
def step_session_progress_negative_total(context: Context) -> None:
try:
context.session.progress("Bad", total=-10)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# P1-2: JSON/YAML timing field
# ===========================================================================
# (no step needed — reuses existing "json output contains" steps)
# ===========================================================================
# P1-4: NO_COLOR env var
# ===========================================================================
@given("the NO_COLOR environment variable is set")
def step_set_no_color(context: Context) -> None:
import os
context.original_no_color = os.environ.get("NO_COLOR")
os.environ["NO_COLOR"] = "1"
def cleanup() -> None:
if context.original_no_color is None:
os.environ.pop("NO_COLOR", None)
else:
os.environ["NO_COLOR"] = context.original_no_color
context.add_cleanup(cleanup)
# ===========================================================================
# P2-1: DiffLine.line_type
# ===========================================================================
@when('I create a DiffLine with line_type "{lt}" and content "{content}"')
def step_create_diffline_line_type(context: Context, lt: str, content: str) -> None:
from cleveragents.cli.output.handles import DiffLine
# Construct via model_validate to use field name (not alias).
context.diff_line = DiffLine.model_validate({"line_type": lt, "content": content})
@when('I create a DiffLine with type alias "{lt}" and content "{content}"')
def step_create_diffline_type_alias(context: Context, lt: str, content: str) -> None:
from cleveragents.cli.output.handles import DiffLine
context.diff_line = DiffLine(type=lt, content=content)
@then('the DiffLine line_type is "{expected}"')
def step_diffline_line_type(context: Context, expected: str) -> None:
assert context.diff_line.line_type == expected
# ===========================================================================
# P2-3: Numeric column sorting
# ===========================================================================
@when('I create a table with a numeric column "{name}"')
def step_create_table_numeric_col(context: Context, name: str) -> None:
context.table_handle = context.session.table(
columns=[ColumnDef(name=name, col_type="number")]
)
@when("I add rows with scores {scores}")
def step_add_score_rows(context: Context, scores: str) -> None:
for score in scores.split(", "):
context.table_handle.add_row({"Score": score.strip()})
@when('I set sort key to "{column}" ascending')
def step_set_sort_ascending(context: Context, column: str) -> None:
context.table_handle.set_sort_key(column, descending=False)
# ===========================================================================
# P2-4: ColumnDef default fields always serialised
# ===========================================================================
@when("I create a table with default ColumnDef fields")
def step_create_table_default_coldef(context: Context) -> None:
col_defs = [ColumnDef(name="Name")]
context.table_handle = context.session.table(columns=col_defs)
# ===========================================================================
# P2-5: Unicode box-drawing
# ===========================================================================
@then("the strategy output contains Unicode box-drawing chars")
def step_unicode_box_chars(context: Context) -> None:
output = context.strategy.get_output()
assert "" in output or "" in output, f"No Unicode box-drawing chars in:\n{output}"
# ===========================================================================
# P3-3: MAX_TABLE_ROWS in add_rows batch
# ===========================================================================
@when("I fill the table close to its limit")
def step_fill_table_close_to_limit(context: Context) -> None:
from cleveragents.cli.output.handles import MAX_TABLE_ROWS
# Fill to MAX_TABLE_ROWS - 5
batch = [{"Name": f"row-{i}"} for i in range(MAX_TABLE_ROWS - 5)]
context.table_handle.add_rows(batch)
@then("adding a batch that exceeds the limit raises ValueError")
def step_add_batch_exceeds_limit(context: Context) -> None:
try:
context.table_handle.add_rows([{"Name": f"overflow-{i}"} for i in range(10)])
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# P3-5: Stress test with 10 concurrent producers
# ===========================================================================
@when("I concurrently create {n:d} panels from {t:d} threads")
def step_concurrent_panels_stress(context: Context, n: int, t: int) -> None:
import threading
errors: list[Exception] = []
per_thread = n // t
def create_panels(start_idx: int, count: int) -> None:
try:
for i in range(count):
p = context.session.panel(f"Panel-{start_idx + i}")
p.set_entry("k", "v")
p.close()
except Exception as exc:
errors.append(exc)
threads: list[threading.Thread] = []
for i in range(t):
th = threading.Thread(target=create_panels, args=(i * per_thread, per_thread))
threads.append(th)
th.start()
for th in threads:
th.join(timeout=30)
assert not errors, f"Thread errors: {errors}"
# ===========================================================================
# P3-6: Boxdraw summary truncation
# ===========================================================================
@when('I create a table with a 2-char column "{col}"')
def step_create_narrow_table(context: Context, col: str) -> None:
context.table_handle = context.session.table(columns=[col])
context.table_handle.add_row({col: "XY"})
@when("I set a very long summary string")
def step_set_long_summary(context: Context) -> None:
context.table_handle.set_summary({"result": "A" * 200})
@then("the strategy output does not have summary exceeding table width")
def step_summary_within_border(context: Context) -> None:
output = context.strategy.get_output()
lines = output.split("\n")
# All lines should have roughly the same width (border-bound).
border_line = lines[1] if len(lines) > 1 else lines[0]
max_width = len(border_line) + 2 # Allow small tolerance
for line in lines:
assert len(line) <= max_width, (
f"Line exceeds border: {len(line)} > {max_width}: {line!r}"
)
# ===========================================================================
# P3-7: Explicit format flag for color and table
# (no additional step needed — reuses existing step_select_materializer_explicit)
# ===========================================================================
# ===========================================================================
# P3-8: ProgressHandle zero delta
# ===========================================================================
@then("incrementing by zero delta raises ValueError")
def step_incr_zero_delta(context: Context) -> None:
try:
context.progress_handle.increment(0)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Coverage boost: color table with summary
# ===========================================================================
# (reuses existing steps — no new step needed)
# ===========================================================================
# Coverage boost: color text wrap=False and multiline with empty lines
# ===========================================================================
@when("I create a text handle with multiline content including empty lines")
def step_create_text_multiline_empty(context: Context) -> None:
context.text_handle = context.session.text("line1\n\nline3", wrap=True)
# ===========================================================================
# Coverage boost: _reset_counters
# ===========================================================================
@when("I reset the ID counters")
def step_reset_id_counters(context: Context) -> None:
from cleveragents.cli.output._ids import _reset_counters
_reset_counters()
@then("the next session ID starts from 1")
def step_session_id_from_1(context: Context) -> None:
from cleveragents.cli.output._ids import generate_session_id
sid = generate_session_id()
assert sid == "ses-000001", f"Expected ses-000001, got {sid}"
@then("the next handle ID starts from 1")
def step_handle_id_from_1(context: Context) -> None:
from cleveragents.cli.output._ids import generate_handle_id
hid = generate_handle_id()
assert hid == "hdl-000001", f"Expected hdl-000001, got {hid}"
# ===========================================================================
# Coverage boost: JSON diff with line numbers
# ===========================================================================
@when("I add a diff hunk with line numbers")
def step_add_diff_hunk_line_numbers(context: Context) -> None:
from cleveragents.cli.output.handles import DiffLine
lines = [
DiffLine(
type="context", content="existing", line_number_old=1, line_number_new=1
),
DiffLine(type="add", content="new line", line_number_new=2),
]
context.diff_handle.add_hunk("@@ -1,1 +1,2 @@", lines)
# ===========================================================================
# Coverage boost: JSON tree with style_hint and metadata
# ===========================================================================
@when('I add a tree child "{label}" under "{path}" with style "{style}" and metadata')
def step_add_tree_child_style_metadata(
context: Context, label: str, path: str, style: str
) -> None:
context.tree_handle.add_child(
path, label, style_hint=style, metadata={"key": "value"}
)
# ===========================================================================
# Coverage boost: flat tree depth guard
# ===========================================================================
@when(
'I create a tree handle with root label "{root}" and show_guides false and max_depth_hint {depth:d}'
)
def step_create_flat_tree_max_depth(context: Context, root: str, depth: int) -> None:
context.tree_handle = context.session.tree(
root, show_guides=False, max_depth_hint=depth
)
# ===========================================================================
# Coverage boost: _find_node cache miss traversal
# ===========================================================================
@when("I build a 3-level deep tree and query a deep path")
def step_build_deep_tree_cache_miss(context: Context) -> None:
# Build 3 levels
context.tree_handle.add_child("Root", "A")
context.tree_handle.add_child("Root/A", "B")
context.tree_handle.add_child("Root/A/B", "C")
# Clear the path cache to force fallback traversal
context.tree_handle._path_cache.clear()
context.tree_handle._path_cache[""] = context.tree_handle._element.root
context.tree_handle._path_cache[context.tree_handle._element.root.label] = (
context.tree_handle._element.root
)
# Now query a deep path — forces traversal
node = context.tree_handle._find_node("Root/A/B/C")
context.deep_node = node
@then("the deep tree node was found correctly")
def step_deep_node_found(context: Context) -> None:
assert context.deep_node is not None
assert context.deep_node.label == "C"
# ===========================================================================
# Coverage boost: TextHandle with incremental strategy
# ===========================================================================
@given("an OutputSession with a mock incremental strategy")
def step_session_incremental_strategy(context: Context) -> None:
from cleveragents.cli.output.materializers import PlainMaterializer
from cleveragents.cli.output.session import OutputSession
strategy = PlainMaterializer()
strategy.supports_incremental_updates = True
context.incremental_events = []
original_on_updated = strategy.on_element_updated
def tracking_on_updated(event: ElementUpdated) -> None:
context.incremental_events.append(event)
original_on_updated(event)
strategy.on_element_updated = tracking_on_updated # type: ignore[method-assign]
context.session = OutputSession(format="plain", command="test", strategy=strategy)
context.strategy = strategy
@then("the incremental strategy received an update event")
def step_incremental_event_received(context: Context) -> None:
assert len(context.incremental_events) > 0, "No incremental events received"
# ===========================================================================
# Coverage boost: table → color fallback on non-TTY with ANSI
# ===========================================================================
@given("a terminal with ANSI but non-TTY")
def step_terminal_ansi_non_tty(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=False, supports_ansi=True, supports_cursor=False, term="xterm-256color"
)
# ===========================================================================
# Coverage boost: set_node_style on non-existent path
# ===========================================================================
@then("setting tree node style on non-existent path raises ValueError")
def step_set_node_style_nonexistent(context: Context) -> None:
try:
context.tree_handle.set_node_style("Root/nonexistent", "bold")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Coverage boost: session status factory rejects invalid level
# ===========================================================================
@then("creating a status with invalid level raises ValueError")
def step_session_status_invalid_level(context: Context) -> None:
try:
context.session.status("msg", level="bogus")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Coverage boost: deep tree at MAX_TREE_DEPTH
# ===========================================================================
@when("I create a tree at maximum depth")
def step_create_max_depth_tree(context: Context) -> None:
from cleveragents.cli.output._renderers import render_element_plain
from cleveragents.cli.output.handles import MAX_TREE_DEPTH, Tree, TreeNode
# Build a tree model deeper than MAX_TREE_DEPTH by constructing
# TreeNode objects directly (bypassing the handle's depth guard).
# This tests the render-layer depth guard (defence-in-depth).
node = TreeNode(label="leaf", children=[TreeNode(label="overflow-child")])
for i in range(MAX_TREE_DEPTH):
node = TreeNode(label=f"d{MAX_TREE_DEPTH - 1 - i}", children=[node])
tree_model = Tree(root=node, show_guides=True)
# Render directly — don't go through the session/handle
context.deep_tree_output = render_element_plain(tree_model)
# Create a dummy handle so the session close doesn't fail
context.tree_handle = context.session.tree("Dummy")
# ===========================================================================
# Coverage boost: JSON tree node truncation at depth limit
# ===========================================================================
@when("I serialize a tree node beyond MAX_TREE_DEPTH")
def step_serialize_deep_tree_node(context: Context) -> None:
from cleveragents.cli.output.handles import TreeNode
from cleveragents.cli.output.materializers import _tree_node_to_dict
# Build a simple node and call with depth at limit
node = TreeNode(label="deep", children=[TreeNode(label="child")])
context.tree_dict = _tree_node_to_dict(node, _depth=64)
@then('the deep tree output contains "{text}"')
def step_deep_tree_output_contains(context: Context, text: str) -> None:
assert text in context.deep_tree_output, f"Expected {text!r} in deep tree output"
@then("the serialized node contains truncated flag")
def step_tree_truncated(context: Context) -> None:
assert context.tree_dict.get("truncated") is True, (
f"Expected truncated=True, got {context.tree_dict}"
)
# ===========================================================================
# Coverage boost: selection.py — rich with cursor / ANSI-only
# ===========================================================================
@given("a terminal with cursor support")
def step_terminal_cursor(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=True,
supports_ansi=True,
supports_cursor_movement=True,
term_program="xterm-256color",
)
@given("a terminal with ANSI only no TTY no cursor")
def step_terminal_ansi_only(context: Context) -> None:
context.terminal_caps = TerminalCapabilities(
is_tty=False, supports_ansi=True, supports_cursor=False, term="ansi"
)
# ===========================================================================
# Coverage boost: TreeHandle add_child at MAX_TREE_DEPTH
# ===========================================================================
@when("I create a tree at exactly max depth via handle")
def step_create_tree_max_depth_handle(context: Context) -> None:
from cleveragents.cli.output.handles import MAX_TREE_DEPTH
context.tree_handle = context.session.tree("R")
path = "R"
for i in range(MAX_TREE_DEPTH - 1):
path = context.tree_handle.add_child(path, f"n{i}")
context.max_depth_path = path
@then("adding one more child level raises ValueError")
def step_add_child_exceeds_depth(context: Context) -> None:
try:
context.tree_handle.add_child(context.max_depth_path, "overflow")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ===========================================================================
# Coverage boost: detect_terminal_capabilities with specific TERM
# ===========================================================================
@when('I detect capabilities with TERM "{term}" on a TTY')
def step_detect_caps_specific_term(context: Context, term: str) -> None:
import os
import unittest.mock
with (
unittest.mock.patch.dict(os.environ, {"TERM": term}),
unittest.mock.patch("sys.stdout") as mock_stdout,
):
mock_stdout.isatty.return_value = True
from cleveragents.cli.output.selection import detect_terminal_capabilities
context.detected_caps = detect_terminal_capabilities()
@then("the capabilities supports_cursor is true")
def step_caps_cursor_true(context: Context) -> None:
assert context.detected_caps.supports_cursor is True, (
f"Expected supports_cursor=True, got {context.detected_caps.supports_cursor}"
)
# ===========================================================================
# Coverage boost: _find_node with wrong root
# ===========================================================================
@then("querying a path with wrong root returns None")
def step_find_node_wrong_root(context: Context) -> None:
# Clear cache to force traversal path
context.tree_handle._path_cache.clear()
context.tree_handle._path_cache[""] = context.tree_handle._element.root
result = context.tree_handle._find_node("WrongRoot/child")
assert result is None, f"Expected None, got {result}"
# ===========================================================================
# LiveMaterializationStrategy
# ===========================================================================
@given("a RichMaterializer instance")
def step_given_rich_materializer_instance(context: Context) -> None:
context.rich_mat = RichMaterializer()
@then("the rich materializer supports incremental updates")
def step_rich_supports_incremental(context: Context) -> None:
assert context.rich_mat.supports_incremental_updates is True, (
"Expected supports_incremental_updates=True"
)
@then('the rich materializer strategy name is "{name}"')
def step_rich_strategy_name(context: Context, name: str) -> None:
assert context.rich_mat.strategy_name == name, (
f"Expected strategy_name={name!r}, got {context.rich_mat.strategy_name!r}"
)
@then("the live strategy frame rate is {rate}")
def step_live_frame_rate(context: Context, rate: str) -> None:
assert float(rate) == context.rich_mat._FRAME_RATE, (
f"Expected frame rate {rate}, got {context.rich_mat._FRAME_RATE}"
)