Skip to content

Commit cfa0c44

Browse files
feat: add bridge CLI command + 4 E2E experience verification tests
Re-register bridge add/list commands that were dropped during the compact-to-core CLI pass. Add 4 new E2E tests that verify user experience not just plumbing: sidebar pane renders topics, cleanup notes flow into resume persona, strict directory reuse on resume, and sidebar shows elapsed time. 31 integration tests now pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c522c3e commit cfa0c44

3 files changed

Lines changed: 197 additions & 2 deletions

File tree

packages/studyctl/src/studyctl/cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@
2424
"setup": "studyctl.cli._setup:setup",
2525
# _config.py — configuration
2626
"config": "studyctl.cli._config:config_group",
27-
# _review.py — spaced repetition, progress, wins, streaks
27+
# _review.py — spaced repetition, progress, wins, streaks, bridges
2828
"review": "studyctl.cli._review:review",
2929
"struggles": "studyctl.cli._review:struggles",
3030
"wins": "studyctl.cli._review:wins",
3131
"progress": "studyctl.cli._review:progress",
3232
"resume": "studyctl.cli._review:resume",
3333
"streaks": "studyctl.cli._review:streaks",
34+
"bridge": "studyctl.cli._review:bridge_group",
3435
# _content.py — content pipeline (pdf splitting, NotebookLM, syllabus)
3536
"content": "studyctl.cli._content:content_group",
3637
# _web.py — web UI

