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..d09ea54 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 @@ -144,11 +149,13 @@ 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: 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)) @@ -163,10 +170,12 @@ 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: 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) @@ -194,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: @@ -204,6 +214,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 +223,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 +250,49 @@ 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: + """Start the countdown that strips the marker once checks pass.""" + 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: + """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 + self._advance_exercise = 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: + 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: + """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) + 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 +336,8 @@ 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() 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..b8e7f90 100644 --- a/pythonlings/widgets/editor_pane.py +++ b/pythonlings/widgets/editor_pane.py @@ -26,8 +26,14 @@ 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: + """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..8c781e5 100644 --- a/pythonlings/widgets/output_panel.py +++ b/pythonlings/widgets/output_panel.py @@ -60,7 +60,9 @@ def render_result( failures: int = 0, completed: int = 0, 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)}" @@ -98,11 +100,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 +117,15 @@ 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: + """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: """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..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" @@ -355,6 +355,110 @@ 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: + """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: + 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 ( + f"advancing to the next exercise in {_AUTO_ADVANCE_SECONDS}s" + 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: + """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: + 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 == _AUTO_ADVANCE_SECONDS + + track._tick_advance() + await pilot.pause() + assert track._advance_remaining == _AUTO_ADVANCE_SECONDS - 1 + assert ( + f"advancing to the next exercise in {_AUTO_ADVANCE_SECONDS - 1}s" + in track.query_one(OutputPanel).renderable_text().lower() + ) + + 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 + + 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: + """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: + 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: + """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: + 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..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() @@ -114,7 +115,29 @@ 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: + """Passing auto_advance_seconds swaps in the countdown message.""" + 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..1c67286 100644 --- a/tests/unit/test_exercise.py +++ b/tests/unit/test_exercise.py @@ -33,6 +33,27 @@ 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: + """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 + + def test_exercise_is_frozen() -> None: ex = Exercise( name="a",