learnx-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. learnx_cli-0.3.0.dist-info/METADATA +240 -0
  2. learnx_cli-0.3.0.dist-info/RECORD +131 -0
  3. learnx_cli-0.3.0.dist-info/WHEEL +4 -0
  4. learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
  5. tutor/.env copy.example +4 -0
  6. tutor/__init__.py +0 -0
  7. tutor/__main__.py +4 -0
  8. tutor/assets/__init__.py +5 -0
  9. tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
  10. tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
  11. tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
  12. tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
  13. tutor/assets/html/highlight-java.min.js +2 -0
  14. tutor/assets/html/highlight-javascript.min.js +2 -0
  15. tutor/assets/html/highlight-python.min.js +2 -0
  16. tutor/assets/html/highlight.min.js +17 -0
  17. tutor/assets/html/mermaid.min.js +31 -0
  18. tutor/assets/html/slide_base.css +464 -0
  19. tutor/assets/html/theme-learnx-dark.css +12 -0
  20. tutor/audio/__init__.py +0 -0
  21. tutor/audio/audio_builder.py +143 -0
  22. tutor/audio/sanitizer.py +9 -0
  23. tutor/audio/tts_renderer.py +54 -0
  24. tutor/cli/__init__.py +0 -0
  25. tutor/cli/commands.py +391 -0
  26. tutor/cli/logo.py +21 -0
  27. tutor/cli/playback_commands.py +239 -0
  28. tutor/cli/shell.py +91 -0
  29. tutor/cli/shell_context.py +18 -0
  30. tutor/cli/theme.py +39 -0
  31. tutor/cli/video_commands.py +123 -0
  32. tutor/config.py +122 -0
  33. tutor/conftest.py +5 -0
  34. tutor/constants.py +82 -0
  35. tutor/exceptions.py +26 -0
  36. tutor/generation/__init__.py +0 -0
  37. tutor/generation/assembler.py +81 -0
  38. tutor/generation/curriculum.py +97 -0
  39. tutor/generation/dialogue.py +172 -0
  40. tutor/generation/narrator.py +122 -0
  41. tutor/generation/segment_parser.py +223 -0
  42. tutor/generation/segment_planner.py +200 -0
  43. tutor/generation/visual_planner.py +205 -0
  44. tutor/infra/__init__.py +0 -0
  45. tutor/infra/llm.py +152 -0
  46. tutor/ingestion/__init__.py +0 -0
  47. tutor/ingestion/chunker.py +171 -0
  48. tutor/ingestion/doc_analyzer.py +41 -0
  49. tutor/ingestion/parse_content.py +19 -0
  50. tutor/ingestion/summarizer.py +51 -0
  51. tutor/inspector.py +117 -0
  52. tutor/llm_config.toml +58 -0
  53. tutor/models.py +147 -0
  54. tutor/player/__init__.py +0 -0
  55. tutor/player/input_handler.py +45 -0
  56. tutor/player/player.py +308 -0
  57. tutor/player/player_display.py +117 -0
  58. tutor/prompts/curriculum.txt +67 -0
  59. tutor/prompts/dialogue.txt +62 -0
  60. tutor/prompts/narrate.txt +34 -0
  61. tutor/prompts/qa.txt +17 -0
  62. tutor/prompts/summarize.txt +9 -0
  63. tutor/prompts/visual.txt +60 -0
  64. tutor/prompts/visual_v3.txt +91 -0
  65. tutor/qa/__init__.py +0 -0
  66. tutor/qa/qa.py +105 -0
  67. tutor/requirements-dev.txt +2 -0
  68. tutor/requirements.txt +12 -0
  69. tutor/sample_docs/headingless_large.md +1 -0
  70. tutor/sample_docs/headingless_test.md +1 -0
  71. tutor/sample_docs/java-basics.md +78 -0
  72. tutor/tests/__init__.py +0 -0
  73. tutor/tests/audio/__init__.py +0 -0
  74. tutor/tests/audio/test_audio_builder.py +106 -0
  75. tutor/tests/audio/test_sanitizer.py +41 -0
  76. tutor/tests/cli/__init__.py +0 -0
  77. tutor/tests/cli/test_commands.py +67 -0
  78. tutor/tests/cli/test_video_commands.py +190 -0
  79. tutor/tests/e2e/README.md +61 -0
  80. tutor/tests/e2e/__init__.py +0 -0
  81. tutor/tests/e2e/conftest.py +117 -0
  82. tutor/tests/e2e/fixtures/README.md +17 -0
  83. tutor/tests/e2e/fixtures/sample.md +13 -0
  84. tutor/tests/e2e/test_audio_quality.py +40 -0
  85. tutor/tests/e2e/test_av_sync.py +56 -0
  86. tutor/tests/e2e/test_pipeline_smoke.py +37 -0
  87. tutor/tests/e2e/test_slide_render.py +72 -0
  88. tutor/tests/e2e/test_video_streams.py +104 -0
  89. tutor/tests/generation/__init__.py +0 -0
  90. tutor/tests/generation/conftest.py +134 -0
  91. tutor/tests/generation/test_assembler.py +64 -0
  92. tutor/tests/generation/test_curriculum.py +107 -0
  93. tutor/tests/generation/test_narrator.py +165 -0
  94. tutor/tests/generation/test_segment_edge_cases.py +280 -0
  95. tutor/tests/generation/test_segment_planner.py +324 -0
  96. tutor/tests/generation/test_visual_planner.py +319 -0
  97. tutor/tests/ingestion/__init__.py +0 -0
  98. tutor/tests/ingestion/test_chunker.py +94 -0
  99. tutor/tests/ingestion/test_doc_analyzer.py +51 -0
  100. tutor/tests/player/__init__.py +0 -0
  101. tutor/tests/player/test_player_states.py +88 -0
  102. tutor/tests/test_assets.py +39 -0
  103. tutor/tests/test_models_visual.py +180 -0
  104. tutor/tests/visual/__init__.py +0 -0
  105. tutor/tests/visual/test_beat_timer.py +321 -0
  106. tutor/tests/visual/test_pipeline_integration.py +178 -0
  107. tutor/tests/visual/test_slide_renderer.py +298 -0
  108. tutor/tests/visual/test_subtitle_writer.py +165 -0
  109. tutor/tests/visual/test_video_assembler.py +108 -0
  110. tutor/tests/visual/test_visual_pipeline.py +270 -0
  111. tutor/tutor.py +365 -0
  112. tutor/visual/__init__.py +213 -0
  113. tutor/visual/beat_timer.py +222 -0
  114. tutor/visual/slide_renderer.py +236 -0
  115. tutor/visual/subtitle_writer.py +187 -0
  116. tutor/visual/templates/_base.html.j2 +40 -0
  117. tutor/visual/templates/analogy.html.j2 +21 -0
  118. tutor/visual/templates/callout.html.j2 +10 -0
  119. tutor/visual/templates/code_example.html.j2 +12 -0
  120. tutor/visual/templates/comparison.html.j2 +28 -0
  121. tutor/visual/templates/decision_guide.html.j2 +37 -0
  122. tutor/visual/templates/definition.html.j2 +13 -0
  123. tutor/visual/templates/diagram.html.j2 +11 -0
  124. tutor/visual/templates/hook_question.html.j2 +17 -0
  125. tutor/visual/templates/key_insight.html.j2 +9 -0
  126. tutor/visual/templates/memory_hook.html.j2 +7 -0
  127. tutor/visual/templates/outro.html.j2 +16 -0
  128. tutor/visual/templates/question_prompt.html.j2 +13 -0
  129. tutor/visual/templates/step_sequence.html.j2 +14 -0
  130. tutor/visual/templates/title_card.html.j2 +12 -0
  131. tutor/visual/video_assembler.py +299 -0