packages/studyctl/src/studyctl/cli/_review.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Review commands — spaced repetition, progress, and struggle detection."""
1+
"""Review commands — spaced repetition, progress, struggle detection, and knowledge bridges."""
22

33
from __future__ import annotations
44

@@ -9,6 +9,8 @@
99

1010
from studyctl.cli._shared import TOPIC_KEYWORDS, console
1111
from studyctl.history import (
12+
get_bridges,
13+
record_bridge,
1214
spaced_repetition_due,
1315
struggle_topics,
1416
)
@@ -219,3 +221,82 @@ def streaks() -> None:
219221
console.print(
220222
"\n [dim]No session today or yesterday. Start one to keep your streak going![/dim]"
221223
)
224+
225+
226+
# --- Knowledge bridges ---
227+
228+
229+
@click.group(name="bridge")
230+
def bridge_group() -> None:
231+
"""Manage knowledge bridges between domains."""
232+
233+
234+
@bridge_group.command(name="add")
235+
@click.argument("source")
236+
@click.option("--source-domain", "-s", required=True, help="Source domain (e.g. networking).")
237+
@click.argument("target")
238+
@click.option("--target-domain", "-t", required=True, help="Target domain (e.g. python).")
239+
@click.option("--mapping", "-m", required=True, help="How concepts relate.")
240+
@click.option(
241+
"--quality",
242+
"-q",
243+
type=click.Choice(["strong", "moderate", "weak"]),
244+
default="moderate",
245+
help="Bridge quality.",
246+
)
247+
def bridge_add(
248+
source: str,
249+
source_domain: str,
250+
target: str,
251+
target_domain: str,
252+
mapping: str,
253+
quality: str,
254+
) -> None:
255+
"""Add a knowledge bridge between two concepts.
256+
257+
Example::
258+
259+
studyctl bridge add "ECMP" -s networking \\
260+
"Spark partitions" -t python -m "Both distribute"
261+
"""
262+
if record_bridge(source, source_domain, target, target_domain, mapping, quality, "student"):
263+
console.print(
264+
f"[green]Bridge added:[/green] "
265+
f"{source} ({source_domain}) \u2192 {target} ({target_domain})"
266+
)
267+
else:
268+
console.print("[red]Failed to add bridge. Check your session database.[/red]")
269+
270+
271+
@bridge_group.command(name="list")
272+
@click.option("--source-domain", "-s", default=None, help="Filter by source domain.")
273+
@click.option("--target-domain", "-t", default=None, help="Filter by target domain.")
274+
@click.option("--quality", "-q", default=None, help="Filter by quality.")
275+
def bridge_list(source_domain: str | None, target_domain: str | None, quality: str | None) -> None:
276+
"""List knowledge bridges."""
277+
bridges = get_bridges(target_domain=target_domain, source_domain=source_domain, quality=quality)
278+
if not bridges:
279+
console.print("[dim]No bridges found. Use 'studyctl bridge add' to create some.[/dim]")
280+
return
281+
282+
table = Table(title="Knowledge Bridges")
283+
table.add_column("Source", style="cyan")
284+
table.add_column("Domain")
285+
table.add_column("\u2192")
286+
table.add_column("Target", style="green")
287+
table.add_column("Domain")
288+
table.add_column("Mapping")
289+
table.add_column("Quality")
290+
291+
for b in bridges:
292+
table.add_row(
293+
b["source_concept"],
294+
b["source_domain"],
295+
"\u2192",
296+
b["target_concept"],
297+
b["target_domain"],
298+
b["mapping"],
299+
b["quality"],
300+
)
301+
302+
console.print(table)

packages/studyctl/tests/test_study_integration.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,3 +678,116 @@ def test_wrapper_script_exists_and_executable(self, tmp_path):
678678
content = wrapper.read_text()
679679
assert "python" in content.lower()
680680
assert "-m studyctl.cli" in content
681+
682+
683+
# ---------------------------------------------------------------------------
684+
# Test: E2E Experience Verification
685+
# ---------------------------------------------------------------------------
686+
687+
688+
class TestExperienceVerification:
689+
"""Verify user-facing experience, not just plumbing.
690+
691+
These tests go beyond checking IPC files to verify what the user
692+
actually sees in the terminal and what data flows across sessions.
693+
"""
694+
695+
def test_sidebar_pane_renders_topics(self, tmp_path):
696+
"""Topics logged by the agent appear in the sidebar PANE, not just IPC files."""
697+
agent = _make_mock_agent(tmp_path)
698+
info = _start_session(agent)
699+
700+
_wait_for(
701+
lambda: TOPICS_FILE.exists() and "First-class" in TOPICS_FILE.read_text(),
702+
desc="topics logged to IPC",
703+
)
704+
time.sleep(7)
705+
706+
content = _capture_pane(info["sidebar_pane"])
707+
assert any(marker in content for marker in ["Closures", "First-class", "W:", "L:"]), (
708+
f"Sidebar pane should render topic data but got:\n{content}"
709+
)
710+
711+
def test_cleanup_notes_flow_into_resume_persona(self, tmp_path):
712+
"""Full chain: topics -> cleanup -> DB notes -> resume persona."""
713+
agent = _make_mock_agent(tmp_path)
714+
info = _start_session(agent)
715+
original_name = info["session_name"]
716+
717+
_wait_for(
718+
lambda: TOPICS_FILE.exists() and "First-class" in TOPICS_FILE.read_text(),
719+
desc="topics logged before end",
720+
)
721+
_studyctl("study", "--end")
722+
_wait_for(
723+
lambda: not _session_exists(original_name),
724+
timeout=10,
725+
desc="original session killed",
726+
)
727+
728+
agent2 = _make_mock_agent(tmp_path, name="mock-agent-chain.sh")
729+
_studyctl(
730+
"study",
731+
"--resume",
732+
env_overrides={"STUDYCTL_TEST_AGENT_CMD": f"bash {agent2} {{persona_file}}"},
733+
)
734+
_wait_for(STATE_FILE.exists, desc="resumed state file")
735+
state = _read_state()
736+
737+
persona_path = state.get("persona_file")
738+
assert persona_path, "Resumed session should have a persona file"
739+
persona = Path(persona_path)
740+
assert persona.exists(), f"Persona file not found at {persona_path}"
741+
742+
persona_content = persona.read_text()
743+
has_topic_ref = "Closures" in persona_content or "First-class" in persona_content
744+
has_resume_section = "Resuming" in persona_content or "Previous" in persona_content
745+
assert has_topic_ref or has_resume_section, (
746+
f"Resume persona should reference session 1 topics.\n"
747+
f"Persona content (first 800 chars):\n{persona_content[:800]}"
748+
)
749+
750+
def test_resume_flag_strictly_present(self, tmp_path):
751+
"""Resume must reuse session directory -- no OR fallback allowed."""
752+
agent = _make_fast_agent(tmp_path)
753+
info = _start_session(agent)
754+
original_dir = info["session_dir"]
755+
original_name = info["session_name"]
756+
757+
_wait_for(
758+
lambda: not _session_exists(original_name) or _read_state().get("mode") == "ended",
759+
timeout=20,
760+
desc="session ended",
761+
)
762+
763+
agent2 = _make_mock_agent(tmp_path, name="mock-resume-strict.sh")
764+
_studyctl(
765+
"study",
766+
"--resume",
767+
env_overrides={"STUDYCTL_TEST_AGENT_CMD": f"bash {agent2} {{persona_file}}"},
768+
)
769+
_wait_for(STATE_FILE.exists, desc="resumed state file")
770+
state = _read_state()
771+
772+
assert state.get("session_dir") == original_dir, (
773+
f"Resume should reuse {original_dir}, got {state.get('session_dir')}"
774+
)
775+
776+
def test_sidebar_shows_elapsed_time(self, tmp_path):
777+
"""Sidebar should display a non-zero elapsed time after a few seconds."""
778+
import re
779+
780+
agent = _make_mock_agent(tmp_path)
781+
info = _start_session(agent)
782+
783+
_wait_for(
784+
lambda: len(_capture_pane(info["sidebar_pane"]).strip()) > 0,
785+
timeout=10,
786+
desc="sidebar to render",
787+
)
788+
time.sleep(5)
789+
790+
content = _capture_pane(info["sidebar_pane"])
791+
has_time = bool(re.search(r"\d+:\d{2}", content))
792+
has_elapsed = "elapsed" in content.lower() or "timer" in content.lower()
793+
assert has_time or has_elapsed, f"Sidebar should show elapsed time but got:\n{content}"

0 commit comments

Comments
 (0)