codeplain 0.3.10.dev4__py3-none-any.whl → 0.3.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ """Regression tests for issue #133.
2
+
3
+ When git is unusable the CLI must report a clean message and exit 1, never dump
4
+ a traceback. The failure is at import time (GitPython raises from module scope
5
+ before main() runs), so the CLI is run in a subprocess and asserted on what a
6
+ user would actually see. sys.executable is launched by absolute path, so
7
+ replacing PATH only controls which git, if any, can be found.
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ from git_preflight import GIT_BROKEN_MESSAGE, GIT_MISSING_MESSAGE
19
+
20
+ REPO_ROOT = Path(__file__).resolve().parents[1]
21
+ TRACEBACK_MARKER = "Traceback (most recent call last)"
22
+
23
+ # The crash was in the import chain, so it hit every entry point equally.
24
+ CLI_INVOCATIONS = [["--version"], ["--status"], ["does-not-exist.plain"]]
25
+
26
+
27
+ def run_cli(args, path_dir=None):
28
+ """Run the CLI and return its exit code and output, stripped of styling and
29
+ with whitespace collapsed, so assertions survive console line wrapping."""
30
+ env = os.environ.copy()
31
+ # Drop GitPython overrides so the PATH search decides whether git is found.
32
+ env.pop("GIT_PYTHON_GIT_EXECUTABLE", None)
33
+ env.pop("GIT_PYTHON_REFRESH", None)
34
+ env.setdefault("CODEPLAIN_API_KEY", "dummy")
35
+ if path_dir is not None:
36
+ env["PATH"] = str(path_dir)
37
+
38
+ result = subprocess.run(
39
+ [sys.executable, "plain2code.py", *args],
40
+ cwd=REPO_ROOT,
41
+ env=env,
42
+ capture_output=True,
43
+ text=True,
44
+ timeout=120,
45
+ )
46
+ output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout + result.stderr)
47
+ return result.returncode, " ".join(output.split())
48
+
49
+
50
+ @pytest.fixture
51
+ def no_git_dir(tmp_path):
52
+ """A directory containing no git at all, used as the whole PATH."""
53
+ d = tmp_path / "no-git"
54
+ d.mkdir()
55
+ return d
56
+
57
+
58
+ @pytest.fixture
59
+ def broken_git_dir(tmp_path):
60
+ """A directory whose git resolves but always fails, used as the whole PATH.
61
+ This is the shape of macOS without the Command Line Tools."""
62
+ d = tmp_path / "broken-git"
63
+ d.mkdir()
64
+ if sys.platform == "win32":
65
+ (d / "git.bat").write_text("@echo off\nexit /b 127\n")
66
+ else:
67
+ git = d / "git"
68
+ git.write_text("#!/bin/sh\nexit 127\n")
69
+ git.chmod(0o755)
70
+ return d
71
+
72
+
73
+ @pytest.mark.parametrize("args", CLI_INVOCATIONS)
74
+ def test_missing_git_reports_cleanly(args, no_git_dir):
75
+ rc, output = run_cli(args, no_git_dir)
76
+
77
+ assert GIT_MISSING_MESSAGE in output, output
78
+ assert TRACEBACK_MARKER not in output, output
79
+ # GitPython's own advice about GIT_PYTHON_REFRESH must not reach the user.
80
+ assert "GIT_PYTHON_REFRESH" not in output, output
81
+ assert rc == 1, output
82
+
83
+
84
+ @pytest.mark.parametrize("args", CLI_INVOCATIONS)
85
+ def test_broken_git_reports_cleanly(args, broken_git_dir):
86
+ rc, output = run_cli(args, broken_git_dir)
87
+
88
+ assert GIT_BROKEN_MESSAGE in output, output
89
+ assert GIT_MISSING_MESSAGE not in output, output
90
+ assert TRACEBACK_MARKER not in output, output
91
+ assert rc == 1, output
92
+
93
+
94
+ def test_cli_works_when_git_is_present():
95
+ rc, output = run_cli(["--version"])
96
+
97
+ assert rc == 0, output
98
+ assert "codeplain version" in output, output
@@ -146,14 +146,30 @@ def test_invalid_absolute_link(get_test_data_path):
146
146
  )
147
147
 
148
148
 
149
+ def test_missing_link_error_lists_searched_directories(get_test_data_path):
150
+ """The error tells the user which directories were searched for the linked resource."""
151
+ plain_file_dir = get_test_data_path("data/plainfile")
152
+
153
+ with pytest.raises(PlainSyntaxError) as exception_info:
154
+ plain_file.plain_file_parser(
155
+ "plain_source_with_missing_link.plain",
156
+ [plain_file_dir],
157
+ )
158
+
159
+ message = str(exception_info.value)
160
+ assert "resources/missing_resource.yaml does not exist" in message
161
+ assert "highest to lowest precedence" in message
162
+ assert plain_file_dir in message
163
+
164
+
149
165
  def test_reference_link_parsing(get_test_data_path):
150
166
  _, plain_sections, _ = plain_file.plain_file_parser(
151
167
  "task_manager_with_reference_links.plain",
152
168
  [get_test_data_path("data/plainfile")],
153
169
  )
154
170
  asserted_resources = [
155
- "tests/data/plainfile/task_list_ui_specification.yaml",
156
- "tests/data/plainfile/add_new_task_modal_specification.yaml",
171
+ "task_list_ui_specification.yaml",
172
+ "add_new_task_modal_specification.yaml",
157
173
  ]
158
174
  for functional_requirement in plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS]:
159
175
  if "linked_resources" not in functional_requirement:
@@ -180,13 +196,36 @@ def test_reference_link_parsing(get_test_data_path):
180
196
  assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][1]["linked_resources"] == [
181
197
  {
182
198
  "text": "task_list_ui_specification.yaml",
183
- "target": "tests/data/plainfile/task_list_ui_specification.yaml",
199
+ "target": "task_list_ui_specification.yaml",
200
+ }
201
+ ]
202
+ assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][2]["linked_resources"] == [
203
+ {
204
+ "text": "add_new_task_modal_specification.yaml",
205
+ "target": "add_new_task_modal_specification.yaml",
206
+ }
207
+ ]
208
+
209
+
210
+ def test_reference_link_parsing_independent_of_working_directory(get_test_data_path, monkeypatch, tmp_path):
211
+ """Linked resources resolve against the plain file's directory, not the invocation cwd."""
212
+ monkeypatch.chdir(tmp_path)
213
+
214
+ _, plain_sections, _ = plain_file.plain_file_parser(
215
+ "task_manager_with_reference_links.plain",
216
+ [get_test_data_path("data/plainfile")],
217
+ )
218
+
219
+ assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][1]["linked_resources"] == [
220
+ {
221
+ "text": "task_list_ui_specification.yaml",
222
+ "target": "task_list_ui_specification.yaml",
184
223
  }
185
224
  ]
