2775 lines
93 KiB
Python
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}"
|
|
)
|