Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pythonlings/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
16 changes: 10 additions & 6 deletions pythonlings/screens/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ 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:
super().__init__()
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

Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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
109 changes: 109 additions & 0 deletions tests/tui/test_app_pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[object] = []
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)
Expand Down