From ef970e5123a9960ef9e3500143525c97050f5e36 Mon Sep 17 00:00:00 2001 From: Radha Prajapath Date: Sat, 22 Aug 2026 13:54:10 +0530 Subject: [PATCH 1/2] discussion_auto_submit --- pythonlings/core/exercise.py | 6 ++ pythonlings/screens/track.py | 54 +++++++++++++++ pythonlings/screens/welcome.py | 3 +- pythonlings/widgets/editor_pane.py | 4 ++ pythonlings/widgets/output_panel.py | 18 ++++- tests/tui/test_app_pilot.py | 100 ++++++++++++++++++++++++++++ tests/tui/test_output_panel.py | 23 ++++++- tests/unit/test_exercise.py | 18 +++++ 8 files changed, 221 insertions(+), 5 deletions(-) diff --git a/pythonlings/core/exercise.py b/pythonlings/core/exercise.py index 419d41c..4f7bc2e 100644 --- a/pythonlings/core/exercise.py +++ b/pythonlings/core/exercise.py @@ -22,6 +22,12 @@ class Exercise: def is_pending(self) -> bool: return self.DONE_MARKER in self.path.read_text(encoding="utf-8") + def strip_marker(self, text: str) -> str: + """Remove the '# I AM NOT DONE' marker line, if present as its own line.""" + lines = text.splitlines(keepends=True) + kept = [line for line in lines if line.strip() != self.DONE_MARKER] + return "".join(kept) + @dataclass class RunResult: diff --git a/pythonlings/screens/track.py b/pythonlings/screens/track.py index fce440d..21023f5 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -18,6 +18,7 @@ from pythonlings.widgets.progress import ProgressBar _DEBOUNCE_SECONDS = 0.6 +_AUTO_ADVANCE_SECONDS = 5 _FULL_FOOTER_WIDTH = 70 @@ -76,6 +77,10 @@ def __init__(self, topic: str, start_exercise: str | None = None) -> None: self.topic = topic self._start_exercise = start_exercise self._save_timer: Timer | None = None + self._advance_timer: Timer | None = None + self._advance_remaining = 0 + self._advance_exercise: Exercise | None = None + self._advance_text = "" self._loaded_text = "" self._failure_counts: dict[str, int] = {} self.current: str | None = None @@ -149,6 +154,7 @@ def _load_current(self) -> None: if self._save_timer is not None: self._save_timer.stop() self._save_timer = None + self._stop_advance_timer() self.query_one(OutputPanel).reset_hint() pane = self.query_one(EditorPane) pane.load_exercise(self._exercise(self.current)) @@ -167,6 +173,7 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None: return if self.query_one(EditorPane).text == self._loaded_text: return + self._stop_advance_timer() if self._save_timer is not None: self._save_timer.stop() self._save_timer = self.set_timer(_DEBOUNCE_SECONDS, self._flush_and_run) @@ -204,6 +211,8 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: ) else: self._failure_counts[exercise.name] = 0 + checks_ok = result.exit_code == 0 and not result.timed_out + auto_advance = checks_ok and exercise.is_pending() completed, total = self._progress_counts() self.query_one(OutputPanel).render_result( exercise, @@ -211,7 +220,12 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: failures=self._failure_counts.get(exercise.name, 0), completed=completed, total=total, + auto_advance_seconds=_AUTO_ADVANCE_SECONDS if auto_advance else None, ) + if auto_advance: + checked_text = self.query_one(EditorPane).text + self._schedule_auto_advance(exercise, checked_text) + return if not result.passed: return self.app.state.mark_done(exercise.name) @@ -233,6 +247,45 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: self._load_current() self._run_current() + def _schedule_auto_advance(self, exercise: Exercise, checked_text: str) -> None: + self._stop_advance_timer() + self._advance_exercise = exercise + self._advance_text = checked_text + self._advance_remaining = _AUTO_ADVANCE_SECONDS + self._advance_timer = self.set_interval(1.0, self._tick_advance) + + def _stop_advance_timer(self) -> None: + if self._advance_timer is not None: + self._advance_timer.stop() + self._advance_timer = None + self._advance_exercise = None + self._advance_text = "" + + def _tick_advance(self) -> None: + self._advance_remaining -= 1 + exercise, checked_text = self._advance_exercise, self._advance_text + if self._advance_remaining <= 0: + self._stop_advance_timer() + if exercise is not None: + self._auto_advance(exercise, checked_text) + return + if self.is_attached: + self.query_one(OutputPanel).update_countdown(self._advance_remaining) + + def _auto_advance(self, exercise: Exercise, checked_text: str) -> None: + if not self.is_attached or exercise.name != self.current: + return + pane = self.query_one(EditorPane) + if pane.text != checked_text: + return # the learner resumed editing; let the normal loop take over + stripped = exercise.strip_marker(checked_text) + if stripped == checked_text: + return + self._loaded_text = stripped + pane.set_text(stripped) + exercise.path.write_text(stripped, encoding="utf-8") + self._run_current() + # --- actions --------------------------------------------------------- def action_toggle_hint(self) -> None: @@ -276,6 +329,7 @@ def action_quit(self) -> None: self.app.exit(0) def _flush_pending(self) -> None: + self._stop_advance_timer() if self._save_timer is not None: self._save_timer.stop() self._save_timer = None diff --git a/pythonlings/screens/welcome.py b/pythonlings/screens/welcome.py index 2cf7bd1..2b408ca 100644 --- a/pythonlings/screens/welcome.py +++ b/pythonlings/screens/welcome.py @@ -16,7 +16,8 @@ def welcome_text() -> str: "You learn Python here by fixing small broken programs. The loop is:\n\n" " 1. Edit the current exercise in the built-in editor.\n" " 2. Checks rerun automatically as you type.\n" - " 3. Remove the `# I AM NOT DONE` marker to advance to the next one.\n\n" + " 3. Once checks pass, the `# I AM NOT DONE` marker is cleared and " + "you advance to the next one automatically.\n\n" "Handy keys: F1 hint - F3 exercise list - F4 topics - " "F5 local docs - Ctrl+Q quit.\n\n" "Press Enter to start." diff --git a/pythonlings/widgets/editor_pane.py b/pythonlings/widgets/editor_pane.py index 8308664..740a25c 100644 --- a/pythonlings/widgets/editor_pane.py +++ b/pythonlings/widgets/editor_pane.py @@ -31,3 +31,7 @@ def focus_editor(self) -> None: @property def text(self) -> str: return self.query_one("#code", TextArea).text + + def set_text(self, text: str) -> None: + """Replace the editor contents without touching the cursor position.""" + self.query_one("#code", TextArea).text = text diff --git a/pythonlings/widgets/output_panel.py b/pythonlings/widgets/output_panel.py index 2e0c573..7bb47e7 100644 --- a/pythonlings/widgets/output_panel.py +++ b/pythonlings/widgets/output_panel.py @@ -60,6 +60,7 @@ def render_result( failures: int = 0, completed: int = 0, total: int = 0, + auto_advance_seconds: float | None = None, ) -> None: self._render_header(exercise, completed, total) self.query_one("#goal", Static).update( @@ -98,11 +99,14 @@ def render_result( if exercise.is_pending(): self.add_class("pending") self.query_one("#status", Static).update( - "[bold yellow]Checks pass, remove marker[/bold yellow]" + "[bold green]✓ Checks pass[/bold green]" ) - self.query_one("#next-step", Static).update( - "Remove the # I AM NOT DONE line to advance." + next_step = ( + self._advance_message(auto_advance_seconds) + if auto_advance_seconds + else "Remove the # I AM NOT DONE line to advance." ) + self.query_one("#next-step", Static).update(next_step) return self.add_class("passed") self.query_one("#status", Static).update( @@ -112,6 +116,14 @@ def render_result( "Loading the next exercise." ) + def update_countdown(self, seconds: int) -> None: + """Tick the auto-advance countdown shown in the pending state.""" + self.query_one("#next-step", Static).update(self._advance_message(seconds)) + + @staticmethod + def _advance_message(seconds: float) -> str: + return f"Advancing to the next exercise in {seconds:.0f}s…" + def show_final(self, message: str) -> None: """Render the whole-curriculum-complete screen.""" self._show_complete("All exercises complete", message) diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index f055980..7ec012e 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -355,6 +355,106 @@ async def test_instant_advance_updates_resume_to_next_exercise( assert app.state.last_exercise == "a2" +@pytest.mark.asyncio +async def test_marker_pass_schedules_auto_advance(tmp_path: Path) -> None: + work = _work_copy(tmp_path) + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + track.query_one("#code", TextArea).text = "# I AM NOT DONE\nx = 1\n" + track._flush_and_run() + await _settle(pilot) + assert "a1" not in app.state.completed + assert track.current == "a1" + assert track._advance_timer is not None + assert ( + "advancing to the next exercise in 4s" + in track.query_one(OutputPanel).renderable_text().lower() + ) + + +@pytest.mark.asyncio +async def test_auto_advance_countdown_ticks_down_each_second( + tmp_path: Path, +) -> None: + work = _work_copy(tmp_path) + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + track.query_one("#code", TextArea).text = "# I AM NOT DONE\nx = 1\n" + track._flush_and_run() + await _settle(pilot) + assert track._advance_remaining == 4 + + track._tick_advance() + await pilot.pause() + assert track._advance_remaining == 3 + assert ( + "advancing to the next exercise in 3s" + in track.query_one(OutputPanel).renderable_text().lower() + ) + + track._tick_advance() + track._tick_advance() + await pilot.pause() + assert track._advance_remaining == 1 + assert "a1" not in app.state.completed + + track._tick_advance() # remaining hits 0: fires the auto-advance + await _settle(pilot) + assert "a1" in app.state.completed + assert track.current == "a2" + + +@pytest.mark.asyncio +async def test_auto_advance_fires_strips_marker_and_advances( + tmp_path: Path, +) -> None: + work = _work_copy(tmp_path) + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + checked_text = "# I AM NOT DONE\nx = 1\n" + track.query_one("#code", TextArea).text = checked_text + track._flush_and_run() + await _settle(pilot) + # Simulate the auto-advance timer firing rather than waiting on it. + track._auto_advance(track._exercise("a1"), checked_text) + await _settle(pilot) + assert "a1" in app.state.completed + assert track.current == "a2" + assert "# I AM NOT DONE" not in (work / "exercises" / "alpha" / "a1.py").read_text( + encoding="utf-8" + ) + + +@pytest.mark.asyncio +async def test_editing_during_countdown_cancels_auto_advance( + tmp_path: Path, +) -> None: + work = _work_copy(tmp_path) + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + track.query_one("#code", TextArea).text = "# I AM NOT DONE\nx = 1\n" + track._flush_and_run() + await _settle(pilot) + assert track._advance_timer is not None + track.query_one("#code", TextArea).text = "# I AM NOT DONE\nx = 2\n" + await _settle(pilot) + assert track._advance_timer is None + assert "a1" not in app.state.completed + assert track.current == "a1" + + @pytest.mark.asyncio async def test_failed_run_shows_progressive_nudge(tmp_path: Path) -> None: work = _work_copy(tmp_path) diff --git a/tests/tui/test_output_panel.py b/tests/tui/test_output_panel.py index 31f6ed1..8a8e204 100644 --- a/tests/tui/test_output_panel.py +++ b/tests/tui/test_output_panel.py @@ -114,7 +114,28 @@ async def test_marker_pass_prompts_marker_removal(tmp_path: Path) -> None: ex, _result(0, stdout="ok"), failures=0, completed=0, total=2 ) await pilot.pause() - assert "remove marker" in panel.renderable_text().lower() + assert "remove the # i am not done" in panel.renderable_text().lower() + + +@pytest.mark.asyncio +async def test_marker_pass_with_auto_advance_shows_countdown(tmp_path: Path) -> None: + app = _Harness() + async with app.run_test() as pilot: + await pilot.pause() + panel = app.query_one(OutputPanel) + ex = _exercise(tmp_path, "# I AM NOT DONE\nvalue = 1\n") + panel.render_result( + ex, + _result(0, stdout="ok"), + failures=0, + completed=0, + total=2, + auto_advance_seconds=4.0, + ) + await pilot.pause() + rendered = panel.renderable_text().lower() + assert "checks pass" in rendered + assert "advancing to the next exercise in 4s" in rendered @pytest.mark.asyncio diff --git a/tests/unit/test_exercise.py b/tests/unit/test_exercise.py index 346745b..d1f52a2 100644 --- a/tests/unit/test_exercise.py +++ b/tests/unit/test_exercise.py @@ -33,6 +33,24 @@ def test_is_pending_marker_inside_string_still_counts(tmp_path: Path) -> None: assert _ex(file).is_pending() is True +def test_strip_marker_removes_marker_line(tmp_path: Path) -> None: + ex = _ex(tmp_path / "ex.py") + stripped = ex.strip_marker("# I AM NOT DONE\nx = 1\n") + assert stripped == "x = 1\n" + + +def test_strip_marker_leaves_marker_inside_string_untouched(tmp_path: Path) -> None: + ex = _ex(tmp_path / "ex.py") + text = 's = "# I AM NOT DONE"\n' + assert ex.strip_marker(text) == text + + +def test_strip_marker_noop_when_marker_absent(tmp_path: Path) -> None: + ex = _ex(tmp_path / "ex.py") + text = "x = 1\n" + assert ex.strip_marker(text) == text + + def test_exercise_is_frozen() -> None: ex = Exercise( name="a", From f91f7c2778532a703b321d7ceeda2d2f4c56629c Mon Sep 17 00:00:00 2001 From: Radha Prajapath Date: Sat, 22 Aug 2026 15:15:57 +0530 Subject: [PATCH 2/2] discussion_auto_submit --- pythonlings/screens/track.py | 8 ++++++++ pythonlings/widgets/editor_pane.py | 2 ++ pythonlings/widgets/output_panel.py | 2 ++ tests/tui/test_app_pilot.py | 18 +++++++++++------- tests/tui/test_output_panel.py | 2 ++ tests/unit/test_exercise.py | 3 +++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/pythonlings/screens/track.py b/pythonlings/screens/track.py index 21023f5..d09ea54 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -149,6 +149,7 @@ def _exercise(self, name: str) -> Exercise: raise KeyError(name) def _load_current(self) -> None: + """Load `self.current` into the editor, resetting per-exercise state.""" if self.current is None: return if self._save_timer is not None: @@ -169,6 +170,7 @@ def _record_resume(self, exercise: str | None) -> None: # --- auto-save / run loop ------------------------------------------- def on_text_area_changed(self, event: TextArea.Changed) -> None: + """Restart the save/run debounce timer on each real editor edit.""" if event.text_area is not self.query_one("#code", TextArea): return if self.query_one(EditorPane).text == self._loaded_text: @@ -201,6 +203,7 @@ def _run_blocking(self, exercise: Exercise) -> None: self.app.call_from_thread(self._apply_result, exercise, result) def _apply_result(self, exercise: Exercise, result: RunResult) -> None: + """Render a check result and advance once it's fully passed.""" if not self.is_attached: return # the track screen was popped while a run was in flight if exercise.name != self.current: @@ -248,6 +251,7 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: self._run_current() def _schedule_auto_advance(self, exercise: Exercise, checked_text: str) -> None: + """Start the countdown that strips the marker once checks pass.""" self._stop_advance_timer() self._advance_exercise = exercise self._advance_text = checked_text @@ -255,6 +259,7 @@ def _schedule_auto_advance(self, exercise: Exercise, checked_text: str) -> None: self._advance_timer = self.set_interval(1.0, self._tick_advance) def _stop_advance_timer(self) -> None: + """Cancel any pending auto-advance countdown, e.g. on a resumed edit.""" if self._advance_timer is not None: self._advance_timer.stop() self._advance_timer = None @@ -262,6 +267,7 @@ def _stop_advance_timer(self) -> None: self._advance_text = "" def _tick_advance(self) -> None: + """Decrement the countdown each second, firing the advance at zero.""" self._advance_remaining -= 1 exercise, checked_text = self._advance_exercise, self._advance_text if self._advance_remaining <= 0: @@ -273,6 +279,7 @@ def _tick_advance(self) -> None: self.query_one(OutputPanel).update_countdown(self._advance_remaining) def _auto_advance(self, exercise: Exercise, checked_text: str) -> None: + """Strip the marker and rerun checks, unless the learner kept editing.""" if not self.is_attached or exercise.name != self.current: return pane = self.query_one(EditorPane) @@ -329,6 +336,7 @@ def action_quit(self) -> None: self.app.exit(0) def _flush_pending(self) -> None: + """Write out an unsaved edit before leaving the screen.""" self._stop_advance_timer() if self._save_timer is not None: self._save_timer.stop() diff --git a/pythonlings/widgets/editor_pane.py b/pythonlings/widgets/editor_pane.py index 740a25c..b8e7f90 100644 --- a/pythonlings/widgets/editor_pane.py +++ b/pythonlings/widgets/editor_pane.py @@ -26,10 +26,12 @@ def load_exercise(self, exercise: Exercise) -> None: area.move_cursor((0, 0)) def focus_editor(self) -> None: + """Move input focus to the code editor.""" self.query_one("#code", TextArea).focus() @property def text(self) -> str: + """The editor's current buffer contents.""" return self.query_one("#code", TextArea).text def set_text(self, text: str) -> None: diff --git a/pythonlings/widgets/output_panel.py b/pythonlings/widgets/output_panel.py index 7bb47e7..8c781e5 100644 --- a/pythonlings/widgets/output_panel.py +++ b/pythonlings/widgets/output_panel.py @@ -62,6 +62,7 @@ def render_result( total: int = 0, auto_advance_seconds: float | None = None, ) -> None: + """Render a finished check run: failure, pending marker, or complete.""" self._render_header(exercise, completed, total) self.query_one("#goal", Static).update( f"[bold]Goal[/bold]\n{self._goal_from(exercise)}" @@ -122,6 +123,7 @@ def update_countdown(self, seconds: int) -> None: @staticmethod def _advance_message(seconds: float) -> str: + """Format the shared countdown text used by both render paths.""" return f"Advancing to the next exercise in {seconds:.0f}s…" def show_final(self, message: str) -> None: diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index 7ec012e..2ebaff1 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -11,7 +11,7 @@ from pythonlings.core.state import State, save as save_state from pythonlings.screens.docs import DocsScreen from pythonlings.screens.topic_picker import TopicPickerScreen -from pythonlings.screens.track import TrackScreen +from pythonlings.screens.track import _AUTO_ADVANCE_SECONDS, TrackScreen from pythonlings.widgets.output_panel import OutputPanel MULTI = Path(__file__).parent.parent / "fixtures" / "multi_topic" @@ -357,6 +357,7 @@ async def test_instant_advance_updates_resume_to_next_exercise( @pytest.mark.asyncio async def test_marker_pass_schedules_auto_advance(tmp_path: Path) -> None: + """Checks passing with the marker still present starts the countdown.""" work = _work_copy(tmp_path) app = PythonlingsApp(root=work, start_topic="alpha") async with app.run_test() as pilot: @@ -370,7 +371,7 @@ async def test_marker_pass_schedules_auto_advance(tmp_path: Path) -> None: assert track.current == "a1" assert track._advance_timer is not None assert ( - "advancing to the next exercise in 4s" + f"advancing to the next exercise in {_AUTO_ADVANCE_SECONDS}s" in track.query_one(OutputPanel).renderable_text().lower() ) @@ -379,6 +380,7 @@ async def test_marker_pass_schedules_auto_advance(tmp_path: Path) -> None: async def test_auto_advance_countdown_ticks_down_each_second( tmp_path: Path, ) -> None: + """Each tick decrements the countdown and updates the panel text.""" work = _work_copy(tmp_path) app = PythonlingsApp(root=work, start_topic="alpha") async with app.run_test() as pilot: @@ -388,18 +390,18 @@ async def test_auto_advance_countdown_ticks_down_each_second( track.query_one("#code", TextArea).text = "# I AM NOT DONE\nx = 1\n" track._flush_and_run() await _settle(pilot) - assert track._advance_remaining == 4 + assert track._advance_remaining == _AUTO_ADVANCE_SECONDS track._tick_advance() await pilot.pause() - assert track._advance_remaining == 3 + assert track._advance_remaining == _AUTO_ADVANCE_SECONDS - 1 assert ( - "advancing to the next exercise in 3s" + f"advancing to the next exercise in {_AUTO_ADVANCE_SECONDS - 1}s" in track.query_one(OutputPanel).renderable_text().lower() ) - track._tick_advance() - track._tick_advance() + for _ in range(_AUTO_ADVANCE_SECONDS - 2): + track._tick_advance() await pilot.pause() assert track._advance_remaining == 1 assert "a1" not in app.state.completed @@ -414,6 +416,7 @@ async def test_auto_advance_countdown_ticks_down_each_second( async def test_auto_advance_fires_strips_marker_and_advances( tmp_path: Path, ) -> None: + """When the countdown fires, the marker is stripped on disk and advances.""" work = _work_copy(tmp_path) app = PythonlingsApp(root=work, start_topic="alpha") async with app.run_test() as pilot: @@ -438,6 +441,7 @@ async def test_auto_advance_fires_strips_marker_and_advances( async def test_editing_during_countdown_cancels_auto_advance( tmp_path: Path, ) -> None: + """Resuming an edit mid-countdown cancels the auto-advance timer.""" work = _work_copy(tmp_path) app = PythonlingsApp(root=work, start_topic="alpha") async with app.run_test() as pilot: diff --git a/tests/tui/test_output_panel.py b/tests/tui/test_output_panel.py index 8a8e204..b5b57c0 100644 --- a/tests/tui/test_output_panel.py +++ b/tests/tui/test_output_panel.py @@ -105,6 +105,7 @@ async def test_result_shows_docs_link(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_marker_pass_prompts_marker_removal(tmp_path: Path) -> None: + """Without a countdown, the panel falls back to the manual instruction.""" app = _Harness() async with app.run_test() as pilot: await pilot.pause() @@ -119,6 +120,7 @@ async def test_marker_pass_prompts_marker_removal(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_marker_pass_with_auto_advance_shows_countdown(tmp_path: Path) -> None: + """Passing auto_advance_seconds swaps in the countdown message.""" app = _Harness() async with app.run_test() as pilot: await pilot.pause() diff --git a/tests/unit/test_exercise.py b/tests/unit/test_exercise.py index d1f52a2..1c67286 100644 --- a/tests/unit/test_exercise.py +++ b/tests/unit/test_exercise.py @@ -34,18 +34,21 @@ def test_is_pending_marker_inside_string_still_counts(tmp_path: Path) -> None: def test_strip_marker_removes_marker_line(tmp_path: Path) -> None: + """strip_marker deletes the marker line and keeps the rest intact.""" ex = _ex(tmp_path / "ex.py") stripped = ex.strip_marker("# I AM NOT DONE\nx = 1\n") assert stripped == "x = 1\n" def test_strip_marker_leaves_marker_inside_string_untouched(tmp_path: Path) -> None: + """A marker embedded in a string literal is not a real marker line.""" ex = _ex(tmp_path / "ex.py") text = 's = "# I AM NOT DONE"\n' assert ex.strip_marker(text) == text def test_strip_marker_noop_when_marker_absent(tmp_path: Path) -> None: + """Stripping text with no marker line returns it unchanged.""" ex = _ex(tmp_path / "ex.py") text = "x = 1\n" assert ex.strip_marker(text) == text