186
225
  assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][2]["linked_resources"] == [
187
226
  {
188
227
  "text": "add_new_task_modal_specification.yaml",
189
- "target": "tests/data/plainfile/add_new_task_modal_specification.yaml",
228
+ "target": "add_new_task_modal_specification.yaml",
190
229
  }
191
230
  ]
192
231
 
@@ -632,6 +671,11 @@ def test_concept_validation_acceptance_tests(get_test_data_path):
632
671
  )
633
672
 
634
673
 
674
+ def test_concept_validation_cyclic_definitions(get_test_data_path):
675
+ with pytest.raises(PlainSyntaxError, match="cycles in the concept graph"):
676
+ plain_file.plain_file_parser("cyclic_definitions.plain", [get_test_data_path("data/plainfileparser")])
677
+
678
+
635
679
  def test_required_concepts(get_test_data_path):
636
680
  plain_file.plain_file_parser(
637
681
  "required_concepts_example.plain",
@@ -661,28 +705,44 @@ def test_required_concepts(get_test_data_path):
661
705
  )
662
706
 
663
707
 
664
- def test_exported_concepts(get_test_data_path):
665
- plain_file.plain_file_parser(
708
+ @pytest.mark.parametrize(
709
+ "fixture",
710
+ [
666
711
  "exported_concepts_example.plain",
667
- [get_test_data_path("data/plainfileparser")],
668
- )
669
-
670
- plain_file.plain_file_parser(
712
+ "exported_concepts_imported_exported.plain",
713
+ "exported_concepts_multiple_per_bullet.plain",
671
714
  "exported_concepts_nested_example.plain",
715
+ ],
716
+ )
717
+ def test_exported_concepts_valid(get_test_data_path, fixture):
718
+ plain_file.plain_file_parser(fixture, [get_test_data_path("data/plainfileparser")])
719
+
720
+
721
+ @pytest.mark.parametrize(
722
+ ("fixture", "expected_word"),
723
+ [
724
+ ("exported_concepts_defaults_exported.plain", "cannot export default concept"),
725
+ ("exported_concepts_inherited_reexported.plain", "cannot be re-exported"),
726
+ ("exported_concepts_malformed.plain", "exports an invalid concept"),
727
+ ("exported_concepts_missing_definition_example.plain", "'exported_concepts_missing_definition' exports"),
728
+ ("exported_concepts_missing_definition.plain", "exports undefined concept"),
729
+ ("exported_concepts_multiple_per_bullet_undefined.plain", "exports undefined concept"),
730
+ ("exported_concepts_no_definitions.plain", "exports undefined concept"),
731
+ ("exported_concepts_not_declared_example.plain", "not defined"),
732
+ ("exported_concepts_transitive_example.plain", "not defined"),
733
+ ],
734
+ )
735
+ def test_exported_concepts_invalid(get_test_data_path, fixture, expected_word):
736
+ with pytest.raises(PlainSyntaxError, match=expected_word):
737
+ plain_file.plain_file_parser(fixture, [get_test_data_path("data/plainfileparser")])
738
+
739
+
740
+ def test_requires_without_definitions(get_test_data_path):
741
+ _, plain_source, _ = plain_file.plain_file_parser(
742
+ "requires_without_definitions_example.plain",
672
743
  [get_test_data_path("data/plainfileparser")],
673
744
  )
674
-
675
- with pytest.raises(PlainSyntaxError):
676
- plain_file.plain_file_parser(
677
- "exported_concepts_missing_example.plain",
678
- [get_test_data_path("data/plainfileparser")],
679
- )
680
-
681
- with pytest.raises(PlainSyntaxError):
682
- plain_file.plain_file_parser(
683
- "exported_concepts_transitive_example.plain",
684
- [get_test_data_path("data/plainfileparser")],
685
- )
745
+ assert plain_source[plain_spec.DEFINITIONS] == [{"markdown": "- :Concept: is a concept."}]
686
746
 
687
747
 
688
748
  def test_topological_sort(get_test_data_path):
@@ -0,0 +1,200 @@
1
+ """Tests for reporting a render's terminal state to the API.
2
+
3
+ The render state machine lives in the client, so the API only learns how a
4
+ render ended because main() tells it. Two things are worth proving: that the
5
+ outcome names the right ending, and that main() reports on every path out -
6
+ including the ones the state machine never sees, like a keyboard interrupt.
7
+ """
8
+
9
+ from argparse import Namespace
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+
14
+ import plain2code
15
+ from plain2code_exceptions import InvalidAPIKey
16
+ from plain2code_state import RunState
17
+
18
+
19
+ def make_run_state(succeeded=False, cancelled=False, user_email="user@codeplain.ai"):
20
+ run_state = RunState(spec_filename="project.plain")
21
+ run_state.set_render_succeeded(succeeded)
22
+ if cancelled:
23
+ run_state.set_render_cancelled()
24
+ run_state.user_email = user_email
25
+ return run_state
26
+
27
+
28
+ class TestRenderOutcome:
29
+ def test_a_successful_render_is_completed(self):
30
+ assert plain2code._render_outcome(make_run_state(succeeded=True)) == "completed"
31
+
32
+ def test_an_unsuccessful_render_is_failed(self):
33
+ assert plain2code._render_outcome(make_run_state(succeeded=False)) == "failed"
34
+
35
+ def test_a_cancelled_render_is_cancelled(self):
36
+ """A cancelled render leaves render_succeeded False, so cancellation has to
37
+ be asked about first or it would be reported as a failure."""
38
+ assert plain2code._render_outcome(make_run_state(cancelled=True)) == "cancelled"
39
+
40
+ def test_cancellation_wins_over_a_success_already_recorded(self):
41
+ """Cancelling during the dist step leaves both flags set."""
42
+ assert plain2code._render_outcome(make_run_state(succeeded=True, cancelled=True)) == "cancelled"
43
+
44
+ def test_an_interrupted_render_is_cancelled(self):
45
+ """Ctrl-C is the user stopping the render. It does not set the cancelled
46
+ flag, so the outcome has to be told about it."""
47
+ assert plain2code._render_outcome(make_run_state(), interrupted=True) == "cancelled"
48
+
49
+
50
+ class TestReportRenderFinished:
51
+ def test_reports_the_outcome_with_the_render_state(self):
52
+ api = MagicMock()
53
+ run_state = make_run_state(succeeded=True)
54
+
55
+ plain2code.report_render_finished(api, run_state)
56
+
57
+ api.render_finished.assert_called_once_with("completed", run_state)
58
+
59
+ def test_says_nothing_when_the_api_never_identified_us(self):
60
+ """user_email is set by the connection check, so an absent one means a
61
+ missing key, an invalid key or an outdated client - none of which rendered."""
62
+ api = MagicMock()
63
+
64
+ plain2code.report_render_finished(api, make_run_state(user_email=None))
65
+
66
+ api.render_finished.assert_not_called()
67
+
68
+ @pytest.mark.parametrize(
69
+ "failure",
70
+ [
71
+ ConnectionError("api unreachable"),
72
+ TimeoutError("api slow"),
73
+ RuntimeError("something else entirely"),
74
+ ],
75
+ )
76
+ def test_a_failed_report_never_propagates(self, failure):
77
+ """The report is the last thing a render does. It must not become the
78
+ reason the render looks like it failed."""
79
+ api = MagicMock()
80
+ api.render_finished.side_effect = failure
81
+
82
+ plain2code.report_render_finished(api, make_run_state(succeeded=True))
83
+
84
+
85
+ def make_args(api_key="test-key", headless=False):
86
+ return Namespace(
87
+ version=False,
88
+ status=False,
89
+ full_plain=False,
90
+ dry_run=False,
91
+ api_key=api_key,
92
+ api="https://api.example.com",
93
+ filename="project.plain",
94
+ template_dir=None,
95
+ build_folder="build",
96
+ render_range=None,
97
+ render_from=None,
98
+ headless=headless,
99
+ replay_with=None,
100
+ log_to_file=False,
101
+ log_file_name="codeplain.log",
102
+ conformance_tests_script=None,
103
+ unittests_script=None,
104
+ prepare_environment_script=None,
105
+ )
106
+
107
+
108
+ def run_main(render_side_effect=None, api_key="test-key"):
109
+ """Drive main() with everything but the reporting path stubbed out.
110
+
111
+ render() is replaced, so the stub is what decides how the render "ended" -
112
+ which is the point: the report must be made from main()'s finally whatever
113
+ render did, not from inside the state machine.
114
+
115
+ Returns the API client mock so the caller can inspect the report.
116
+ """
117
+ api = MagicMock()
118
+
119
+ def fake_render(plain_module, codeplainAPI, args, run_state, event_bus, default_log_level="INFO"):
120
+ run_state.user_email = "user@codeplain.ai"
121
+ if render_side_effect is not None:
122
+ render_side_effect(run_state)
123
+
124
+ with (
125
+ patch("plain2code.parse_arguments", return_value=make_args(api_key=api_key)),
126
+ patch("plain2code.file_utils.get_template_directories", return_value=["templates"]),
127
+ patch("plain2code.plain_modules.PlainModule", return_value=MagicMock(all_required_modules=[])),
128
+ patch("plain2code.setup_logging", return_value="INFO"),
129
+ patch("plain2code.initialize_telemetry"),
130
+ patch("plain2code.stop_narrating"),
131
+ patch("plain2code.print_exit_summary"),
132
+ patch("plain2code.dump_crash_logs"),
133
+ patch("plain2code.capture_crash"),
134
+ patch("plain2code.codeplain_api.CodeplainAPI", return_value=api),
135
+ patch("plain2code.render", side_effect=fake_render),
136
+ ):
137
+ plain2code.main()
138
+
139
+ return api
140
+
141
+
142
+ class TestMainReportsEveryEnding:
143
+ """main()'s finally is the only place that sees all of these."""
144
+
145
+ def test_a_completed_render_is_reported(self):
146
+ api = run_main(lambda run_state: run_state.set_render_succeeded(True))
147
+
148
+ assert api.render_finished.call_args.args[0] == "completed"
149
+
150
+ def test_a_failed_render_is_reported(self):
151
+ def fail(run_state):
152
+ run_state.set_render_succeeded(False)
153
+ raise Exception("rendering blew up")
154
+
155
+ api = run_main(fail)
156
+
157
+ assert api.render_finished.call_args.args[0] == "failed"
158
+
159
+ def test_an_expected_error_is_reported_as_a_failure(self):
160
+ def fail(run_state):
161
+ raise InvalidAPIKey("key went stale mid-render")
162
+
163
+ api = run_main(fail)
164
+
165
+ assert api.render_finished.call_args.args[0] == "failed"
166
+
167
+ def test_a_cancelled_render_is_reported(self):
168
+ api = run_main(lambda run_state: run_state.set_render_cancelled())
169
+
170
+ assert api.render_finished.call_args.args[0] == "cancelled"
171
+
172
+ def test_a_keyboard_interrupt_is_reported_as_cancelled(self):
173
+ """Ctrl-C never enters a terminal state in the state machine, so this
174
+ ending exists only in main(). In headless mode it is the only way to stop
175
+ a render, and stopping is a cancellation, not a failure."""
176
+
177
+ def interrupt(run_state):
178
+ raise KeyboardInterrupt()
179
+
180
+ api = run_main(interrupt)
181
+
182
+ assert api.render_finished.call_args.args[0] == "cancelled"
183
+
184
+ def test_a_keyboard_interrupt_leaves_the_exit_summary_alone(self):
185
+ """The summary reads render_cancelled. Only the reported outcome changes."""
186
+ captured = {}
187
+
188
+ def interrupt(run_state):
189
+ captured["run_state"] = run_state
190
+ raise KeyboardInterrupt()
191
+
192
+ run_main(interrupt)
193
+
194
+ assert captured["run_state"].render_cancelled is False
195
+
196
+ def test_a_missing_api_key_reports_nothing(self):
197
+ """render() is never reached, so nothing identified the caller."""
198
+ api = run_main(api_key=None)
199
+
200
+ api.render_finished.assert_not_called()
@@ -0,0 +1,147 @@
1
+ """Regression tests: spec-derived text with square brackets must never be parsed as Textual markup."""
2
+
3
+ import asyncio
4
+
5
+ from textual.widgets import Static
6
+
7
+ from event_bus import EventBus
8
+ from plain2code_state import RunState
9
+ from tui.components import FRIDProgress, ProgressItem, RenderingInfoBox, SubstateLine, TUIComponents
10
+ from tui.models import Substate
11
+ from tui.plain2code_tui import Plain2CodeTUI
12
+ from tui.widget_helpers import display_error_message, display_success_message, update_progress_item_substates
13
+
14
+ # Exact acceptance-test text that crashed the TUI with
15
+ # "MarkupError: closing tag '[/#888888]' does not match any open tag":
16
+ # the unterminated "[b" swallowed the timer's opening tag.
17
+ UNTERMINATED_BRACKET_TEXT = (
18
+ "Rendering acceptance test: With a=1, b=2, c=3, d=4: ZRANK z c should reply 2, "
19
+ 'ZREVRANK z c WITHSCORE should reply [1, "3"], ZRANK z zz should reply null, '
20
+ "ZCOUNT z (1 3 should reply 2, and ZLEXCOUNT z [b + should reply 3."
21
+ )
22
+ # Terminated brackets used to be silently eaten as a markup tag ("LPOP key ").
23
+ TERMINATED_BRACKET_TEXT = "LPOP key [count] and RPOP key [count] reply one element"
24
+
25
+
26
+ def _make_app(run_state: RunState, event_bus: EventBus) -> Plain2CodeTUI:
27
+ return Plain2CodeTUI(
28
+ event_bus=event_bus,
29
+ run_state=run_state,
30
+ on_ready=lambda: None,
31
+ render_id="test-render-id",
32
+ unittests_script=None,
33
+ conformance_tests_script="run_conformance_tests.sh",
34
+ prepare_environment_script=None,
35
+ state_machine_version="0.0.0",
36
+ css_path="styles.css",
37
+ )
38
+
39
+
40
+ def test_substate_line_keeps_bracket_text_verbatim():
41
+ for text in (UNTERMINATED_BRACKET_TEXT, TERMINATED_BRACKET_TEXT):
42
+ line = SubstateLine(text, " ", ProgressItem.PROCESSING)._format_line()
43
+ assert text in line.plain
44
+ assert line.plain.endswith("(0s)")
45
+ # Only the timer carries the muted style; the text itself is unstyled.
46
+ assert len(line.spans) == 1
47
+ assert line.plain[line.spans[0].start : line.spans[0].end] == "(0s)"
48
+
49
+
50
+ def test_substate_with_brackets_renders_without_markup_error():
51
+ async def scenario():
52
+ event_bus = EventBus()
53
+ run_state = RunState(spec_filename="x.plain")
54
+ app = _make_app(run_state, event_bus)
55
+ async with app.run_test() as pilot:
56
+ widget_id = TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value
57
+ update_progress_item_substates(
58
+ app, widget_id, [Substate(UNTERMINATED_BRACKET_TEXT), Substate(TERMINATED_BRACKET_TEXT)]
59
+ )
60
+ await pilot.pause()
61
+ await pilot.pause()
62
+
63
+ lines = app.query(".substate-line-text").results(Static)
64
+ rendered = [str(line.content) for line in lines]
65
+ assert any(UNTERMINATED_BRACKET_TEXT in text for text in rendered)
66
+ assert any(TERMINATED_BRACKET_TEXT in text for text in rendered)
67
+
68
+ asyncio.run(scenario())
69
+
70
+
71
+ def test_status_messages_keep_brackets():
72
+ async def scenario():
73
+ event_bus = EventBus()
74
+ run_state = RunState(spec_filename="x.plain")
75
+ app = _make_app(run_state, event_bus)
76
+ async with app.run_test() as pilot:
77
+ status = app.query_one(f"#{TUIComponents.RENDER_STATUS_WIDGET.value}", Static)
78
+
79
+ display_success_message(app, "out/[weird] path")
80
+ await pilot.pause()
81
+ assert "rendering completed!" in str(status.content)
82
+ assert "generated code folder: out/[weird] path" in str(status.content)
83
+
84
+ display_error_message(app, "Error: [Errno 2] No such file: run_tests.sh [b")
85
+ await pilot.pause()
86
+ assert "[Errno 2] No such file: run_tests.sh [b" in str(status.content)
87
+
88
+ asyncio.run(scenario())
89
+
90
+
91
+ # A functionality quote as the render TUI receives it: several lines, square brackets included.
92
+ MULTILINE_FUNCTIONALITY_TEXT = (
93
+ "Functionality 3: :User: should be able to add a :Task: [optional]\n"
94
+ " - The :Task: must have non-empty content.\n"
95
+ " - The :Task: is appended to the end of the list."
96
+ )
97
+
98
+
99
+ def test_functionality_text_collapses_and_expands_with_ctrl_o():
100
+ async def scenario():
101
+ event_bus = EventBus()
102
+ run_state = RunState(spec_filename="x.plain")
103
+ app = _make_app(run_state, event_bus)
104
+ async with app.run_test() as pilot:
105
+ frid_progress = app.query_one(f"#{TUIComponents.FRID_PROGRESS.value}", FRIDProgress)
106
+ info_box = frid_progress.query_one(RenderingInfoBox)
107
+ info_box.update_functionality(MULTILINE_FUNCTIONALITY_TEXT)
108
+ await pilot.pause()
109
+
110
+ widget = info_box.functionality_widget
111
+ assert widget is not None
112
+
113
+ # Collapsed by default: first line only, with the expand hint.
114
+ collapsed = str(widget.content)
115
+ assert "Functionality 3: :User: should be able to add a :Task: [optional]" in collapsed
116
+ assert "non-empty content" not in collapsed
117
+ assert RenderingInfoBox.EXPAND_HINT in collapsed
118
+
119
+ await pilot.press("ctrl+o")
120
+ await pilot.pause()
121
+ expanded = str(widget.content)
122
+ assert "non-empty content" in expanded
123
+ assert "appended to the end of the list" in expanded
124
+ assert RenderingInfoBox.COLLAPSE_HINT in expanded
125
+
126
+ await pilot.press("ctrl+o")
127
+ await pilot.pause()
128
+ assert str(widget.content) == collapsed
129
+
130
+ asyncio.run(scenario())
131
+
132
+
133
+ def test_single_line_functionality_text_has_no_hint():
134
+ async def scenario():
135
+ event_bus = EventBus()
136
+ run_state = RunState(spec_filename="x.plain")
137
+ app = _make_app(run_state, event_bus)
138
+ async with app.run_test() as pilot:
139
+ info_box = app.query_one(f"#{TUIComponents.FRID_PROGRESS.value}", FRIDProgress).query_one(RenderingInfoBox)
140
+ info_box.update_functionality("Functionality 1: :User: should be able to add a :Task:")
141
+ await pilot.pause()
142
+
143
+ widget = info_box.functionality_widget
144
+ assert widget is not None
145
+ assert str(widget.content) == "Functionality 1: :User: should be able to add a :Task:"
146
+
147
+ asyncio.run(scenario())