From fcfd41f50efdb86be313d6d8151805997a6fb195 Mon Sep 17 00:00:00 2001 From: Suganthan Date: Fri, 16 Oct 2026 02:47:12 +0530 Subject: [PATCH 1/2] fix(tui): persist reverted exercise text (#74) --- pythonlings/app.py | 8 +++ pythonlings/screens/track.py | 16 +++-- tests/tui/test_app_pilot.py | 109 +++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/pythonlings/app.py b/pythonlings/app.py index 68ee35c..ac47602 100644 --- a/pythonlings/app.py +++ b/pythonlings/app.py @@ -39,6 +39,14 @@ def on_mount(self) -> None: self.push_screen(WelcomeScreen()) + def action_quit(self) -> None: + """Flush an active exercise before handling the app-level quit key.""" + for screen in reversed(self.screen_stack): + if isinstance(screen, TrackScreen): + screen.action_quit() + return + self.exit(0) + def _startup_target(self) -> tuple[str, str | None] | None: if self._start_topic is not None: if self._start_topic in self.manifest.topics(): diff --git a/pythonlings/screens/track.py b/pythonlings/screens/track.py index fce440d..b0342a6 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -68,7 +68,7 @@ class TrackScreen(Screen[None]): Binding("f4", "topics", "Topics"), Binding("f5", "docs", "Docs"), Binding("escape", "quit", "Quit", priority=True), - Binding("ctrl+q", "quit", "Quit"), + Binding("ctrl+q", "quit", "Quit", priority=True), ] def __init__(self, topic: str, start_exercise: str | None = None) -> None: @@ -76,7 +76,7 @@ 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._loaded_text = "" + self._persisted_text = "" self._failure_counts: dict[str, int] = {} self.current: str | None = None @@ -152,7 +152,7 @@ def _load_current(self) -> None: self.query_one(OutputPanel).reset_hint() pane = self.query_one(EditorPane) pane.load_exercise(self._exercise(self.current)) - self._loaded_text = pane.text + self._persisted_text = pane.text self._failure_counts[self.current] = 0 self._record_resume(self.current) @@ -165,7 +165,7 @@ def _record_resume(self, exercise: str | None) -> None: def on_text_area_changed(self, event: TextArea.Changed) -> None: if event.text_area is not self.query_one("#code", TextArea): return - if self.query_one(EditorPane).text == self._loaded_text: + if self.query_one(EditorPane).text == self._persisted_text: return if self._save_timer is not None: self._save_timer.stop() @@ -176,7 +176,9 @@ def _flush_and_run(self) -> None: if self.current is None: return ex = self._exercise(self.current) - ex.path.write_text(self.query_one(EditorPane).text, encoding="utf-8") + text = self.query_one(EditorPane).text + ex.path.write_text(text, encoding="utf-8") + self._persisted_text = text self._run_current() def _run_current(self) -> None: @@ -280,6 +282,8 @@ def _flush_pending(self) -> None: self._save_timer.stop() self._save_timer = None if self.current is not None: + text = self.query_one(EditorPane).text self._exercise(self.current).path.write_text( - self.query_one(EditorPane).text, encoding="utf-8" + text, encoding="utf-8" ) + self._persisted_text = text diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index f055980..592e5fb 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -369,6 +369,115 @@ async def test_failed_run_shows_progressive_nudge(tmp_path: Path) -> None: assert "alpha one" in track.query_one(OutputPanel).renderable_text() +@pytest.mark.asyncio +@pytest.mark.parametrize("leave_key", ["f4", "escape", "ctrl+q"]) +async def test_revert_to_loaded_text_is_persisted_when_leaving( + tmp_path: Path, leave_key: str +) -> None: + work = _work_copy(tmp_path) + exercise_path = work / "exercises" / "alpha" / "a1.py" + original_text = exercise_path.read_text(encoding="utf-8") + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + editor = track.query_one("#code", TextArea) + + editor.text = "x = 99\n" + await pilot.pause(0.7) + await _settle(pilot) + assert exercise_path.read_text(encoding="utf-8") == "x = 99\n" + + editor.text = original_text + await pilot.pause() + await pilot.press(leave_key) + await pilot.pause() + + assert editor.text == original_text + assert exercise_path.read_text(encoding="utf-8") == original_text + if leave_key == "f4": + assert isinstance(app.screen, TopicPickerScreen) + else: + assert app.return_code == 0 + + +@pytest.mark.asyncio +async def test_revert_is_persisted_when_quitting_from_docs( + tmp_path: Path, +) -> None: + work = _work_copy(tmp_path) + exercise_path = work / "exercises" / "alpha" / "a1.py" + original_text = exercise_path.read_text(encoding="utf-8") + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + editor = track.query_one("#code", TextArea) + + editor.text = "x = 99\n" + await pilot.pause(0.7) + await _settle(pilot) + editor.text = original_text + await pilot.pause() + + await pilot.press("f5") + await pilot.pause() + assert isinstance(app.screen, DocsScreen) + await pilot.press("ctrl+q") + await pilot.pause() + + assert exercise_path.read_text(encoding="utf-8") == original_text + assert app.return_code == 0 + + +@pytest.mark.asyncio +async def test_revert_and_rapid_edits_use_debounced_persistence( + tmp_path: Path, +) -> None: + work = _work_copy(tmp_path) + exercise_path = work / "exercises" / "alpha" / "a1.py" + original_text = exercise_path.read_text(encoding="utf-8") + app = PythonlingsApp(root=work, start_topic="alpha") + async with app.run_test() as pilot: + await _settle(pilot) + track = app.screen + assert isinstance(track, TrackScreen) + editor = track.query_one("#code", TextArea) + + editor.text = "x = 1\n" + editor.text = "x = 2\n" + await pilot.pause(0.7) + await _settle(pilot) + assert exercise_path.read_text(encoding="utf-8") == "x = 2\n" + + editor.text = original_text + await pilot.pause(0.7) + await _settle(pilot) + assert exercise_path.read_text(encoding="utf-8") == original_text + + +@pytest.mark.asyncio +async def test_initial_load_does_not_schedule_a_save( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + flushes: list[str | None] = [] + original_flush = TrackScreen._flush_and_run + + def record_flush(screen: TrackScreen) -> None: + flushes.append(screen.current) + original_flush(screen) + + monkeypatch.setattr(TrackScreen, "_flush_and_run", record_flush) + app = PythonlingsApp(root=_work_copy(tmp_path), start_topic="alpha") + async with app.run_test() as pilot: + await pilot.pause(0.7) + await _settle(pilot) + + assert flushes == [] + + @pytest.mark.asyncio async def test_hint_visibility_resets_after_advance(tmp_path: Path) -> None: work = _work_copy(tmp_path) From 0314fe468994fdf8575c37441ef8df105cc9c0a3 Mon Sep 17 00:00:00 2001 From: Suganthan Date: Fri, 16 Oct 2026 02:50:33 +0530 Subject: [PATCH 2/2] fix(tui): keep pilot test compatible with Python 3.9 --- tests/tui/test_app_pilot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index 592e5fb..7bc7f81 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -462,7 +462,7 @@ async def test_revert_and_rapid_edits_use_debounced_persistence( async def test_initial_load_does_not_schedule_a_save( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - flushes: list[str | None] = [] + flushes: list[object] = [] original_flush = TrackScreen._flush_and_run def record_flush(screen: TrackScreen) -> None: