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
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:
@@ -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
|
||||
Reference in New Issue
Block a user