@@ -0,0 +1,78 @@
1
+ # Java Fundamentals
2
+
3
+ ## How the JVM Works
4
+
5
+ Java source code is compiled by `javac` into bytecode — `.class` files. The JVM
6
+ (Java Virtual Machine) loads and executes bytecode. This means Java is
7
+ "write once, run anywhere": the same `.class` file runs on any OS that has a JVM.
8
+
9
+ The JVM uses a JIT (Just-In-Time) compiler to convert hot bytecode paths into
10
+ native machine code at runtime. First execution is interpreted; repeated calls
11
+ get compiled to native for speed.
12
+
13
+ Memory is divided into the stack (local variables, method calls) and the heap
14
+ (objects). When you write `int x = 5`, x lives on the stack. When you write
15
+ `new Object()`, that object lives on the heap.
16
+
17
+ ## Primitive vs Reference Types
18
+
19
+ Java has 8 primitive types: `int`, `long`, `double`, `float`, `boolean`,
20
+ `char`, `byte`, `short`. Primitives live on the stack and hold values directly.
21
+
22
+ Reference types (everything else) are objects. A variable of a reference type
23
+ holds a memory address — a pointer to where the object lives on the heap. The
24
+ variable is not the object; it points to the object.
25
+
26
+ ## Pass-by-Value
27
+
28
+ Java is strictly pass-by-value. When you pass a variable to a method, Java
29
+ copies the value of that variable into the parameter. For primitives, this is
30
+ the actual number. For reference types, this is the memory address.
31
+
32
+ This means: reassigning a parameter inside a method does NOT affect the
33
+ caller's variable. But mutating the object via the reference (e.g., calling
34
+ `list.add()`) DOES affect the caller, because both the original and the copy
35
+ point to the same heap object.
36
+
37
+ ## String Equality
38
+
39
+ Strings in Java are objects. The `==` operator compares references (memory
40
+ addresses), not content. Two String variables can hold the same text but be
41
+ different objects, so `==` returns `false`.
42
+
43
+ Use `.equals()` to compare String content:
44
+
45
+ ```java
46
+ String a = new String("hello");
47
+ String b = new String("hello");
48
+ a == b // false — different objects
49
+ a.equals(b) // true — same content
50
+ ```
51
+
52
+ String literals are interned: `"hello" == "hello"` may return `true` due to
53
+ the string pool. Do not rely on this. Always use `.equals()`.
54
+
55
+ ## The final Keyword
56
+
57
+ `final` on a variable means the variable cannot be reassigned. It does NOT
58
+ make the object immutable.
59
+
60
+ ```java
61
+ final List<String> names = new ArrayList<>();
62
+ names = new ArrayList<>(); // compile error — reassignment blocked
63
+ names.add("Alice"); // fine — mutation allowed
64
+ ```
65
+
66
+ `final` on a method prevents overriding. `final` on a class prevents subclassing.
67
+
68
+ ## Checked vs Unchecked Exceptions
69
+
70
+ Checked exceptions extend `Exception` directly. The compiler forces you to
71
+ either catch them or declare them in the method signature with `throws`.
72
+ Example: `IOException`, `SQLException`.
73
+
74
+ Unchecked exceptions extend `RuntimeException`. The compiler does not require
75
+ handling. Example: `NullPointerException`, `IllegalArgumentException`.
76
+
77
+ The rule: use checked exceptions for recoverable conditions the caller should
78
+ handle. Use unchecked for programming errors that should not occur.
File without changes
File without changes
@@ -0,0 +1,106 @@
1
+ import json
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ from pydub import AudioSegment
6
+
7
+ from tutor.audio.audio_builder import _assemble
8
+ from tutor.constants import SILENCE_BREATH_MS, SILENCE_TURN_MS
9
+ from tutor.models import DialogueLine, RenderedSegment
10
+
11
+ # ── helpers ───────────────────────────────────────────────────────────────────
12
+
13
+ CLIP_MS = 1000 # fake MP3 duration returned by the mock
14
+
15
+
16
+ def _line(unit: int, speaker: str, text: str = "test line") -> DialogueLine:
17
+ return DialogueLine(speaker=speaker, text=text, unit_number=unit)
18
+
19
+
20
+ def _seg(line: DialogueLine) -> RenderedSegment:
21
+ return RenderedSegment(line=line, audio_path="dummy.mp3", duration_ms=CLIP_MS)
22
+
23
+
24
+ def _run_assemble(tmp_path: Path, segments: list[RenderedSegment]) -> dict:
25
+ """Run _assemble() with mocked I/O; return parsed timing JSON."""
26
+ out_path = tmp_path / "tutorial.mp3"
27
+ units_dir = tmp_path / "units"
28
+ units_dir.mkdir()
29
+
30
+ with (
31
+ patch(
32
+ "tutor.audio.audio_builder.AudioSegment.from_mp3",
33
+ return_value=AudioSegment.silent(duration=CLIP_MS),
34
+ ),
35
+ patch.object(AudioSegment, "export"),
36
+ ):
37
+ _assemble(segments, str(out_path), str(units_dir))
38
+
39
+ return json.loads((tmp_path / "tutorial.timing.json").read_text())
40
+
41
+
42
+ # ── tests ─────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def test_timing_file_written_after_build(tmp_path):
46
+ segs = [_seg(_line(1, "ALEX")), _seg(_line(1, "MAYA"))]
47
+ _run_assemble(tmp_path, segs)
48
+ assert (tmp_path / "tutorial.timing.json").exists()
49
+
50
+
51
+ def test_timing_version_field_is_1(tmp_path):
52
+ segs = [_seg(_line(1, "ALEX")), _seg(_line(1, "MAYA"))]
53
+ data = _run_assemble(tmp_path, segs)
54
+ assert data["version"] == 1
55
+
56
+
57
+ def test_timing_keys_are_string_integers(tmp_path):
58
+ segs = [_seg(_line(1, "ALEX")), _seg(_line(2, "ALEX"))]
59
+ data = _run_assemble(tmp_path, segs)
60
+ keys = set(data["units"].keys())
61
+ assert "1" in keys
62
+ assert "2" in keys
63
+ assert not any("unit_" in k for k in keys)
64
+
65
+
66
+ def test_timing_keys_match_teaching_units(tmp_path):
67
+ segs = [
68
+ _seg(_line(0, "ALEX")), # intro — excluded
69
+ _seg(_line(1, "ALEX")),
70
+ _seg(_line(1, "MAYA")),
71
+ _seg(_line(2, "ALEX")),
72
+ _seg(_line(-1, "ALEX")), # outro — excluded
73
+ ]
74
+ data = _run_assemble(tmp_path, segs)
75
+ units = data["units"]
76
+ assert "1" in units
77
+ assert "2" in units
78
+ assert "0" not in units
79
+ assert "-1" not in units
80
+
81
+
82
+ def test_timing_offsets_no_gaps_no_overlaps(tmp_path):
83
+ segs = [
84
+ _seg(_line(1, "ALEX", "First line")),
85
+ _seg(_line(1, "MAYA", "Second line")),
86
+ _seg(_line(1, "ALEX", "Third line")),
87
+ ]
88
+ data = _run_assemble(tmp_path, segs)
89
+ entries = data["units"]["1"]
90
+ for i in range(len(entries) - 1):
91
+ cur, nxt = entries[i], entries[i + 1]
92
+ gap = SILENCE_BREATH_MS if cur["speaker"] == nxt["speaker"] else SILENCE_TURN_MS
93
+ assert nxt["start_ms"] == cur["end_ms"] + gap
94
+
95
+
96
+ def test_timing_duration_matches_pydub_len(tmp_path):
97
+ segs = [_seg(_line(1, "ALEX")), _seg(_line(1, "MAYA"))]
98
+ data = _run_assemble(tmp_path, segs)
99
+ for entry in data["units"]["1"]:
100
+ assert entry["end_ms"] - entry["start_ms"] == CLIP_MS
101
+
102
+
103
+ def test_intro_and_outro_excluded_from_timing(tmp_path):
104
+ segs = [_seg(_line(0, "ALEX")), _seg(_line(-1, "ALEX"))]
105
+ data = _run_assemble(tmp_path, segs)
106
+ assert data["units"] == {}
@@ -0,0 +1,41 @@
1
+ from tutor.audio.sanitizer import apply
2
+
3
+
4
+ def test_list_of_strings():
5
+ assert apply("Use List<String> here") == "Use a List of Strings here"
6
+
7
+
8
+ def test_hashmap():
9
+ assert apply("HashMap<String, Integer>") == "a HashMap from String to Integer"
10
+
11
+
12
+ def test_not_equal():
13
+ assert apply("if (a != b)") == "if (a not equal to b)"
14
+
15
+
16
+ def test_double_equals():
17
+ assert apply("if (a == b)") == "if (a double equals b)"
18
+
19
+
20
+ def test_annotation():
21
+ assert apply("@Override") == "Override annotation"
22
+
23
+
24
+ def test_int_array():
25
+ assert apply("int[] arr") == "int array arr"
26
+
27
+
28
+ def test_null_pointer():
29
+ assert apply("throws NullPointerException") == "throws Null Pointer Exception"
30
+
31
+
32
+ def test_no_change():
33
+ result = apply("Java is a statically typed language")
34
+ assert result == "Java is a statically typed language"
35
+
36
+
37
+ def test_multiple_substitutions():
38
+ result = apply("List<String> with != and ==")
39
+ assert "a List of Strings" in result
40
+ assert "not equal to" in result
41
+ assert "double equals" in result
File without changes
@@ -0,0 +1,67 @@
1
+ import json
2
+
3
+ from tutor.cli.commands import _format_duration, _read_meta
4
+
5
+
6
+ def test_read_meta_returns_empty_on_missing_file(tmp_path):
7
+ result = _read_meta(tmp_path / "nonexistent.meta.json")
8
+ assert result == {}
9
+
10
+
11
+ def test_read_meta_returns_empty_on_invalid_json(tmp_path):
12
+ bad = tmp_path / "bad.meta.json"
13
+ bad.write_text("not json", encoding="utf-8")
14
+ result = _read_meta(bad)
15
+ assert result == {}
16
+
17
+
18
+ def test_read_meta_returns_dict_on_valid_file(tmp_path):
19
+ meta_file = tmp_path / "tutorial.meta.json"
20
+ data = {
21
+ "source_file": "week2/3.md",
22
+ "generated_at": "2026-05-09T14:32:11",
23
+ "total_duration_s": 1574.3,
24
+ }
25
+ meta_file.write_text(json.dumps(data), encoding="utf-8")
26
+ result = _read_meta(meta_file)
27
+ assert result["source_file"] == "week2/3.md"
28
+ assert result["total_duration_s"] == 1574.3
29
+
30
+
31
+ def test_format_duration_zero_returns_blank():
32
+ assert _format_duration(0) == ""
33
+
34
+
35
+ def test_format_duration_negative_returns_blank():
36
+ assert _format_duration(-5) == ""
37
+
38
+
39
+ def test_format_duration_correct_formatting():
40
+ assert _format_duration(3674.0) == "61:14"
41
+
42
+
43
+ def test_format_duration_simple():
44
+ assert _format_duration(90) == "1:30"
45
+
46
+
47
+ def test_sessions_output_handles_missing_meta(tmp_path, capsys):
48
+ from pathlib import Path
49
+ from unittest.mock import patch
50
+
51
+ from tutor.cli.commands import cmd_sessions
52
+
53
+ session_dir = tmp_path / "test_session"
54
+ (session_dir / "tutorial_units").mkdir(parents=True)
55
+
56
+ with (
57
+ patch.object(Path, "exists", return_value=True),
58
+ patch("tutor.cli.commands.AUDIO_DIR", tmp_path),
59
+ patch("tutor.cli.video_commands.VIDEO_DIR", tmp_path / "video"),
60
+ ):
61
+ from tutor.cli.commands import ShellContext
62
+
63
+ ctx = ShellContext()
64
+ cmd_sessions([], ctx)
65
+
66
+ captured = capsys.readouterr()
67
+ assert "test_session" in captured.out
@@ -0,0 +1,190 @@
1
+ """Tests for tutor/cli/video_commands.py."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+
8
+ from tutor.cli.commands import ShellContext
9
+ from tutor.cli.video_commands import (
10
+ _assert_audio_ready,
11
+ _confirm_overwrite,
12
+ cmd_video,
13
+ cmd_vsessions,
14
+ )
15
+
16
+
17
+ def _ctx(**kwargs) -> ShellContext:
18
+ ctx = ShellContext()
19
+ for k, v in kwargs.items():
20
+ setattr(ctx, k, v)
21
+ return ctx
22
+
23
+
24
+ # ── _assert_audio_ready ──────────────────────────────────────────────────────
25
+
26
+
27
+ def test_cmd_video_missing_units_json(tmp_path):
28
+ """Audio dir exists, tutorial_units has MP3s, but no tutorial.units.json → ValueError."""
29
+ audio_dir = tmp_path / "audio" / "test_session"
30
+ units_dir = audio_dir / "tutorial_units"
31
+ units_dir.mkdir(parents=True)
32
+ (units_dir / "unit_01.mp3").touch()
33
+ # No tutorial.units.json
34
+
35
+ with pytest.raises(ValueError, match="tutorial.units.json"):
36
+ _assert_audio_ready(audio_dir)
37
+
38
+
39
+ def test_cmd_video_unknown_session(tmp_path, capsys):
40
+ """Session not in audio/ → prints error, does not crash."""
41
+ with patch("tutor.cli.video_commands.AUDIO_DIR", tmp_path / "audio"):
42
+ ctx = _ctx()
43
+ cmd_video(["nonexistent_session"], ctx)
44
+ out = capsys.readouterr().out
45
+ assert "Error" in out or "not found" in out
46
+
47
+
48
+ def test_assert_audio_ready_no_mp3s(tmp_path):
49
+ """audio dir exists with units.json but no MP3s → ValueError."""
50
+ audio_dir = tmp_path / "audio" / "sess"
51
+ units_dir = audio_dir / "tutorial_units"
52
+ units_dir.mkdir(parents=True)
53
+ (audio_dir / "tutorial.units.json").write_text("[]")
54
+
55
+ with pytest.raises(ValueError, match="No MP3"):
56
+ _assert_audio_ready(audio_dir)
57
+
58
+
59
+ # ── session context inference ─────────────────────────────────────────────────
60
+
61
+
62
+ def test_cmd_video_infers_session_from_context(capsys):
63
+ """ctx.current_session set, no arg → uses it (errors out at audio check)."""
64
+ ctx = _ctx(current_session="week2_3")
65
+ with patch("tutor.cli.video_commands.AUDIO_DIR", Path("/nonexistent")):
66
+ cmd_video([], ctx)
67
+ out = capsys.readouterr().out
68
+ # Should attempt to use "week2_3" and fail at audio check, not usage error
69
+ assert "Usage: /video" not in out
70
+
71
+
72
+ def test_cmd_video_no_session_no_context_prints_usage(capsys):
73
+ """No arg and no current_session → prints usage."""
74
+ ctx = _ctx()
75
+ cmd_video([], ctx)
76
+ out = capsys.readouterr().out
77
+ assert "Usage" in out
78
+
79
+
80
+ # ── overwrite prompt ──────────────────────────────────────────────────────────
81
+
82
+
83
+ def test_cmd_video_prompts_before_overwrite(tmp_path, capsys):
84
+ """full_session.mp4 exists → ask before overwriting; 'n' → skip."""
85
+ # Set up a fake complete audio session
86
+ audio_dir = tmp_path / "audio" / "test_sess"
87
+ units_dir = audio_dir / "tutorial_units"
88
+ units_dir.mkdir(parents=True)
89
+ (units_dir / "unit_01.mp3").touch()
90
+ (audio_dir / "tutorial.units.json").write_text("[]")
91
+
92
+ # Pre-existing video
93
+ video_dir = tmp_path / "video" / "test_sess"
94
+ video_dir.mkdir(parents=True)
95
+ mp4 = video_dir / "full_session.mp4"
96
+ mp4.touch()
97
+
98
+ with (
99
+ patch("tutor.cli.video_commands.AUDIO_DIR", tmp_path / "audio"),
100
+ patch("tutor.cli.video_commands.VIDEO_DIR", tmp_path / "video"),
101
+ patch("builtins.input", return_value="n"),
102
+ ):
103
+ ctx = _ctx()
104
+ cmd_video(["test_sess"], ctx)
105
+
106
+ out = capsys.readouterr().out
107
+ assert "Skipped" in out
108
+
109
+
110
+ def test_confirm_overwrite_yes(monkeypatch):
111
+ monkeypatch.setattr("builtins.input", lambda _: "y")
112
+ assert _confirm_overwrite(Path("dummy.mp4")) is True
113
+
114
+
115
+ def test_confirm_overwrite_no(monkeypatch):
116
+ monkeypatch.setattr("builtins.input", lambda _: "n")
117
+ assert _confirm_overwrite(Path("dummy.mp4")) is False
118
+
119
+
120
+ # ── cmd_vsessions ─────────────────────────────────────────────────────────────
121
+
122
+
123
+ def test_sessions_shows_mp4_badge(tmp_path, capsys):
124
+ """Session dir with full_session.mp4 → '[mp4]' in output."""
125
+ sess = tmp_path / "week2_3"
126
+ sess.mkdir()
127
+ mp4 = sess / "full_session.mp4"
128
+ mp4.write_bytes(b"x" * 1024) # 1 KB fake MP4
129
+
130
+ with patch("tutor.cli.video_commands.VIDEO_DIR", tmp_path):
131
+ cmd_vsessions([], _ctx())
132
+
133
+ out = capsys.readouterr().out
134
+ assert "[mp4]" in out
135
+ assert "week2_3" in out
136
+
137
+
138
+ def test_sessions_no_output_when_empty(tmp_path, capsys):
139
+ """No completed videos → dim message."""
140
+ with patch("tutor.cli.video_commands.VIDEO_DIR", tmp_path):
141
+ cmd_vsessions([], _ctx())
142
+ out = capsys.readouterr().out
143
+ assert "[mp4]" not in out
144
+
145
+
146
+ # ── sessions badge in cmd_sessions ───────────────────────────────────────────
147
+
148
+
149
+ def test_sessions_command_shows_mp4_badge(tmp_path, capsys):
150
+ """cmd_sessions shows [mp4] when video/<session>/full_session.mp4 exists."""
151
+ from tutor.cli.commands import cmd_sessions
152
+
153
+ audio_dir = tmp_path / "audio"
154
+ sess = audio_dir / "test_sess"
155
+ units_dir = sess / "tutorial_units"
156
+ units_dir.mkdir(parents=True)
157
+ (units_dir / "unit_01.mp3").touch()
158
+
159
+ video_dir = tmp_path / "video" / "test_sess"
160
+ video_dir.mkdir(parents=True)
161
+ (video_dir / "full_session.mp4").touch()
162
+
163
+ with (
164
+ patch("tutor.cli.commands.AUDIO_DIR", audio_dir),
165
+ patch("tutor.cli.video_commands.VIDEO_DIR", tmp_path / "video"),
166
+ ):
167
+ cmd_sessions([], _ctx())
168
+
169
+ out = capsys.readouterr().out
170
+ assert "[video]" in out
171
+
172
+
173
+ def test_sessions_no_badge_without_mp4(tmp_path, capsys):
174
+ """cmd_sessions shows no [video] when MP4 is absent."""
175
+ from tutor.cli.commands import cmd_sessions
176
+
177
+ audio_dir = tmp_path / "audio"
178
+ sess = audio_dir / "test_sess"
179
+ units_dir = sess / "tutorial_units"
180
+ units_dir.mkdir(parents=True)
181
+ (units_dir / "unit_01.mp3").touch()
182
+
183
+ with (
184
+ patch("tutor.cli.commands.AUDIO_DIR", audio_dir),
185
+ patch("tutor.cli.video_commands.VIDEO_DIR", tmp_path / "video"),
186
+ ):
187
+ cmd_sessions([], _ctx())
188
+
189
+ out = capsys.readouterr().out
190
+ assert "[video]" not in out
@@ -0,0 +1,61 @@
1
+ # E2E Smoke Tests
2
+
3
+ ## What These Tests Verify
4
+
5
+ The unit test suite mocks all LLM and audio calls. These smoke tests run the **real
6
+ pipeline** on a small committed fixture and assert on actual output files. They exist
7
+ because several bugs passed all unit tests but were caught here:
8
+
9
+ | Bug | Caught by |
10
+ |-----|-----------|
11
+ | Silent audio in output video (loudnorm / encoding bug) | `test_audio_stream_present`, `test_audio_not_silent` |
12
+ | Blank slides / CSS not loaded | `test_slide_page_not_blank`, `test_slide_has_visible_text` |
13
+ | Pipeline crash on real markdown input | `test_pipeline_exits_zero` |
14
+ | A/V timing drift (estimation instead of actual) | `test_timing_end_matches_audio_duration` |
15
+ | Video file has no audio stream | `test_audio_stream_present` |
16
+
17
+ ## How to Run
18
+
19
+ ```powershell
20
+ # Windows (PowerShell)
21
+ py -m pytest tutor/tests/e2e/ -v # E2E tests only
22
+ py -m pytest tutor/tests/ --ignore=tutor/tests/e2e/ -v # unit tests only
23
+ py -m pytest tutor/tests/ -v # full suite
24
+ py -m ruff check tutor/
25
+ py -m ruff format --check tutor/
26
+ ```
27
+
28
+ ```bash
29
+ # macOS / Linux
30
+ python -m pytest tutor/tests/e2e/ -v
31
+ python -m pytest tutor/tests/ --ignore=tutor/tests/e2e/ -v
32
+ python -m pytest tutor/tests/ -v
33
+ python -m ruff check tutor/
34
+ python -m ruff format --check tutor/
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ - **Internet connection** — TTS (edge-tts) runs for real; the LLM is mocked
40
+ - **ffmpeg + ffprobe** — must be on PATH or in a standard Windows install location;
41
+ required by pydub (audio loading) and `test_video_streams.py` stream checks
42
+ - **Playwright Chromium** — required by `test_slide_render.py`;
43
+ install with `playwright install chromium` (already done in Docker image)
44
+ - **No API key needed** — `GROQ_API_KEY` is injected as a dummy value by conftest.py
45
+
46
+ ## Why the Fixture Is Small
47
+
48
+ `tutor/tests/e2e/fixtures/sample.md` is a 3-paragraph document (~100 words). E2E
49
+ tests are slow because they call real TTS. A single-unit run takes 30–60 seconds.
50
+ Keeping the fixture tiny keeps the full suite under 3 minutes.
51
+
52
+ ## Output Location
53
+
54
+ Pipeline output is written to `<tempdir>/learnx_e2e_smoke/` (e.g. `/tmp/learnx_e2e_smoke/`
55
+ on Linux, `%TEMP%\learnx_e2e_smoke\` on Windows). The directory persists between runs
56
+ so you can inspect the output manually.
57
+
58
+ ## Skipped Tests
59
+
60
+ - `test_video_streams.py` — all tests skip if `tutorial.mp4` is absent (video pipeline optional)
61
+ - `test_slide_render.py` — all tests skip if `slides/` directory is absent (visual pipeline optional)
File without changes
@@ -0,0 +1,117 @@
1
+ """
2
+ Shared fixtures for E2E smoke tests.
3
+
4
+ The pipeline_output fixture runs the full LearnX pipeline once per test session,
5
+ with the LLM mocked so no API key is needed. TTS (edge-tts) runs for real.
6
+ All E2E test modules depend on this fixture.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import tempfile
14
+ from unittest.mock import patch
15
+
16
+ import pytest
17
+
18
+ FIXTURE_DOC = pathlib.Path("tutor/tests/e2e/fixtures/sample.md")
19
+ # Fixed path so inspecting artifacts after a run is easy. Tests are serial
20
+ # (session fixture), so no race condition from parallel execution.
21
+ OUTPUT_DIR = pathlib.Path(tempfile.gettempdir()) / "learnx_e2e_smoke"
22
+
23
+ CURRICULUM_RESPONSE = json.dumps(
24
+ [
25
+ {
26
+ "concept": "What is a Variable?",
27
+ "complexity": 1,
28
+ "source_sections": ["s01"],
29
+ "key_facts": [
30
+ "A variable is a named container for a value",
31
+ "Variables have a name and hold a value",
32
+ "Variables can store numbers, text, or lists",
33
+ ],
34
+ "common_misconception": "Variables and constants are the same thing",
35
+ "good_analogy": "A labeled box in a warehouse",
36
+ "question_style": "recall",
37
+ "memory_hook": "Variable equals labeled box",
38
+ "word_budget": 200,
39
+ "prerequisite_concepts": [],
40
+ "js_contrast": "",
41
+ "production_relevance": "",
42
+ },
43
+ ]
44
+ )
45
+
46
+ DIALOGUE_RESPONSE = "\n".join(
47
+ [
48
+ "ALEX: Welcome to today's lesson on variables in programming.",
49
+ "MAYA: What exactly is a variable?",
50
+ "ALEX: Think of a variable as a labeled box that stores a value you can retrieve later.",
51
+ "MAYA: Like how I would label a container in my kitchen?",
52
+ "ALEX: Exactly. In Python you write age equals 25 to create a variable called age.",
53
+ "MAYA: And then I can use the name age later to get 25 back?",
54
+ "ALEX: That is right. Variables make programs readable and flexible.",
55
+ "MAYA: What types of values can a variable hold?",
56
+ "ALEX: Numbers, text, lists, and almost anything else your program needs.",
57
+ "MAYA: Great, now variables make much more sense to me.",
58
+ ]
59
+ )
60
+
61
+ SUMMARIZE_RESPONSE = (
62
+ "A variable is a named container that holds a value in a computer program. "
63
+ "Variables can hold different types of data including numbers, text, and lists."
64
+ )
65
+
66
+
67
+ def _mock_llm(messages, call_type="dialogue", **kwargs):
68
+ """Return fixed LLM responses keyed on call_type, bypassing the real API."""
69
+ if call_type == "summarize":
70
+ return SUMMARIZE_RESPONSE
71
+ if call_type == "curriculum":
72
+ return CURRICULUM_RESPONSE
73
+ if call_type == "dialogue":
74
+ return DIALOGUE_RESPONSE
75
+ raise ValueError(f"Unexpected LLM call_type in mock: {call_type!r}")
76
+
77
+
78
+ @pytest.fixture(scope="session")
79
+ def pipeline_output():
80
+ """Run the full pipeline once for the entire E2E test session.
81
+
82
+ LLM responses are mocked; TTS (edge-tts) runs for real and requires
83
+ an internet connection. Output files are written to OUTPUT_DIR.
84
+ """
85
+ from tutor.tutor import cmd_generate
86
+
87
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
88
+
89
+ args = argparse.Namespace(
90
+ input=str(FIXTURE_DOC),
91
+ output=str(OUTPUT_DIR / "tutorial.mp3"),
92
+ provider="groq",
93
+ duration=5,
94
+ fmt="tutor-student",
95
+ difficulty="beginner",
96
+ units=1,
97
+ subject="general",
98
+ topic=None,
99
+ play=False,
100
+ script_only=False,
101
+ dry_run=False,
102
+ inspect=False,
103
+ show_summaries=False,
104
+ no_cache=False,
105
+ verbose=False,
106
+ debug=False,
107
+ explain=False,
108
+ conversation=False,
109
+ )
110
+
111
+ with (
112
+ patch("tutor.infra.llm.chat", side_effect=_mock_llm),
113
+ patch.dict(os.environ, {"GROQ_API_KEY": "test-key-not-used"}),
114
+ ):
115
+ cmd_generate(args)
116
+
117
+ return OUTPUT_DIR
@@ -0,0 +1,17 @@
1
+ # E2E Test Fixtures
2
+
3
+ ## sample.md
4
+
5
+ A minimal 3-paragraph document about variables in programming.
6
+
7
+ **Why it is small:** E2E tests run the real TTS pipeline, which takes 2–4 seconds
8
+ per audio segment. A short document keeps the full suite under 3 minutes. The
9
+ fixture is intentionally not representative of real user content — it exists only
10
+ to exercise the pipeline end-to-end.
11
+
12
+ **Why this topic:** "What is a variable?" produces a single teaching unit with a
13
+ short dialogue. The LLM is mocked (see conftest.py), so the content is fixed and
14
+ the test suite is reproducible without a real API key.
15
+
16
+ **Do not add more content to this file.** If you need to test a different scenario,
17
+ add a new fixture file and a separate E2E test module.