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.
- learnx_cli-0.3.0.dist-info/METADATA +240 -0
- learnx_cli-0.3.0.dist-info/RECORD +131 -0
- learnx_cli-0.3.0.dist-info/WHEEL +4 -0
- learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
- tutor/.env copy.example +4 -0
- tutor/__init__.py +0 -0
- tutor/__main__.py +4 -0
- tutor/assets/__init__.py +5 -0
- tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
- tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
- tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
- tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
- tutor/assets/html/highlight-java.min.js +2 -0
- tutor/assets/html/highlight-javascript.min.js +2 -0
- tutor/assets/html/highlight-python.min.js +2 -0
- tutor/assets/html/highlight.min.js +17 -0
- tutor/assets/html/mermaid.min.js +31 -0
- tutor/assets/html/slide_base.css +464 -0
- tutor/assets/html/theme-learnx-dark.css +12 -0
- tutor/audio/__init__.py +0 -0
- tutor/audio/audio_builder.py +143 -0
- tutor/audio/sanitizer.py +9 -0
- tutor/audio/tts_renderer.py +54 -0
- tutor/cli/__init__.py +0 -0
- tutor/cli/commands.py +391 -0
- tutor/cli/logo.py +21 -0
- tutor/cli/playback_commands.py +239 -0
- tutor/cli/shell.py +91 -0
- tutor/cli/shell_context.py +18 -0
- tutor/cli/theme.py +39 -0
- tutor/cli/video_commands.py +123 -0
- tutor/config.py +122 -0
- tutor/conftest.py +5 -0
- tutor/constants.py +82 -0
- tutor/exceptions.py +26 -0
- tutor/generation/__init__.py +0 -0
- tutor/generation/assembler.py +81 -0
- tutor/generation/curriculum.py +97 -0
- tutor/generation/dialogue.py +172 -0
- tutor/generation/narrator.py +122 -0
- tutor/generation/segment_parser.py +223 -0
- tutor/generation/segment_planner.py +200 -0
- tutor/generation/visual_planner.py +205 -0
- tutor/infra/__init__.py +0 -0
- tutor/infra/llm.py +152 -0
- tutor/ingestion/__init__.py +0 -0
- tutor/ingestion/chunker.py +171 -0
- tutor/ingestion/doc_analyzer.py +41 -0
- tutor/ingestion/parse_content.py +19 -0
- tutor/ingestion/summarizer.py +51 -0
- tutor/inspector.py +117 -0
- tutor/llm_config.toml +58 -0
- tutor/models.py +147 -0
- tutor/player/__init__.py +0 -0
- tutor/player/input_handler.py +45 -0
- tutor/player/player.py +308 -0
- tutor/player/player_display.py +117 -0
- tutor/prompts/curriculum.txt +67 -0
- tutor/prompts/dialogue.txt +62 -0
- tutor/prompts/narrate.txt +34 -0
- tutor/prompts/qa.txt +17 -0
- tutor/prompts/summarize.txt +9 -0
- tutor/prompts/visual.txt +60 -0
- tutor/prompts/visual_v3.txt +91 -0
- tutor/qa/__init__.py +0 -0
- tutor/qa/qa.py +105 -0
- tutor/requirements-dev.txt +2 -0
- tutor/requirements.txt +12 -0
- tutor/sample_docs/headingless_large.md +1 -0
- tutor/sample_docs/headingless_test.md +1 -0
- tutor/sample_docs/java-basics.md +78 -0
- tutor/tests/__init__.py +0 -0
- tutor/tests/audio/__init__.py +0 -0
- tutor/tests/audio/test_audio_builder.py +106 -0
- tutor/tests/audio/test_sanitizer.py +41 -0
- tutor/tests/cli/__init__.py +0 -0
- tutor/tests/cli/test_commands.py +67 -0
- tutor/tests/cli/test_video_commands.py +190 -0
- tutor/tests/e2e/README.md +61 -0
- tutor/tests/e2e/__init__.py +0 -0
- tutor/tests/e2e/conftest.py +117 -0
- tutor/tests/e2e/fixtures/README.md +17 -0
- tutor/tests/e2e/fixtures/sample.md +13 -0
- tutor/tests/e2e/test_audio_quality.py +40 -0
- tutor/tests/e2e/test_av_sync.py +56 -0
- tutor/tests/e2e/test_pipeline_smoke.py +37 -0
- tutor/tests/e2e/test_slide_render.py +72 -0
- tutor/tests/e2e/test_video_streams.py +104 -0
- tutor/tests/generation/__init__.py +0 -0
- tutor/tests/generation/conftest.py +134 -0
- tutor/tests/generation/test_assembler.py +64 -0
- tutor/tests/generation/test_curriculum.py +107 -0
- tutor/tests/generation/test_narrator.py +165 -0
- tutor/tests/generation/test_segment_edge_cases.py +280 -0
- tutor/tests/generation/test_segment_planner.py +324 -0
- tutor/tests/generation/test_visual_planner.py +319 -0
- tutor/tests/ingestion/__init__.py +0 -0
- tutor/tests/ingestion/test_chunker.py +94 -0
- tutor/tests/ingestion/test_doc_analyzer.py +51 -0
- tutor/tests/player/__init__.py +0 -0
- tutor/tests/player/test_player_states.py +88 -0
- tutor/tests/test_assets.py +39 -0
- tutor/tests/test_models_visual.py +180 -0
- tutor/tests/visual/__init__.py +0 -0
- tutor/tests/visual/test_beat_timer.py +321 -0
- tutor/tests/visual/test_pipeline_integration.py +178 -0
- tutor/tests/visual/test_slide_renderer.py +298 -0
- tutor/tests/visual/test_subtitle_writer.py +165 -0
- tutor/tests/visual/test_video_assembler.py +108 -0
- tutor/tests/visual/test_visual_pipeline.py +270 -0
- tutor/tutor.py +365 -0
- tutor/visual/__init__.py +213 -0
- tutor/visual/beat_timer.py +222 -0
- tutor/visual/slide_renderer.py +236 -0
- tutor/visual/subtitle_writer.py +187 -0
- tutor/visual/templates/_base.html.j2 +40 -0
- tutor/visual/templates/analogy.html.j2 +21 -0
- tutor/visual/templates/callout.html.j2 +10 -0
- tutor/visual/templates/code_example.html.j2 +12 -0
- tutor/visual/templates/comparison.html.j2 +28 -0
- tutor/visual/templates/decision_guide.html.j2 +37 -0
- tutor/visual/templates/definition.html.j2 +13 -0
- tutor/visual/templates/diagram.html.j2 +11 -0
- tutor/visual/templates/hook_question.html.j2 +17 -0
- tutor/visual/templates/key_insight.html.j2 +9 -0
- tutor/visual/templates/memory_hook.html.j2 +7 -0
- tutor/visual/templates/outro.html.j2 +16 -0
- tutor/visual/templates/question_prompt.html.j2 +13 -0
- tutor/visual/templates/step_sequence.html.j2 +14 -0
- tutor/visual/templates/title_card.html.j2 +12 -0
- 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.
|
tutor/tests/__init__.py
ADDED
|
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.
|