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.
- cli_output/status.py +13 -8
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/METADATA +4 -4
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/RECORD +27 -21
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/WHEEL +1 -1
- codeplain_REST_api.py +32 -1
- file_utils.py +14 -0
- git_preflight.py +41 -0
- plain2code.py +76 -5
- plain2code_console.py +5 -4
- plain_file.py +51 -18
- plain_modules.py +6 -6
- render_machine/actions/fix_conformance_test.py +11 -0
- render_machine/actions/fix_unit_tests.py +25 -0
- render_machine/render_types.py +5 -0
- tests/test_cli_output.py +15 -8
- tests/test_console_log_styles.py +61 -5
- tests/test_dry_run_link_validation.py +60 -0
- tests/test_fix_unit_tests_conformance_context.py +230 -0
- tests/test_git_preflight.py +98 -0
- tests/test_plainfileparser.py +82 -22
- tests/test_render_finished_report.py +200 -0
- tests/test_tui_components.py +147 -0
- tui/components.py +37 -10
- tui/plain2code_tui.py +9 -0
- tui/widget_helpers.py +11 -4
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
tests/test_plainfileparser.py
CHANGED
|
@@ -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
|
-
"
|
|
156
|
-
"
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
665
|
-
|
|
708
|
+
@pytest.mark.parametrize(
|
|
709
|
+
"fixture",
|
|
710
|
+
[
|
|
666
711
|
"exported_concepts_example.plain",
|
|
667
|
-
|
|
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())
|