diff --git a/benchmarks/output_rendering_bench.py b/benchmarks/output_rendering_bench.py index fdef0cc0a..1aa68044f 100644 --- a/benchmarks/output_rendering_bench.py +++ b/benchmarks/output_rendering_bench.py @@ -20,6 +20,7 @@ import cleveragents # noqa: E402 importlib.reload(cleveragents) +from cleveragents.cli.output.handles import DiffLine # noqa: E402 from cleveragents.cli.output.materializers import ( # noqa: E402 ColorMaterializer, JsonMaterializer, @@ -112,3 +113,199 @@ class SessionLifecycleSuite: t.close() s = session.status("ok") s.close() + + +class TreeThroughputSuite: + """Benchmark tree-building throughput with deep/wide trees.""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_wide_tree(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + tree = session.tree("Root") + for i in range(100): + tree.add_child("Root", f"child-{i}") + tree.close() + strategy.get_output() + + def time_deep_tree(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + tree = session.tree("Root") + path = "Root" + for i in range(50): + label = f"level-{i}" + path = tree.add_child(path, label) + tree.close() + strategy.get_output() + + +class DiffThroughputSuite: + """Benchmark diff rendering with multi-hunk diffs.""" + + params: list[str] = ["plain", "color", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_multi_hunk_diff(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + diff = session.diff(file_a="old.py", file_b="new.py") + for h in range(20): + lines = [] + for j in range(10): + lines.append(DiffLine(type="context", content=f"ctx-{j}")) + lines.append(DiffLine(type="add", content=f"add-{j}")) + lines.append(DiffLine(type="remove", content=f"rem-{j}")) + diff.add_hunk(f"@@ -{h * 30},{30} +{h * 30},{30} @@", lines) + diff.set_stats(200, 200) + diff.close() + strategy.get_output() + + +class MixedElementSuite: + """Benchmark session throughput with all 10 element types.""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_all_element_types(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + p = session.panel("P") + p.set_entry("k", "v") + p.close() + t = session.table(columns=["A"]) + t.add_row({"A": "1"}) + t.close() + s = session.status("ok") + s.close() + pr = session.progress("go", total=10) + pr.set_progress(5) + pr.close() + tr = session.tree("Root") + tr.add_child("Root", "leaf") + tr.close() + tx = session.text("hello world") + tx.close() + co = session.code("x = 1", language="python") + co.close() + d = session.diff(file_a="a", file_b="b") + d.add_hunk("@@", [DiffLine(type="add", content="new")]) + d.close() + session.separator("line") + session.action_hint(["cmd"]) + strategy.get_output() + + +class TextAppendSuite: + """Benchmark TextHandle.append() throughput (N10: detect O(N²) regressions).""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_text_append_1000(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + tx = session.text("") + for i in range(1000): + tx.append(f"Line {i}\n") + tx.close() + strategy.get_output() + + def time_text_append_5000(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + tx = session.text("") + for i in range(5000): + tx.append(f"Line {i}\n") + tx.close() + strategy.get_output() + + +class LargeTableSuite: + """Benchmark table rendering at 10K+ rows (N10).""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_table_10k_rows(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + table = session.table(columns=["ID", "Name", "Value"]) + for i in range(10_000): + table.add_row({"ID": str(i), "Name": f"item-{i}", "Value": str(i * 10)}) + table.close() + strategy.get_output() + + +class SnapshotCostSuite: + """Benchmark snapshot() cost (N10).""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_snapshot_with_many_elements(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + for i in range(50): + p = session.panel(f"Panel-{i}") + p.set_entry("k", "v") + p.close() + for _ in range(20): + session.snapshot() + + def time_snapshot_with_large_table(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + table = session.table(columns=["A", "B"]) + for i in range(5000): + table.add_row({"A": str(i), "B": str(i)}) + table.close() + for _ in range(10): + session.snapshot() + + +class DeepTreeLookupSuite: + """Benchmark deep tree path lookups (N10).""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_deep_tree_add_and_lookup(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + tree = session.tree("Root") + path = "Root" + for i in range(50): + label = f"level-{i}" + path = tree.add_child(path, label) + # Now do lookups at various depths + tree.add_child("Root", "wide-child-1") + tree.add_child("Root", "wide-child-2") + tree.close() + strategy.get_output() + + +class ElementUpdatedOverheadSuite: + """Benchmark ElementUpdated event dispatch overhead (N10).""" + + params: list[str] = ["plain", "json"] # noqa: RUF012 + param_names: list[str] = ["format"] # noqa: RUF012 + + def time_panel_many_updates(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + panel = session.panel("Update Panel") + for i in range(500): + panel.set_entry(f"key-{i % 50}", f"value-{i}") + panel.close() + + def time_progress_many_ticks(self, fmt: str) -> None: + strategy = _STRATEGY_MAP[fmt]() + with OutputSession(format=fmt, command="bench", strategy=strategy) as session: + prog = session.progress("Ticking", total=5000) + for _ in range(5000): + prog.tick() + prog.close() diff --git a/features/materializers_coverage_boost.feature b/features/materializers_coverage_boost.feature index 317ddddd7..67590c0b0 100644 --- a/features/materializers_coverage_boost.feature +++ b/features/materializers_coverage_boost.feature @@ -108,15 +108,16 @@ Feature: Materializers coverage boost Then the strategy output is empty # --------------------------------------------------------------- - # Scenario 10: TableMaterializer renders non-table element via plain - # Targets line 327 (_render_element_table delegates to _render_element_plain) + # Scenario 10: TableMaterializer renders non-table element via color + # Targets _render_element_table delegates to render_element_color + # (R2-C2 fix #12: table format now uses color for non-table elements) # --------------------------------------------------------------- - Scenario: Table materializer renders status message via plain fallback + Scenario: Table materializer renders status message via color fallback Given a TableMaterializer buffer strategy And an OutputSession wired to that table buffer strategy When I create and close a status element with message "deploy complete" and level "ok" - Then the table strategy output contains "[OK] deploy complete" + Then the table strategy output contains "deploy complete" # --------------------------------------------------------------- # Scenario 11: Session end with empty buffers (no remaining) diff --git a/features/output_rendering.feature b/features/output_rendering.feature index e92df2e14..7ac3a77f1 100644 --- a/features/output_rendering.feature +++ b/features/output_rendering.feature @@ -643,10 +643,10 @@ Feature: Output Rendering Framework When I attempt to select a materializer with empty format Then a ValueError is raised - Scenario: Rich format falls back to color when no cursor support + Scenario: Rich format falls back to table when no cursor support but TTY Given a terminal with ANSI but no cursor support When I select a materializer for format "rich" - Then the selected materializer is ColorMaterializer + Then the selected materializer is TableMaterializer Scenario: Color format uses color materializer with ANSI support Given a terminal with ANSI support @@ -789,15 +789,6 @@ Feature: Output Rendering Framework And the session is closed Then the plain output contains "Alpha" - # ================================================================ - # Coverage boost: detect_capabilities function - # ================================================================ - - Scenario: Detect terminal capabilities returns TerminalCapabilities - When I detect terminal capabilities - Then the capabilities object has is_tty field - And the capabilities object has supports_ansi field - # ================================================================ # Coverage boost: explicit format flag # ================================================================ @@ -829,3 +820,1369 @@ Feature: Output Rendering Framework Scenario: JSON serializer returns empty when session is None Given a JsonMaterializer with no session Then the serializer get_output returns empty string + + # ================================================================ + # TreeHandle + # ================================================================ + + Scenario: TreeHandle creates a tree with root label + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then the tree root label is "Root" + + Scenario: TreeHandle add_child adds children to root + Given an OutputSession with format "plain" + When I create a tree handle with root label "Project" + And I add a tree child "src" under "Project" + And I add a tree child "tests" under "Project" + Then the tree root has 2 children + + Scenario: TreeHandle add_child adds nested children + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + And I add a tree child "child" under "Root" + And I add a tree child "grandchild" under "Root/child" + Then the tree node "Root/child" has 1 children + + Scenario: TreeHandle set_node_style updates style hint + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + And I add a tree child "item" under "Root" + And I set tree node style "Root/item" to "success" + Then the tree node "Root/item" has style "success" + + Scenario: TreeHandle rejects empty label + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then adding a tree child with empty label raises ValueError + + Scenario: TreeHandle rejects invalid parent path + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then adding a tree child under invalid path raises ValueError + + Scenario: TreeHandle rejects empty path in set_node_style + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then setting tree node style with empty path raises ValueError + + Scenario: Writing to closed TreeHandle raises ElementClosedError + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + And I close the tree handle + Then adding a tree child on closed handle raises ElementClosedError + + Scenario: Session rejects tree with empty root label + Given an OutputSession with format "plain" + Then creating a tree with empty root label raises ValueError + + # ================================================================ + # TextHandle + # ================================================================ + + Scenario: TextHandle creates a text block + Given an OutputSession with format "plain" + When I create a text handle with content "Hello world" + Then the text content is "Hello world" + + Scenario: TextHandle append adds text + Given an OutputSession with format "plain" + When I create a text handle with content "Hello" + And I append text " world" + Then the text content is "Hello world" + + Scenario: TextHandle set_content replaces text + Given an OutputSession with format "plain" + When I create a text handle with content "old" + And I set text content to "new" + Then the text content is "new" + + Scenario: TextHandle rejects None in append + Given an OutputSession with format "plain" + When I create a text handle with content "x" + Then appending None text raises ValueError + + Scenario: TextHandle rejects None in set_content + Given an OutputSession with format "plain" + When I create a text handle with content "x" + Then setting None text content raises ValueError + + Scenario: Writing to closed TextHandle raises ElementClosedError + Given an OutputSession with format "plain" + When I create a text handle with content "x" + And I close the text handle + Then appending to closed text handle raises ElementClosedError + + # ================================================================ + # CodeHandle + # ================================================================ + + Scenario: CodeHandle creates a code block + Given an OutputSession with format "plain" + When I create a code handle with content "print('hi')" + Then the code content is "print('hi')" + + Scenario: CodeHandle set_content replaces code + Given an OutputSession with format "plain" + When I create a code handle with content "old" + And I set code content to "new code" + Then the code content is "new code" + + Scenario: CodeHandle set_language updates language + Given an OutputSession with format "plain" + When I create a code handle with content "x = 1" and language "python" + And I set code language to "javascript" + Then the code language is "javascript" + + Scenario: CodeHandle set_highlight_lines sets lines + Given an OutputSession with format "plain" + When I create a code handle with content "a\nb\nc" + And I set code highlight lines to "1,3" + Then the code highlight lines are "1,3" + + Scenario: CodeHandle rejects None content + Given an OutputSession with format "plain" + When I create a code handle with content "x" + Then setting None code content raises ValueError + + Scenario: CodeHandle rejects empty language + Given an OutputSession with format "plain" + When I create a code handle with content "x" + Then setting empty code language raises ValueError + + Scenario: CodeHandle rejects None highlight lines + Given an OutputSession with format "plain" + When I create a code handle with content "x" + Then setting None code highlight lines raises ValueError + + Scenario: Writing to closed CodeHandle raises ElementClosedError + Given an OutputSession with format "plain" + When I create a code handle with content "x" + And I close the code handle + Then setting code content on closed handle raises ElementClosedError + + # ================================================================ + # DiffHandle + # ================================================================ + + Scenario: DiffHandle creates a diff block + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + Then the diff file_a is "a.py" + And the diff file_b is "b.py" + + Scenario: DiffHandle add_hunk adds hunks + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with header "@@ -1,3 +1,4 @@" and context line "line1" and add line "new" + Then the diff has 1 hunks + + Scenario: DiffHandle set_stats sets statistics + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I set diff stats with 5 insertions and 3 deletions + Then the diff stats insertions is 5 + And the diff stats deletions is 3 + + Scenario: DiffHandle rejects empty hunk header + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + Then adding a diff hunk with empty header raises ValueError + + Scenario: DiffHandle rejects None hunk lines + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + Then adding a diff hunk with None lines raises ValueError + + Scenario: DiffHandle rejects negative insertions + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + Then setting diff stats with negative insertions raises ValueError + + Scenario: DiffHandle rejects negative deletions + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + Then setting diff stats with negative deletions raises ValueError + + Scenario: Writing to closed DiffHandle raises ElementClosedError + Given an OutputSession with format "plain" + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I close the diff handle + Then adding a diff hunk on closed handle raises ElementClosedError + + # ================================================================ + # SeparatorHandle + # ================================================================ + + Scenario: SeparatorHandle is auto-closed on creation + Given an OutputSession with format "plain" + When I create a separator with style "line" + Then the separator handle is closed + + Scenario: SeparatorHandle accepts blank style + Given an OutputSession with format "plain" + When I create a separator with style "blank" + Then the separator handle is closed + + Scenario: SeparatorHandle accepts double style + Given an OutputSession with format "plain" + When I create a separator with style "double" + Then the separator handle is closed + + Scenario: SeparatorHandle rejects invalid style + Given an OutputSession with format "plain" + Then creating a separator with invalid style raises ValueError + + # ================================================================ + # ActionHintHandle + # ================================================================ + + Scenario: ActionHintHandle is auto-closed on creation + Given an OutputSession with format "plain" + When I create an action hint with commands "agents list,agents deploy" + Then the action hint handle is closed + + Scenario: ActionHintHandle stores commands and description + Given an OutputSession with format "plain" + When I create an action hint with commands "agents list" and description "Try these" + Then the action hint has 1 commands + And the action hint description is "Try these" + + Scenario: ActionHintHandle rejects empty commands + Given an OutputSession with format "plain" + Then creating an action hint with empty commands raises ValueError + + # ================================================================ + # Plain materializer rendering: new element types + # ================================================================ + + Scenario: Plain materializer renders tree with guides + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree handle with root label "Root" + And I add a tree child "child1" under "Root" + And I add a tree child "child2" under "Root" + And I close the tree handle + And the session is closed + Then the plain output contains "Root" + And the plain output contains "child1" + And the plain output contains "child2" + And the plain output does not contain ANSI codes + + Scenario: Plain materializer renders tree without guides + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree handle with root label "Flat" and show_guides false + And I add a tree child "item" under "Flat" + And I close the tree handle + And the session is closed + Then the plain output contains "Flat" + And the plain output contains "item" + + Scenario: Plain materializer renders tree with max depth hint + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree handle with root label "Deep" and max_depth_hint 1 + And I add a tree child "level1" under "Deep" + And I add a tree child "level2" under "Deep/level1" + And I close the tree handle + And the session is closed + Then the plain output contains "Deep" + And the plain output contains "level1" + And the plain output contains "more" + + Scenario: Plain materializer renders text block + Given an OutputSession with format "plain" using PlainMaterializer + When I create a text handle with content "Hello plain world" + And I close the text handle + And the session is closed + Then the plain output contains "Hello plain world" + And the plain output does not contain ANSI codes + + Scenario: Plain materializer renders text with indent + Given an OutputSession with format "plain" using PlainMaterializer + When I create a text handle with content "indented" and indent 4 + And I close the text handle + And the session is closed + Then the plain output contains " indented" + + Scenario: Plain materializer renders code block + Given an OutputSession with format "plain" using PlainMaterializer + When I create a code handle with content "x = 1" and language "python" + And I close the code handle + And the session is closed + Then the plain output contains "x = 1" + And the plain output does not contain ANSI codes + + Scenario: Plain materializer renders code with line numbers + Given an OutputSession with format "plain" using PlainMaterializer + When I create a code handle with content "a\nb\nc" and line_numbers true + And I close the code handle + And the session is closed + Then the plain output contains "1 |" + And the plain output contains "2 |" + + Scenario: Plain materializer renders diff block + Given an OutputSession with format "plain" using PlainMaterializer + When I create a diff handle with file_a "old.py" and file_b "new.py" + And I add a diff hunk with header "@@ -1,2 +1,3 @@" and context line "same" and add line "added" + And I set diff stats with 1 insertions and 0 deletions + And I close the diff handle + And the session is closed + Then the plain output contains "--- old.py" + And the plain output contains "+++ new.py" + And the plain output contains "+added" + And the plain output contains "1 insertion(s)" + And the plain output does not contain ANSI codes + + Scenario: Plain materializer renders separator line + Given an OutputSession with format "plain" using PlainMaterializer + When I create a separator with style "line" + And the session is closed + Then the plain output contains "----" + + Scenario: Plain materializer renders separator double + Given an OutputSession with format "plain" using PlainMaterializer + When I create a separator with style "double" + And the session is closed + Then the plain output contains "====" + + Scenario: Plain materializer renders separator blank + Given an OutputSession with format "plain" using PlainMaterializer + When I create a separator with style "blank" + And the session is closed + Then the session completed without error + + Scenario: Plain materializer renders action hint + Given an OutputSession with format "plain" using PlainMaterializer + When I create an action hint with commands "agents list,agents deploy" and description "Try these" + And the session is closed + Then the plain output contains "Next steps" + And the plain output contains "$ agents list" + And the plain output contains "Try these" + And the plain output does not contain ANSI codes + + # ================================================================ + # Color materializer rendering: new element types + # ================================================================ + + Scenario: Color materializer renders tree with ANSI + Given an OutputSession with format "color" using ColorMaterializer + When I create a tree handle with root label "Root" + And I add a tree child "child1" under "Root" + And I close the tree handle + And the session is closed + Then the color output contains ANSI codes + And the color output contains "Root" + + Scenario: Color materializer renders text block + Given an OutputSession with format "color" using ColorMaterializer + When I create a text handle with content "Color text" + And I close the text handle + And the session is closed + Then the color output contains "Color text" + + Scenario: Color materializer renders code with language header + Given an OutputSession with format "color" using ColorMaterializer + When I create a code handle with content "x = 1" and language "python" + And I close the code handle + And the session is closed + Then the color output contains ANSI codes + And the color output contains "python" + + Scenario: Color materializer renders diff with coloured lines + Given an OutputSession with format "color" using ColorMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with header "@@ -1,2 +1,3 @@" and context line "same" and add line "new" + And I close the diff handle + And the session is closed + Then the color output contains ANSI codes + + Scenario: Color materializer renders diff with stats + Given an OutputSession with format "color" using ColorMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I set diff stats with 3 insertions and 2 deletions + And I close the diff handle + And the session is closed + Then the color output contains ANSI codes + And the color output contains "insertion" + + Scenario: Color materializer renders separator with ANSI + Given an OutputSession with format "color" using ColorMaterializer + When I create a separator with style "line" + And the session is closed + Then the color output contains ANSI codes + + Scenario: Color materializer renders double separator + Given an OutputSession with format "color" using ColorMaterializer + When I create a separator with style "double" + And the session is closed + Then the color output contains ANSI codes + + Scenario: Color materializer renders action hint with ANSI + Given an OutputSession with format "color" using ColorMaterializer + When I create an action hint with commands "agents list" and description "Hint" + And the session is closed + Then the color output contains ANSI codes + And the color output contains "agents list" + + # ================================================================ + # Table materializer rendering: new element types + # ================================================================ + + Scenario: Table materializer renders tree using colour renderer + Given an OutputSession with format "table" using TableMaterializer + When I create a tree handle with root label "TreeRoot" + And I add a tree child "branch" under "TreeRoot" + And I close the tree handle + And the session is closed + Then the strategy output contains "TreeRoot" + + Scenario: Table materializer renders code using colour renderer + Given an OutputSession with format "table" using TableMaterializer + When I create a code handle with content "code()" and language "python" + And I close the code handle + And the session is closed + Then the strategy output contains "code()" + + Scenario: Table materializer renders diff using colour renderer + Given an OutputSession with format "table" using TableMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with header "@@ -1 +1 @@" and context line "ctx" and add line "new" + And I close the diff handle + And the session is closed + Then the strategy output contains "a.py" + + Scenario: Table materializer renders text as plain + Given an OutputSession with format "table" using TableMaterializer + When I create a text handle with content "plain text" + And I close the text handle + And the session is closed + Then the strategy output contains "plain text" + + # ================================================================ + # Rich materializer rendering: new element types + # ================================================================ + + Scenario: Rich materializer renders tree with ANSI + Given an OutputSession with format "rich" using RichMaterializer + When I create a tree handle with root label "Root" + And I add a tree child "child" under "Root" + And I close the tree handle + And the session is closed + Then the rich output contains "Root" + + Scenario: Rich materializer renders code with language + Given an OutputSession with format "rich" using RichMaterializer + When I create a code handle with content "fn()" and language "python" + And I close the code handle + And the session is closed + Then the rich output contains "fn()" + + # ================================================================ + # JSON materializer rendering: new element types + # ================================================================ + + Scenario: JSON materializer serializes tree element + Given an OutputSession with format "json" using JsonMaterializer + When I create a tree handle with root label "Project" + And I add a tree child "src" under "Project" + And I close the tree handle + And the session is closed + Then the json output is valid JSON + And the json output contains element type "tree" + And the json output contains "Project" + And the json output contains "src" + + Scenario: JSON materializer serializes text element + Given an OutputSession with format "json" using JsonMaterializer + When I create a text handle with content "Some text" + And I close the text handle + And the session is closed + Then the json output is valid JSON + And the json output contains element type "text" + And the json output contains "Some text" + + Scenario: JSON materializer serializes code element + Given an OutputSession with format "json" using JsonMaterializer + When I create a code handle with content "x = 1" and language "python" + And I close the code handle + And the session is closed + Then the json output is valid JSON + And the json output contains element type "code" + And the json output contains "python" + + Scenario: JSON materializer serializes code with highlight lines + Given an OutputSession with format "json" using JsonMaterializer + When I create a code handle with content "a\nb\nc" + And I set code highlight lines to "1,3" + And I close the code handle + And the session is closed + Then the json output is valid JSON + And the json output contains "highlight_lines" + + Scenario: JSON materializer serializes diff element + Given an OutputSession with format "json" using JsonMaterializer + When I create a diff handle with file_a "old.py" and file_b "new.py" + And I add a diff hunk with header "@@ -1 +1 @@" and context line "ctx" and add line "added" + And I set diff stats with 1 insertions and 0 deletions + And I close the diff handle + And the session is closed + Then the json output is valid JSON + And the json output contains element type "diff" + And the json output contains "old.py" + And the json output contains "stats" + + Scenario: JSON materializer serializes separator element + Given an OutputSession with format "json" using JsonMaterializer + When I create a separator with style "line" + And the session is closed + Then the json output is valid JSON + And the json output contains element type "separator" + + Scenario: JSON materializer serializes action hint element + Given an OutputSession with format "json" using JsonMaterializer + When I create an action hint with commands "agents list" and description "Try this" + And the session is closed + Then the json output is valid JSON + And the json output contains element type "action_hint" + And the json output contains "agents list" + + # ================================================================ + # YAML materializer rendering: new element types + # ================================================================ + + Scenario: YAML materializer serializes tree element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a tree handle with root label "Project" + And I add a tree child "src" under "Project" + And I close the tree handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "tree" + And the yaml output contains "Project" + + Scenario: YAML materializer serializes text element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a text handle with content "yaml text" + And I close the text handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "text" + + Scenario: YAML materializer serializes code element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a code handle with content "fn()" and language "ruby" + And I close the code handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "ruby" + + Scenario: YAML materializer serializes diff element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with header "@@ -1 +1 @@" and context line "x" and add line "y" + And I close the diff handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "diff" + + Scenario: YAML materializer serializes separator element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a separator with style "double" + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "separator" + + Scenario: YAML materializer serializes action hint element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create an action hint with commands "agents list" and description "Do this" + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "action_hint" + + # ================================================================ + # Plain diff rendering: edge cases + # ================================================================ + + Scenario: Plain materializer renders diff with no files + Given an OutputSession with format "plain" using PlainMaterializer + When I create a diff handle with no files + And I add a diff hunk with header "@@ -1 +1 @@" and context line "x" and add line "y" + And I close the diff handle + And the session is closed + Then the plain output contains "@@ -1 +1 @@" + + Scenario: Plain materializer renders diff with remove lines + Given an OutputSession with format "plain" using PlainMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with header "@@ -1,2 +1,1 @@" and remove line "deleted" + And I close the diff handle + And the session is closed + Then the plain output contains "-deleted" + + # ================================================================ + # Color diff rendering: edge cases + # ================================================================ + + Scenario: Color materializer renders diff with no files + Given an OutputSession with format "color" using ColorMaterializer + When I create a diff handle with no files + And I add a diff hunk with header "@@ -1 +1 @@" and context line "x" and add line "y" + And I close the diff handle + And the session is closed + Then the color output contains ANSI codes + + Scenario: Color materializer renders separator blank + Given an OutputSession with format "color" using ColorMaterializer + When I create a separator with style "blank" + And the session is closed + Then the session completed without error + + # ================================================================ + # TreeHandle: find_node with empty path returns root + # ================================================================ + + Scenario: TreeHandle find_node with empty path returns root + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + And I add a tree child "child" under the root via empty path + Then the tree root has 1 children + + # ================================================================ + # F5: Rich materializer — missing 7 element types + # ================================================================ + + Scenario: Rich materializer renders table + Given an OutputSession with format "rich" using RichMaterializer + When I create a table handle with columns "Name,Status" + And I add a dict row with Name "svc" and Status "ok" + And I close the table handle + And the session is closed + Then the rich output contains "svc" + + Scenario: Rich materializer renders status + Given an OutputSession with format "rich" using RichMaterializer + When I create a status handle with message "All good" + And I close the status handle + And the session is closed + Then the rich output contains "All good" + + Scenario: Rich materializer renders progress + Given an OutputSession with format "rich" using RichMaterializer + When I create a progress handle labelled "Loading" with total 100 + And I set progress to 50 of 100 + And I close the progress handle + And the session is closed + Then the rich output contains "Loading" + + Scenario: Rich materializer renders text + Given an OutputSession with format "rich" using RichMaterializer + When I create a text handle with content "Some rich text" + And I close the text handle + And the session is closed + Then the rich output contains "Some rich text" + + Scenario: Rich materializer renders diff + Given an OutputSession with format "rich" using RichMaterializer + When I create a diff handle with file_a "old.py" and file_b "new.py" + And I add a diff hunk with header "@@ -1 +1 @@" and context line "ctx" and add line "added" + And I close the diff handle + And the session is closed + Then the rich output contains "old.py" + + Scenario: Rich materializer renders separator + Given an OutputSession with format "rich" using RichMaterializer + When I create a separator with style "line" + And the session is closed + Then the session completed without error + + Scenario: Rich materializer renders action hint + Given an OutputSession with format "rich" using RichMaterializer + When I create an action hint with commands "deploy" + And the session is closed + Then the rich output contains "deploy" + + # ================================================================ + # N4: strip_terminal_escapes — security-critical function + # ================================================================ + + Scenario: strip_terminal_escapes removes 7-bit CSI sequences + When I strip terminal escapes from "hello\x1b[31mred\x1b[0m world" + Then the stripped result is "hellored world" + + Scenario: strip_terminal_escapes removes 8-bit CSI sequences + When I strip terminal escapes from text with 8-bit CSI + Then the stripped result does not contain bytes 0x9b + + Scenario: strip_terminal_escapes removes OSC sequences + When I strip terminal escapes from text with OSC title set + Then the stripped result does not contain OSC payload + + Scenario: strip_terminal_escapes removes C1 control characters + When I strip terminal escapes from text with C1 control chars + Then the stripped result does not contain bytes 0x80 through 0x9f + + Scenario: strip_terminal_escapes preserves normal text + When I strip terminal escapes from "hello world 123" + Then the stripped result is "hello world 123" + + Scenario: strip_terminal_escapes preserves tabs and newlines + When I strip terminal escapes from "line1\tindented\nline2" + Then the stripped result is "line1\tindented\nline2" + + Scenario: strip_terminal_escapes removes BEL-terminated OSC + When I strip terminal escapes from text with BEL-terminated OSC + Then the stripped result is "safetext" + + Scenario: strip_terminal_escapes removes private-mode CSI + When I strip terminal escapes from text with private-mode CSI + Then the stripped result is "content" + + Scenario: strip_terminal_escapes removes hyperlink OSC injection + When I strip terminal escapes from text with hyperlink OSC + Then the stripped result is "click here" + + Scenario: strip_terminal_escapes handles empty input + When I strip terminal escapes from "" + Then the stripped result is "" + + Scenario: strip_terminal_escapes handles mixed escape combinations + When I strip terminal escapes from text with mixed escapes + Then the stripped result is "hello world" + + # ================================================================ + # N11: YAML materializer — missing Panel, Table, Status, Progress + # ================================================================ + + Scenario: YAML materializer serializes panel element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a panel handle with title "Info" + And I set entry "Name" to "myapp" + And I close the panel handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "panel" + And the yaml output contains "Info" + + Scenario: YAML materializer serializes table element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a table handle with columns "Col1,Col2" + And I add a dict row with Name "Col1" and Status "val1" + And I close the table handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "table" + And the yaml output contains "Col1" + + Scenario: YAML materializer serializes status element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a status handle with message "System ready" + And I close the status handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "status" + And the yaml output contains "System ready" + + Scenario: YAML materializer serializes progress element + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a progress handle labelled "Deploying" with total 50 + And I set progress to 25 of 50 + And I close the progress handle + And the session is closed + Then the yaml output is valid YAML + And the yaml output contains "progress" + And the yaml output contains "Deploying" + + # ================================================================ + # N12: Table materializer — missing Status, Progress, Separator, ActionHint + # ================================================================ + + Scenario: Table materializer renders status + Given an OutputSession with format "table" using TableMaterializer + When I create a status handle with message "System OK" + And I close the status handle + And the session is closed + Then the strategy output contains "System OK" + + Scenario: Table materializer renders progress + Given an OutputSession with format "table" using TableMaterializer + When I create a progress handle labelled "Building" with total 10 + And I set progress to 5 of 10 + And I close the progress handle + And the session is closed + Then the strategy output contains "Building" + + Scenario: Table materializer renders separator + Given an OutputSession with format "table" using TableMaterializer + When I create a separator with style "double" + And the session is closed + Then the session completed without error + + Scenario: Table materializer renders action hint + Given an OutputSession with format "table" using TableMaterializer + When I create an action hint with commands "agents list" + And the session is closed + Then the strategy output contains "agents list" + + # ================================================================ + # N13: Concurrency tests + # ================================================================ + + Scenario: Concurrent writes to the same handle are thread-safe + Given an OutputSession with format "plain" using PlainMaterializer + When I create a text handle with content "" + And I concurrently append 100 lines to the same text handle from 4 threads + And I close the text handle + And the session is closed + Then the text handle has exactly 100 appended segments + + Scenario: Concurrent handle creation is thread-safe + Given an OutputSession with format "plain" using PlainMaterializer + When I concurrently create 20 panels from 4 threads + And the session is closed + Then the session snapshot has 20 elements + + Scenario: Race between session close and handle write does not crash + Given an OutputSession with format "plain" using PlainMaterializer + When I create a text handle with content "initial" + And I race session close against handle writes + Then no unhandled exception was raised + + # ================================================================ + # N14: Progress throttling + # ================================================================ + + Scenario: Progress handle throttles rapid updates + Given an OutputSession with format "plain" using PlainMaterializer + When I create a progress handle labelled "Throttle test" with total 1000 + And I rapidly tick the progress 200 times within 50ms + And I close the progress handle + Then fewer than 20 element-updated events were emitted for the progress handle + + # ================================================================ + # Luis review fixes — M2: TreeHandle rejects slash in labels + # ================================================================ + + Scenario: TreeHandle rejects label containing slash + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then adding a tree child with slash in label raises ValueError + + # ================================================================ + # Luis review fixes — M3: MAX_ELEMENTS_PER_SESSION enforcement + # ================================================================ + + Scenario: Session enforces MAX_ELEMENTS_PER_SESSION limit + Given an OutputSession with format "plain" using PlainMaterializer + When I create elements up to the session limit + Then creating one more element raises ValueError + + # ================================================================ + # R2-C2 fix #15: MAX_TABLE_ROWS enforcement + # ================================================================ + + Scenario: Table enforces MAX_TABLE_ROWS limit on add_row + Given an OutputSession with format "plain" using PlainMaterializer + When I create a table handle with columns "Name" + And I fill the table to its MAX_TABLE_ROWS limit + Then adding one more row raises ValueError + + # ================================================================ + # Luis review fixes — M4: sort_descending rendering + # ================================================================ + + Scenario: Table renders rows in descending sort order + Given an OutputSession with format "plain" using PlainMaterializer + When I create a table handle with columns "Name,Status" + And I add a dict row with Name "Alpha" and Status "1" + And I add a dict row with Name "Charlie" and Status "3" + And I add a dict row with Name "Bravo" and Status "2" + And I set sort key to "Name" descending + And I close the table handle + And the session is closed + Then the plain output has "Charlie" before "Alpha" + + # ================================================================ + # Luis review fixes — L4: TextBlock indent validation + # ================================================================ + + Scenario: TextBlock rejects negative indent + Given an OutputSession with format "plain" + Then creating a text handle with negative indent raises ValueError + + # ================================================================ + # Luis review fixes — L9: TextBlock with wrap=False + # ================================================================ + + Scenario: Plain materializer renders text with wrap=False verbatim + Given an OutputSession with format "plain" using PlainMaterializer + When I create a text handle with content "first line" and wrap false and indent 4 + And I close the text handle + And the session is closed + Then the plain output contains " first line" + + # ================================================================ + # Luis review fixes — L10: DiffBlock with no files and no hunks + # ================================================================ + + Scenario: Plain materializer renders empty diff block + Given an OutputSession with format "plain" using PlainMaterializer + When I create a diff handle with no files + And I close the diff handle + And the session is closed + Then the session completed without error + + # ================================================================ + # Review fix — collapsed tree node rendering (L3 fix validation) + # ================================================================ + + Scenario: Plain materializer renders collapsed tree child with marker + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree handle with root label "Root" + And I add a collapsed tree child "hidden" under "Root" + And I close the tree handle + And the session is closed + Then the plain output contains "[collapsed]" + And the plain output contains "hidden" + + Scenario: Plain materializer renders collapsed child in flat mode + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree handle with root label "Flat" and show_guides false + And I add a collapsed tree child "hidden" under "Flat" + And I close the tree handle + And the session is closed + Then the plain output contains "[collapsed]" + And the plain output contains "hidden" + + # ================================================================ + # Review fix — detect_terminal_capabilities actual assertion + # ================================================================ + + Scenario: Detect terminal capabilities returns correct types + When I detect terminal capabilities + Then the capabilities is_tty is a boolean + And the capabilities supports_ansi is a boolean + And the capabilities supports_cursor is a boolean + And the capabilities term is a string + + # ================================================================ + # Review fix — session double-close behavior (C1 fix validation) + # ================================================================ + + Scenario: Session double-close does not deadlock or raise + Given an OutputSession with format "plain" using PlainMaterializer + When I create a panel handle with title "DoubleClose" + And I set entry "K" to "V" + And the session is closed + And the session is closed again + Then no deadlock occurred and both closes returned snapshots + + # ================================================================ + # Review fix — ColumnDef non-default field serialization + # ================================================================ + + Scenario: JSON materializer serializes non-default ColumnDef fields + Given an OutputSession with format "json" using JsonMaterializer + When I create a table with non-default ColumnDef fields + And I add a dict row with Name "Name" and Status "Alice" + And I close the table handle + And the session is closed + Then the json output is valid JSON + And the json output contains "alignment" + And the json output contains "sortable" + And the json output contains "width_hint" + + # ================================================================ + # P1-1: ProgressHandle validates total parameter + # ================================================================ + + Scenario: ProgressHandle rejects negative total in set_progress + Given an OutputSession with format "plain" + When I create a progress handle labelled "Test" + Then setting progress total to negative raises ValueError + + Scenario: Session progress factory rejects negative total + Given an OutputSession with format "plain" + Then creating a progress with negative total raises ValueError + + # ================================================================ + # P1-2: JSON/YAML output includes timing field + # ================================================================ + + Scenario: JSON output includes timing after session close + Given an OutputSession with format "json" using JsonMaterializer + When I create a panel handle with title "Timed" + And I close the panel handle + And the session is closed + Then the json output is valid JSON + And the json output contains "timing" + + # ================================================================ + # P1-4: NO_COLOR environment variable forces plain format + # ================================================================ + + Scenario: NO_COLOR env var forces plain materializer for rich + Given the NO_COLOR environment variable is set + When I select a materializer for format "rich" + Then the selected materializer is PlainMaterializer + + Scenario: NO_COLOR env var forces plain materializer for color + Given the NO_COLOR environment variable is set + When I select a materializer for format "color" + Then the selected materializer is PlainMaterializer + + Scenario: NO_COLOR env var forces plain materializer for table + Given the NO_COLOR environment variable is set + When I select a materializer for format "table" + Then the selected materializer is PlainMaterializer + + Scenario: NO_COLOR env var does not affect json format + Given the NO_COLOR environment variable is set + When I select a materializer for format "json" + Then the selected materializer is JsonMaterializer + + # ================================================================ + # P2-1: DiffLine.line_type backward compatibility + # ================================================================ + + Scenario: DiffLine accepts line_type field name + When I create a DiffLine with line_type "add" and content "new" + Then the DiffLine line_type is "add" + + Scenario: DiffLine accepts type alias for backward compatibility + When I create a DiffLine with type alias "remove" and content "old" + Then the DiffLine line_type is "remove" + + # ================================================================ + # P2-2: Color materializer renders text with ANSI codes + # ================================================================ + + Scenario: Color materializer renders text block with ANSI codes + Given an OutputSession with format "color" using ColorMaterializer + When I create a text handle with content "Color styled text" + And I close the text handle + And the session is closed + Then the color output contains ANSI codes + And the color output contains "Color styled text" + + # ================================================================ + # P2-3: Numeric column sorting + # ================================================================ + + Scenario: Table sorts numeric column by value not lexicographically + Given an OutputSession with format "plain" using PlainMaterializer + When I create a table with a numeric column "Score" + And I add rows with scores 9, 10, 100, 20 + And I set sort key to "Score" ascending + And I close the table handle + And the session is closed + Then the plain output has "9" before "10" + And the plain output has "10" before "20" + And the plain output has "20" before "100" + + # ================================================================ + # P2-4: ColumnDef serialization includes all fields + # ================================================================ + + Scenario: JSON output includes all ColumnDef fields even when default + Given an OutputSession with format "json" using JsonMaterializer + When I create a table with default ColumnDef fields + And I add a dict row with Name "Name" and Status "Alice" + And I close the table handle + And the session is closed + Then the json output is valid JSON + And the json output contains "col_type" + And the json output contains "alignment" + + # ================================================================ + # P2-5: Unicode box-drawing characters + # ================================================================ + + Scenario: Table materializer uses Unicode box-drawing characters + Given an OutputSession with format "table" using TableMaterializer + When I create a table handle with columns "Name,Status" + And I add a dict row with Name "svc" and Status "up" + And I close the table handle + And the session is closed + Then the strategy output contains Unicode box-drawing chars + + # ================================================================ + # P3-3: MAX_TABLE_ROWS enforcement in add_rows batch + # ================================================================ + + Scenario: Table enforces MAX_TABLE_ROWS limit on add_rows batch + Given an OutputSession with format "plain" using PlainMaterializer + When I create a table handle with columns "Name" + And I fill the table close to its limit + Then adding a batch that exceeds the limit raises ValueError + + # ================================================================ + # P3-5: Stress test with 10+ concurrent producers + # ================================================================ + + Scenario: 10 concurrent producers create handles without corruption + Given an OutputSession with format "plain" using PlainMaterializer + When I concurrently create 10 panels from 10 threads + And the session is closed + Then the session snapshot has 10 elements + + # ================================================================ + # P3-6: Boxdraw summary truncation + # ================================================================ + + Scenario: Boxdraw table truncates long summary to fit border + Given an OutputSession with format "table" using TableMaterializer + When I create a table with a 2-char column "AB" + And I set a very long summary string + And I close the table handle + And the session is closed + Then the strategy output does not have summary exceeding table width + + # ================================================================ + # P3-7: Explicit format flag for color and table + # ================================================================ + + Scenario: Explicit color format creates ColorMaterializer without fallback + Given a non-TTY terminal environment + When I select a materializer for format "color" with explicit flag + Then the selected materializer is ColorMaterializer + + Scenario: Explicit table format creates TableMaterializer without fallback + Given a non-TTY terminal environment + When I select a materializer for format "table" with explicit flag + Then the selected materializer is TableMaterializer + + # ================================================================ + # P3-8: ProgressHandle rejects zero delta + # ================================================================ + + Scenario: ProgressHandle rejects zero increment delta + Given an OutputSession with format "plain" + When I create a progress handle labelled "Test" + Then incrementing by zero delta raises ValueError + + # ================================================================ + # Coverage: color materializer table with summary + # ================================================================ + + Scenario: Color materializer renders table with summary + Given an OutputSession with format "color" using ColorMaterializer + When I create a table handle with columns "Name,Count" + And I add a dict row with Name "A" and Status "10" + And I set summary with total "10" + And I close the table handle + And the session is closed + Then the color output contains ANSI codes + And the color output contains "total" + + # ================================================================ + # Coverage: color diff with remove lines + # ================================================================ + + Scenario: Color materializer renders diff with remove lines + Given an OutputSession with format "color" using ColorMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with header "@@ -1,2 +1,1 @@" and remove line "deleted" + And I close the diff handle + And the session is closed + Then the color output contains ANSI codes + + # ================================================================ + # Coverage: color text with wrap=False and multiline + # ================================================================ + + Scenario: Color materializer renders text with wrap=False + Given an OutputSession with format "color" using ColorMaterializer + When I create a text handle with content "first line" and wrap false and indent 2 + And I close the text handle + And the session is closed + Then the color output contains ANSI codes + And the color output contains "first line" + + Scenario: Color materializer renders multiline text with empty lines + Given an OutputSession with format "color" using ColorMaterializer + When I create a text handle with multiline content including empty lines + And I close the text handle + And the session is closed + Then the color output contains ANSI codes + + # ================================================================ + # Coverage: _reset_counters test helper + # ================================================================ + + Scenario: ID counter reset helper works correctly + When I reset the ID counters + Then the next session ID starts from 1 + And the next handle ID starts from 1 + + # ================================================================ + # Coverage: numeric sort with non-numeric values + # ================================================================ + + Scenario: Table numeric sort handles non-numeric values gracefully + Given an OutputSession with format "plain" using PlainMaterializer + When I create a table with a numeric column "Score" + And I add rows with scores 10, N/A, 5 + And I set sort key to "Score" ascending + And I close the table handle + And the session is closed + Then the plain output has "5" before "10" + + # ================================================================ + # Coverage: JSON diff with line numbers + # ================================================================ + + Scenario: JSON materializer serializes diff with line numbers + Given an OutputSession with format "json" using JsonMaterializer + When I create a diff handle with file_a "a.py" and file_b "b.py" + And I add a diff hunk with line numbers + And I close the diff handle + And the session is closed + Then the json output is valid JSON + And the json output contains "line_number_old" + And the json output contains "line_number_new" + + # ================================================================ + # Coverage: JSON tree with style_hint and metadata + # ================================================================ + + Scenario: JSON materializer serializes tree with style hint and metadata + Given an OutputSession with format "json" using JsonMaterializer + When I create a tree handle with root label "Root" + And I add a tree child "styled" under "Root" with style "success" and metadata + And I close the tree handle + And the session is closed + Then the json output is valid JSON + And the json output contains "style_hint" + And the json output contains "metadata" + + # ================================================================ + # Coverage: flat tree (no guides) depth guard + # ================================================================ + + Scenario: Flat tree rendering truncates at max_depth_hint + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree handle with root label "Flat" and show_guides false and max_depth_hint 1 + And I add a tree child "child" under "Flat" + And I add a tree child "grandchild" under "Flat/child" + And I close the tree handle + And the session is closed + Then the plain output contains "Flat" + And the plain output contains "child" + And the plain output contains "more" + + # ================================================================ + # Coverage: _find_node cache miss traversal + # ================================================================ + + Scenario: TreeHandle find_node traverses path on cache miss + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + And I build a 3-level deep tree and query a deep path + Then the deep tree node was found correctly + + # ================================================================ + # Coverage: TextHandle _emit_update with incremental strategy + # ================================================================ + + Scenario: TextHandle emits updates when strategy supports incremental + Given an OutputSession with a mock incremental strategy + When I create a text handle with content "" + And I append text "hello" + Then the incremental strategy received an update event + + # ================================================================ + # Coverage: table format falls back to color on non-TTY with ANSI + # ================================================================ + + Scenario: Table format falls back to color when non-TTY but ANSI + Given a terminal with ANSI but non-TTY + When I select a materializer for format "table" + Then the selected materializer is ColorMaterializer + + # ================================================================ + # Coverage: set_node_style with non-existent path + # ================================================================ + + Scenario: TreeHandle set_node_style rejects non-existent path + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then setting tree node style on non-existent path raises ValueError + + # ================================================================ + # Coverage: session status factory rejects invalid level + # ================================================================ + + Scenario: Session status factory rejects invalid level + Given an OutputSession with format "plain" + Then creating a status with invalid level raises ValueError + + # ================================================================ + # Coverage: JSON tree with collapsed node + # ================================================================ + + Scenario: JSON materializer serializes tree with collapsed node + Given an OutputSession with format "json" using JsonMaterializer + When I create a tree handle with root label "Root" + And I add a collapsed tree child "hidden" under "Root" + And I close the tree handle + And the session is closed + Then the json output is valid JSON + And the json output contains "collapsed" + + # ================================================================ + # Coverage: deep tree at MAX_TREE_DEPTH rendering + # ================================================================ + + Scenario: Deep tree at MAX_TREE_DEPTH triggers render depth guard + Given an OutputSession with format "plain" using PlainMaterializer + When I create a tree at maximum depth + And I close the tree handle + And the session is closed + Then the deep tree output contains "more" + + # ================================================================ + # Coverage: JSON tree truncated at depth limit + # ================================================================ + + Scenario: JSON tree node serialization truncates at depth limit + When I serialize a tree node beyond MAX_TREE_DEPTH + Then the serialized node contains truncated flag + + # ================================================================ + # Coverage: selection.py — rich with cursor support + # ================================================================ + + Scenario: Rich format uses RichMaterializer when cursor supported + Given a terminal with cursor support + When I select a materializer for format "rich" + Then the selected materializer is RichMaterializer + + Scenario: Rich format falls back to color when ANSI but no TTY no cursor + Given a terminal with ANSI only no TTY no cursor + When I select a materializer for format "rich" + Then the selected materializer is ColorMaterializer + + # ================================================================ + # Coverage: TreeHandle add_child exceeds MAX_TREE_DEPTH + # ================================================================ + + Scenario: TreeHandle rejects child beyond MAX_TREE_DEPTH + Given an OutputSession with format "plain" + When I create a tree at exactly max depth via handle + Then adding one more child level raises ValueError + + # ================================================================ + # Coverage: detect_terminal_capabilities with cursor-capable TERM + # ================================================================ + + Scenario: detect_terminal_capabilities detects cursor support for xterm + When I detect capabilities with TERM "xterm-256color" on a TTY + Then the capabilities supports_cursor is true + + # ================================================================ + # Coverage: _find_node with mismatched root label + # ================================================================ + + Scenario: TreeHandle find_node returns None for mismatched root + Given an OutputSession with format "plain" + When I create a tree handle with root label "Root" + Then querying a path with wrong root returns None diff --git a/features/steps/materializers_coverage_boost_steps.py b/features/steps/materializers_coverage_boost_steps.py index 57c27948e..950b3b488 100644 --- a/features/steps/materializers_coverage_boost_steps.py +++ b/features/steps/materializers_coverage_boost_steps.py @@ -18,6 +18,12 @@ import re from behave import given, then, when from behave.runner import Context +from cleveragents.cli.output._color_renderers import ( + render_element_color as _render_element_color, +) +from cleveragents.cli.output._renderers import ( + render_element_plain as _render_element_plain, +) from cleveragents.cli.output.handles import ( ElementClosed, Panel, @@ -32,8 +38,6 @@ from cleveragents.cli.output.materializers import ( _AccumulateStrategy, _BaseBufferStrategy, _element_to_dict, - _render_element_color, - _render_element_plain, ) from cleveragents.cli.output.session import OutputSession, StructuredOutput diff --git a/features/steps/output_rendering_steps.py b/features/steps/output_rendering_steps.py index f487708d3..3597c8dcc 100644 --- a/features/steps/output_rendering_steps.py +++ b/features/steps/output_rendering_steps.py @@ -2,9 +2,12 @@ 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 @@ -15,14 +18,21 @@ from cleveragents.cli.formatting import ( 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, @@ -31,6 +41,7 @@ from cleveragents.cli.output.materializers import ( RichMaterializer, TableMaterializer, YamlMaterializer, + strip_terminal_escapes, ) from cleveragents.cli.output.selection import ( TerminalCapabilities, @@ -84,7 +95,6 @@ def step_session_auto_closed(context: Context) -> None: def step_session_ctx_exception(context: Context) -> None: strategy = PlainMaterializer() context.strategy = strategy - context.exc_exit_code = None try: with OutputSession( format="plain", command="test-exc", strategy=strategy @@ -93,15 +103,16 @@ def step_session_ctx_exception(context: Context) -> None: raise RuntimeError("Intentional test error") except RuntimeError: pass - # The session should have been closed with exit_code 1 - # We can check this via the snapshot - context.exc_exit_code = 1 # implied by __exit__ on exception + # 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" - assert context.exc_exit_code == code + # 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}" # =========================================================================== @@ -126,13 +137,13 @@ def step_close_panel(context: Context) -> None: @then("the panel has {count:d} entries") def step_panel_entry_count(context: Context, count: int) -> None: - panel: Panel = context.panel_handle.element # type: ignore[assignment] + 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: Panel = context.panel_handle.element # type: ignore[assignment] + 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}" @@ -180,7 +191,7 @@ def step_create_table(context: Context, columns: str) -> None: @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: Table = context.table_handle.element # type: ignore[assignment] + 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: @@ -198,7 +209,7 @@ def step_add_list_row(context: Context, values: str) -> None: @when("I add {count:d} batch rows") def step_add_batch_rows(context: Context, count: int) -> None: - tbl: Table = context.table_handle.element # type: ignore[assignment] + 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): @@ -211,7 +222,7 @@ def step_add_batch_rows(context: Context, count: int) -> None: @then("the table has {count:d} rows") def step_table_row_count(context: Context, count: int) -> None: - tbl: Table = context.table_handle.element # type: ignore[assignment] + tbl = cast(Table, context.table_handle.element) assert len(tbl.rows) == count, f"Expected {count}, got {len(tbl.rows)}" @@ -222,7 +233,7 @@ def step_set_summary(context: Context, total: str) -> None: @then("the table summary is set") def step_table_summary_set(context: Context) -> None: - tbl: Table = context.table_handle.element # type: ignore[assignment] + tbl = cast(Table, context.table_handle.element) assert tbl.summary is not None @@ -233,7 +244,7 @@ def step_set_sort_key(context: Context, column: str) -> None: @then('the table sort key is "{column}"') def step_table_sort_key(context: Context, column: str) -> None: - tbl: Table = context.table_handle.element # type: ignore[assignment] + tbl = cast(Table, context.table_handle.element) assert tbl.sort_key == column @@ -283,19 +294,19 @@ def step_close_status(context: Context) -> None: @then('the status message is "{message}"') def step_status_message_check(context: Context, message: str) -> None: - sm: StatusMessage = context.status_handle.element # type: ignore[assignment] + 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: StatusMessage = context.status_handle.element # type: ignore[assignment] + 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: StatusMessage = context.status_handle.element # type: ignore[assignment] + sm = cast(StatusMessage, context.status_handle.element) assert sm.detail == detail @@ -377,31 +388,31 @@ def step_close_progress(context: Context) -> None: @then("the progress current is {val:d}") def step_progress_current(context: Context, val: int) -> None: - pi: ProgressIndicator = context.progress_handle.element # type: ignore[assignment] + 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: ProgressIndicator = context.progress_handle.element # type: ignore[assignment] + 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: ProgressIndicator = context.progress_handle.element # type: ignore[assignment] + pi = cast(ProgressIndicator, context.progress_handle.element) assert pi.label == label @then("the progress is indeterminate") def step_progress_indeterminate(context: Context) -> None: - pi: ProgressIndicator = context.progress_handle.element # type: ignore[assignment] + 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: ProgressIndicator = context.progress_handle.element # type: ignore[assignment] + pi = cast(ProgressIndicator, context.progress_handle.element) assert pi.steps is not None for s in pi.steps: if s.label == step: @@ -513,7 +524,8 @@ def step_color_output_contains(context: Context, text: str) -> None: @then("the table output contains box-drawing characters") def step_table_box_chars(context: Context) -> None: output = context.strategy.get_output() - assert "|" in output or "+" in output, f"No box chars in:\n{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}"') @@ -800,9 +812,10 @@ def step_set_entry_empty_key(context: Context) -> None: @then("batch setting None entries raises ValueError") def step_batch_set_none(context: Context) -> None: try: - context.panel_handle.set_entries(None) # type: ignore[arg-type] + none_val: Any = None + context.panel_handle.set_entries(none_val) raise AssertionError("Expected ValueError") - except (ValueError, TypeError): + except ValueError: pass @@ -818,27 +831,30 @@ def step_remove_empty_key(context: Context) -> None: @then("adding a None row raises ValueError") def step_add_none_row(context: Context) -> None: try: - context.table_handle.add_row(None) # type: ignore[arg-type] + none_val: Any = None + context.table_handle.add_row(none_val) raise AssertionError("Expected ValueError") - except (ValueError, TypeError): + except ValueError: pass @then("adding None rows raises ValueError") def step_add_none_rows(context: Context) -> None: try: - context.table_handle.add_rows(None) # type: ignore[arg-type] + none_val: Any = None + context.table_handle.add_rows(none_val) raise AssertionError("Expected ValueError") - except (ValueError, TypeError): + except ValueError: pass @then("setting None summary raises ValueError") def step_set_none_summary(context: Context) -> None: try: - context.table_handle.set_summary(None) # type: ignore[arg-type] + none_val: Any = None + context.table_handle.set_summary(none_val) raise AssertionError("Expected ValueError") - except (ValueError, TypeError): + except ValueError: pass @@ -924,19 +940,19 @@ def step_create_empty_table(context: Context) -> None: @when('I set table title to "{title}"') def step_set_table_title(context: Context, title: str) -> None: - tbl: Table = context.table_handle.element # type: ignore[assignment] + 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: Panel = context.panel_handle.element # type: ignore[assignment] + 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: Panel = context.panel_handle.element # type: ignore[assignment] + panel = cast(Panel, context.panel_handle.element) panel.entries.append(PanelEntry(key=key, value=val, icon=icon)) @@ -1096,7 +1112,7 @@ def step_handle_none_session(context: Context) -> None: handle_id="h1", element_type="panel", declaration_index=0, - session=None, # type: ignore[arg-type] + session=cast(Any, None), element=Panel(title="T"), ) raise AssertionError("Expected ValueError") @@ -1112,7 +1128,7 @@ def step_handle_none_element(context: Context) -> None: element_type="panel", declaration_index=0, session=context.session, - element=None, # type: ignore[arg-type] + element=cast(Any, None), ) raise AssertionError("Expected ValueError") except ValueError: @@ -1169,16 +1185,6 @@ def step_detect_caps(context: Context) -> None: context.detected_caps = detect_terminal_capabilities() -@then("the capabilities object has is_tty field") -def step_caps_has_tty(context: Context) -> None: - assert hasattr(context.detected_caps, "is_tty") - - -@then("the capabilities object has supports_ansi field") -def step_caps_has_ansi(context: Context) -> None: - assert hasattr(context.detected_caps, "supports_ansi") - - # =========================================================================== # Second panel handle for out-of-order flush # =========================================================================== @@ -1213,3 +1219,1522 @@ def step_json_no_session(context: Context) -> None: 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