test(tui): add direct Behave coverage for conversation pruning module
CI / push-validation (pull_request) Successful in 24s
CI / helm (pull_request) Successful in 30s
CI / lint (pull_request) Successful in 39s
CI / build (pull_request) Successful in 36s
CI / quality (pull_request) Successful in 1m0s
CI / typecheck (pull_request) Successful in 1m10s
CI / security (pull_request) Successful in 1m19s
CI / unit_tests (pull_request) Successful in 5m52s
CI / docker (pull_request) Successful in 1m54s
CI / coverage (pull_request) Successful in 11m53s
CI / integration_tests (pull_request) Successful in 26m4s
CI / status-check (pull_request) Successful in 3s

Adds 20 scenarios that exercise ConversationSettings clamping,
ConversationBlock line counting, ConversationStream bootstrap / clear /
extend / render / pruning behaviour, and load_conversation_settings
fallback paths directly. The pre-existing single Behave scenario
covered ConversationStream only through the TUI app's
_append_conversation_block, leaving most of the new conversation
module unmeasured and pulling overall coverage below the 97%
threshold (the lone failing CI gate).

ISSUES CLOSED: #6350
This commit is contained in:
2026-05-31 21:41:50 -04:00
parent 8d91b65ee8
commit d36dfdcc86
2 changed files with 416 additions and 0 deletions
@@ -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}"
)
@@ -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