diff --git a/features/steps/tui_conversation_module_coverage_steps.py b/features/steps/tui_conversation_module_coverage_steps.py new file mode 100644 index 000000000..80266c666 --- /dev/null +++ b/features/steps/tui_conversation_module_coverage_steps.py @@ -0,0 +1,284 @@ +"""Step definitions for direct coverage of cleveragents.tui.conversation.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from behave import then, when + +from cleveragents.tui.conversation import ( + ConversationBlock, + ConversationSettings, + ConversationStream, + load_conversation_settings, +) + + +def _unescape(value: str) -> str: + return value.encode("utf-8").decode("unicode_escape") + + +@when("I build default ConversationSettings") +def step_build_default_settings(context): + context._conv_settings = ConversationSettings() + + +@when( + "I build ConversationSettings with prune_low_mark {low:d} " + "prune_excess {excess:d} preserve_recent_lines {preserve:d}" +) +def step_build_explicit_settings(context, low, excess, preserve): + context._conv_settings = ConversationSettings( + prune_low_mark=low, + prune_excess=excess, + preserve_recent_lines=preserve, + ) + + +@then("conversation settings prune_low_mark should equal {expected:d}") +def step_assert_settings_low(context, expected): + assert context._conv_settings.prune_low_mark == expected, ( + f"expected prune_low_mark={expected}, " + f"got {context._conv_settings.prune_low_mark}" + ) + + +@then("conversation settings prune_excess should equal {expected:d}") +def step_assert_settings_excess(context, expected): + assert context._conv_settings.prune_excess == expected, ( + f"expected prune_excess={expected}, got {context._conv_settings.prune_excess}" + ) + + +@then("conversation settings preserve_recent_lines should equal {expected:d}") +def step_assert_settings_preserve(context, expected): + assert context._conv_settings.preserve_recent_lines == expected, ( + f"expected preserve_recent_lines={expected}, " + f"got {context._conv_settings.preserve_recent_lines}" + ) + + +@then("conversation settings trigger_line_count should equal {expected:d}") +def step_assert_trigger(context, expected): + assert context._conv_settings.trigger_line_count == expected, ( + f"expected trigger_line_count={expected}, " + f"got {context._conv_settings.trigger_line_count}" + ) + + +@then('a conversation block with text "{text}" should have line count {count:d}') +def step_assert_block_lines(context, text, count): + decoded = _unescape(text) + block = ConversationBlock(text=decoded) + assert block.line_count() == count, ( + f"expected line_count={count} for {decoded!r}, got {block.line_count()}" + ) + + +@then("a conversation block with empty text should have line count {count:d}") +def step_assert_empty_block_lines(context, count): + block = ConversationBlock(text="") + assert block.line_count() == count, ( + f"expected empty block line_count={count}, got {block.line_count()}" + ) + + +def _make_stream(settings: ConversationSettings | None = None) -> ConversationStream: + return ConversationStream(settings=settings) + + +@when("a fresh ConversationStream with default settings") +def step_fresh_stream_default(context): + context._conv_stream = _make_stream() + + +# Behave does not support a "Given" decorator import alias mismatch; use when too. +from behave import given # noqa: E402 + + +@given("a fresh ConversationStream with default settings") +def step_given_fresh_stream_default(context): + context._conv_stream = _make_stream() + + +@given( + "a fresh ConversationStream with low_mark {low:d} excess {excess:d} " + "preserve_recent_lines {preserve:d}" +) +def step_given_fresh_stream_custom(context, low, excess, preserve): + context._conv_stream = _make_stream( + ConversationSettings( + prune_low_mark=low, + prune_excess=excess, + preserve_recent_lines=preserve, + ) + ) + + +@when('I bootstrap the stream with welcome text "{text}"') +def step_bootstrap_stream(context, text): + context._conv_stream.bootstrap(welcome_text=text) + + +@when("I clear the stream") +def step_clear_stream(context): + context._conv_stream.clear() + + +@when("I extend the stream with {count:d} plain message blocks") +def step_extend_stream(context, count): + blocks = [ + ConversationBlock(text=f"extend-block-{idx}") for idx in range(1, count + 1) + ] + context._conv_stream.extend(blocks) + + +@when('I add a plain block "{text}" to the stream') +def step_add_plain_block(context, text): + context._conv_stream.add_block(text) + + +@when("I add an empty plain block to the stream") +def step_add_empty_plain_block(context): + context._conv_stream.add_block("") + + +@when('I add a markup block "{text}" to the stream') +def step_add_markup_block(context, text): + context._conv_stream.add_block(text, markup=True) + + +@when("I deliver {count:d} plain blocks of {lines:d} lines each through add_block") +def step_deliver_blocks(context, count, lines): + for idx in range(1, count + 1): + payload = "\n".join(f"plain-{idx}-line-{line}" for line in range(1, lines + 1)) + context._conv_stream.add_block(payload) + + +@then("the stream should have exactly {count:d} block") +@then("the stream should have exactly {count:d} blocks") +def step_assert_block_count(context, count): + assert len(context._conv_stream.blocks) == count, ( + f"expected {count} blocks, got {len(context._conv_stream.blocks)}" + ) + + +@then("the stream total_lines should be zero") +def step_assert_zero_lines(context): + assert context._conv_stream.total_lines == 0, ( + f"expected total_lines=0, got {context._conv_stream.total_lines}" + ) + + +@then("the stream total_lines should be greater than zero") +def step_assert_positive_lines(context): + assert context._conv_stream.total_lines > 0, ( + f"expected positive total_lines, got {context._conv_stream.total_lines}" + ) + + +@then("the stream total_lines should be less than or equal to {limit:d}") +def step_assert_lines_bound(context, limit): + assert context._conv_stream.total_lines <= limit, ( + f"expected total_lines<={limit}, got {context._conv_stream.total_lines}" + ) + + +@then('the stream rendered text should contain "{text}"') +def step_assert_render_contains(context, text): + rendered = context._conv_stream.render() + assert text in rendered, f"expected {text!r} in render, got {rendered!r}" + + +@then('the stream rendered text should not contain "{text}"') +def step_assert_render_not_contains(context, text): + decoded = _unescape(text) + rendered = context._conv_stream.render() + assert decoded not in rendered, ( + f"unexpected {decoded!r} in render, got {rendered!r}" + ) + + +@then('the stream rendered text should equal "{text}"') +def step_assert_render_equals(context, text): + rendered = context._conv_stream.render() + assert rendered == text, f"expected render=={text!r}, got {rendered!r}" + + +@then("the stream rendered plain segment should be backslash escaped") +def step_assert_plain_escaped(context): + rendered = context._conv_stream.render() + assert "\\[red]plain\\[/]" in rendered, ( + f"expected escaped plain markup in render, got {rendered!r}" + ) + + +@then("the stream should have exactly {count:d} note block") +@then("the stream should have exactly {count:d} note blocks") +def step_assert_note_count(context, count): + notes = [b for b in context._conv_stream.blocks if b.block_type == "note"] + assert len(notes) == count, ( + f"expected {count} note blocks, got {len(notes)}: {notes!r}" + ) + + +def _temp_dir(context) -> Path: + if not hasattr(context, "_conv_tmp"): + context._conv_tmp = tempfile.mkdtemp(prefix="conv-settings-") + return Path(context._conv_tmp) + + +@when("I load conversation settings from a missing config path") +def step_load_missing(context): + missing = _temp_dir(context) / "absent.json" + context._loaded_settings = load_conversation_settings(missing) + + +@when("I load conversation settings from a malformed JSON config") +def step_load_malformed(context): + path = _temp_dir(context) / "malformed.json" + path.write_text("{not valid json", encoding="utf-8") + context._loaded_settings = load_conversation_settings(path) + + +@when( + "I load conversation settings from a config with low_mark {low:d} excess {excess:d}" +) +def step_load_valid(context, low, excess): + path = _temp_dir(context) / "valid.json" + payload = {"ui": {"prune_low_mark": low, "prune_excess": excess}} + path.write_text(json.dumps(payload), encoding="utf-8") + context._loaded_settings = load_conversation_settings(path) + + +@when("I load conversation settings from a config with non-numeric values") +def step_load_bad_numeric(context): + path = _temp_dir(context) / "non_numeric.json" + payload = {"ui": {"prune_low_mark": "not-a-number", "prune_excess": {}}} + path.write_text(json.dumps(payload), encoding="utf-8") + context._loaded_settings = load_conversation_settings(path) + + +@when("I load conversation settings from a list-valued JSON config") +def step_load_list_root(context): + path = _temp_dir(context) / "list.json" + path.write_text(json.dumps(["this", "is", "a", "list"]), encoding="utf-8") + context._loaded_settings = load_conversation_settings(path) + + +@then("loaded settings prune_low_mark should equal {expected:d}") +def step_assert_loaded_low(context, expected): + assert context._loaded_settings.prune_low_mark == expected, ( + f"expected loaded prune_low_mark={expected}, " + f"got {context._loaded_settings.prune_low_mark}" + ) + + +@then("loaded settings prune_excess should equal {expected:d}") +def step_assert_loaded_excess(context, expected): + assert context._loaded_settings.prune_excess == expected, ( + f"expected loaded prune_excess={expected}, " + f"got {context._loaded_settings.prune_excess}" + ) diff --git a/features/tui_conversation_module_coverage.feature b/features/tui_conversation_module_coverage.feature new file mode 100644 index 000000000..754d91a75 --- /dev/null +++ b/features/tui_conversation_module_coverage.feature @@ -0,0 +1,132 @@ +Feature: TUI conversation module coverage + Directly exercises the cleveragents.tui.conversation module to cover + pruning hysteresis, settings clamping, persistence loading, and edge + cases that the high-level TUI scenarios do not reach. + + @tdd_issue @tdd_issue_6350 + Scenario: Default ConversationSettings derive trigger_line_count from low_mark and excess + When I build default ConversationSettings + Then conversation settings prune_low_mark should equal 1500 + And conversation settings prune_excess should equal 1000 + And conversation settings trigger_line_count should equal 2500 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationSettings clamps prune_low_mark below the minimum + When I build ConversationSettings with prune_low_mark 10 prune_excess 200 preserve_recent_lines 50 + Then conversation settings prune_low_mark should equal 100 + And conversation settings prune_excess should equal 200 + And conversation settings preserve_recent_lines should equal 50 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationSettings clamps prune_low_mark above the maximum + When I build ConversationSettings with prune_low_mark 99999 prune_excess 200 preserve_recent_lines 50 + Then conversation settings prune_low_mark should equal 10000 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationSettings clamps prune_excess below the minimum + When I build ConversationSettings with prune_low_mark 500 prune_excess 1 preserve_recent_lines 50 + Then conversation settings prune_excess should equal 10 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationSettings clamps prune_excess above the maximum + When I build ConversationSettings with prune_low_mark 500 prune_excess 99999 preserve_recent_lines 50 + Then conversation settings prune_excess should equal 5000 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationSettings clamps preserve_recent_lines below zero + When I build ConversationSettings with prune_low_mark 500 prune_excess 100 preserve_recent_lines -25 + Then conversation settings preserve_recent_lines should equal 0 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationBlock line_count counts trailing newlines and empty text + Then a conversation block with text "alpha" should have line count 1 + And a conversation block with text "alpha\nbeta" should have line count 2 + And a conversation block with empty text should have line count 0 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream bootstrap installs a protected welcome block + Given a fresh ConversationStream with default settings + When I bootstrap the stream with welcome text "Hello world" + Then the stream should have exactly 1 block + And the stream total_lines should be greater than zero + And the stream rendered text should contain "Hello world" + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream clear empties blocks and resets total_lines + Given a fresh ConversationStream with default settings + When I bootstrap the stream with welcome text "Hello" + And I clear the stream + Then the stream should have exactly 0 blocks + And the stream total_lines should be zero + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream extend appends multiple blocks + Given a fresh ConversationStream with default settings + When I extend the stream with 3 plain message blocks + Then the stream should have exactly 3 blocks + And the stream rendered text should contain "extend-block-2" + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream renders markup blocks verbatim and escapes plain blocks + Given a fresh ConversationStream with default settings + When I add a plain block "[red]plain[/]" to the stream + And I add a markup block "[red]styled[/]" to the stream + Then the stream rendered text should contain "[red]styled[/]" + And the stream rendered plain segment should be backslash escaped + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream skips empty-text blocks during render + Given a fresh ConversationStream with default settings + When I add an empty plain block to the stream + And I add a plain block "after-empty" to the stream + Then the stream rendered text should equal "after-empty" + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream pruning preserves protected blocks + Given a fresh ConversationStream with low_mark 200 excess 50 preserve_recent_lines 100 + When I bootstrap the stream with welcome text "Protected welcome" + And I deliver 4 plain blocks of 200 lines each through add_block + Then the stream rendered text should contain "Protected welcome" + And the stream total_lines should be less than or equal to 350 + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream pruning updates the note in place rather than re-inserting + Given a fresh ConversationStream with low_mark 200 excess 50 preserve_recent_lines 100 + When I deliver 8 plain blocks of 200 lines each through add_block + Then the stream should have exactly 1 note block + + @tdd_issue @tdd_issue_6350 + Scenario: ConversationStream pruning with preserve_recent_lines zero allows full prune + Given a fresh ConversationStream with low_mark 200 excess 50 preserve_recent_lines 0 + When I deliver 6 plain blocks of 200 lines each through add_block + Then the stream total_lines should be less than or equal to 250 + + @tdd_issue @tdd_issue_6350 + Scenario: load_conversation_settings returns defaults when the config file is absent + When I load conversation settings from a missing config path + Then loaded settings prune_low_mark should equal 1500 + And loaded settings prune_excess should equal 1000 + + @tdd_issue @tdd_issue_6350 + Scenario: load_conversation_settings returns defaults when the config file is malformed JSON + When I load conversation settings from a malformed JSON config + Then loaded settings prune_low_mark should equal 1500 + And loaded settings prune_excess should equal 1000 + + @tdd_issue @tdd_issue_6350 + Scenario: load_conversation_settings honours explicit ui values + When I load conversation settings from a config with low_mark 800 excess 400 + Then loaded settings prune_low_mark should equal 800 + And loaded settings prune_excess should equal 400 + + @tdd_issue @tdd_issue_6350 + Scenario: load_conversation_settings returns defaults when ui values are non-numeric + When I load conversation settings from a config with non-numeric values + Then loaded settings prune_low_mark should equal 1500 + And loaded settings prune_excess should equal 1000 + + @tdd_issue @tdd_issue_6350 + Scenario: load_conversation_settings returns defaults when payload is not a dict + When I load conversation settings from a list-valued JSON config + Then loaded settings prune_low_mark should equal 1500 + And loaded settings prune_excess should equal 1000