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,298 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from jinja2 import TemplateNotFound
5
+
6
+ from tutor.models import SlideSegment, VisualSpec
7
+ from tutor.visual.slide_renderer import _render_html, render_all_slides
8
+
9
+ # ── Fixtures ─────────────────────────────────────────────────────────────────
10
+
11
+
12
+ def _make_seg(
13
+ unit_index: int = 1,
14
+ segment_index: int = 0,
15
+ visual_type: str = "key_insight",
16
+ title: str = "Test Slide",
17
+ **kwargs: object,
18
+ ) -> SlideSegment:
19
+ return SlideSegment(
20
+ unit_index=unit_index,
21
+ segment_index=segment_index,
22
+ lines_start=0,
23
+ lines_end=1,
24
+ visual_type=visual_type,
25
+ title=title,
26
+ body=kwargs.get("body", "Some body text"), # type: ignore[arg-type]
27
+ code=kwargs.get("code"), # type: ignore[arg-type]
28
+ language=kwargs.get("language"), # type: ignore[arg-type]
29
+ mermaid=kwargs.get("mermaid"), # type: ignore[arg-type]
30
+ left=kwargs.get("left"), # type: ignore[arg-type]
31
+ right=kwargs.get("right"), # type: ignore[arg-type]
32
+ rows=kwargs.get("rows"), # type: ignore[arg-type]
33
+ )
34
+
35
+
36
+ def _make_spec(title: str = "Test Tutorial", subtitle: str = "Subtitle") -> VisualSpec:
37
+ return VisualSpec(unit_index=0, slide_type="title_card", title=title, subtitle=subtitle)
38
+
39
+
40
+ def _make_segments_by_unit() -> dict[int, list[SlideSegment]]:
41
+ return {
42
+ 1: [
43
+ _make_seg(unit_index=1, segment_index=0, visual_type="hook_question"),
44
+ _make_seg(unit_index=1, segment_index=1, visual_type="definition"),
45
+ _make_seg(unit_index=1, segment_index=2, visual_type="key_insight"),
46
+ ],
47
+ 2: [
48
+ _make_seg(unit_index=2, segment_index=0, visual_type="hook_question"),
49
+ _make_seg(unit_index=2, segment_index=1, visual_type="memory_hook"),
50
+ _make_seg(unit_index=2, segment_index=2, visual_type="key_insight"),
51
+ ],
52
+ }
53
+
54
+
55
+ # ── Non-browser tests ─────────────────────────────────────────────────────────
56
+
57
+
58
+ def test_render_html_returns_string() -> None:
59
+ seg = _make_seg(visual_type="key_insight", body="Remember this!")
60
+ result = _render_html("key_insight", seg=seg, current_dot=1, total_dots=3)
61
+ assert isinstance(result, str)
62
+ assert len(result) > 0
63
+ assert "html" in result.lower()
64
+
65
+
66
+ def test_template_missing_raises_clearly() -> None:
67
+ seg = _make_seg()
68
+ with pytest.raises(TemplateNotFound):
69
+ _render_html("nonexistent_type", seg=seg)
70
+
71
+
72
+ # ── Browser tests (slow) ──────────────────────────────────────────────────────
73
+
74
+
75
+ @pytest.mark.slow
76
+ def test_render_all_slides_returns_correct_count(tmp_path: Path) -> None:
77
+ # 2 units × 3 segments + title + outro = 8 paths
78
+ title_spec = _make_spec("My Tutorial")
79
+ outro_spec = _make_spec("Thanks!", "See you next time")
80
+ segs = _make_segments_by_unit()
81
+ paths = render_all_slides(title_spec, outro_spec, segs, tmp_path / "slides", "test")
82
+ assert len(paths) == 8
83
+
84
+
85
+ @pytest.mark.slow
86
+ def test_title_is_first_path(tmp_path: Path) -> None:
87
+ title_spec = _make_spec()
88
+ outro_spec = _make_spec("Outro")
89
+ paths = render_all_slides(
90
+ title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
91
+ )
92
+ assert paths[0].name.startswith("00_title")
93
+
94
+
95
+ @pytest.mark.slow
96
+ def test_outro_is_last_path(tmp_path: Path) -> None:
97
+ title_spec = _make_spec()
98
+ outro_spec = _make_spec("Outro")
99
+ paths = render_all_slides(
100
+ title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
101
+ )
102
+ assert paths[-1].name.startswith("99_outro")
103
+
104
+
105
+ @pytest.mark.slow
106
+ def test_png_path_populated_on_segments(tmp_path: Path) -> None:
107
+ title_spec = _make_spec()
108
+ outro_spec = _make_spec("Outro")
109
+ segs_by_unit = _make_segments_by_unit()
110
+ render_all_slides(title_spec, outro_spec, segs_by_unit, tmp_path / "slides", "test")
111
+ for unit_segs in segs_by_unit.values():
112
+ for seg in unit_segs:
113
+ assert seg.png_path != ""
114
+
115
+
116
+ @pytest.mark.slow
117
+ def test_output_files_exist_on_disk(tmp_path: Path) -> None:
118
+ title_spec = _make_spec()
119
+ outro_spec = _make_spec("Outro")
120
+ paths = render_all_slides(
121
+ title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
122
+ )
123
+ for p in paths:
124
+ assert p.exists(), f"Missing: {p}"
125
+
126
+
127
+ @pytest.mark.slow
128
+ def test_image_dimensions_are_1920x1080(tmp_path: Path) -> None:
129
+ from PIL import Image # noqa: PLC0415
130
+
131
+ title_spec = _make_spec()
132
+ outro_spec = _make_spec("Outro")
133
+ paths = render_all_slides(
134
+ title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
135
+ )
136
+ for p in paths:
137
+ img = Image.open(p)
138
+ assert img.size == (1920, 1080), f"{p.name}: expected 1920×1080, got {img.size}"
139
+
140
+
141
+ def test_screenshot_uses_file_url_not_set_content(tmp_path: Path) -> None:
142
+ """_screenshot must call page.goto with a file:// URL, never page.set_content."""
143
+ from unittest.mock import MagicMock, patch
144
+
145
+ from tutor.visual.slide_renderer import _screenshot
146
+
147
+ mock_page = MagicMock()
148
+ out = tmp_path / "out.png"
149
+
150
+ def fake_screenshot(**kwargs: object) -> None:
151
+ out.write_bytes(b"PNG" + b"\x00" * 6000)
152
+
153
+ mock_page.screenshot.side_effect = fake_screenshot
154
+
155
+ with (
156
+ patch("tutor.visual.slide_renderer.tempfile.NamedTemporaryFile") as mock_ntf,
157
+ patch("tutor.visual.slide_renderer.os.unlink"),
158
+ ):
159
+ mock_file = MagicMock()
160
+ mock_file.__enter__ = lambda s: s
161
+ mock_file.__exit__ = MagicMock(return_value=False)
162
+ mock_file.name = str(tmp_path / "tmp.html")
163
+ mock_ntf.return_value = mock_file
164
+ _screenshot(mock_page, "<html></html>", out, False, False)
165
+
166
+ mock_page.goto.assert_called_once()
167
+ call_url = mock_page.goto.call_args[0][0]
168
+ assert call_url.startswith("file:///")
169
+ mock_page.set_content.assert_not_called()
170
+
171
+
172
+ def test_tmp_file_cleaned_up_after_screenshot(tmp_path: Path) -> None:
173
+ """Temp HTML file must be deleted even if screenshot succeeds."""
174
+ import os
175
+ from unittest.mock import MagicMock
176
+
177
+ from tutor.visual.slide_renderer import _screenshot
178
+
179
+ recorded: list[str] = []
180
+
181
+ mock_page = MagicMock()
182
+ out = tmp_path / "out.png"
183
+
184
+ def fake_screenshot(**kwargs: object) -> None:
185
+ out.write_bytes(b"PNG" + b"\x00" * 6000)
186
+
187
+ mock_page.screenshot.side_effect = fake_screenshot
188
+
189
+ original_unlink = os.unlink
190
+
191
+ def tracking_unlink(path: str) -> None:
192
+ recorded.append(path)
193
+ try:
194
+ original_unlink(path)
195
+ except OSError:
196
+ pass
197
+
198
+ import tutor.visual.slide_renderer as sr
199
+
200
+ original = sr.os.unlink
201
+ sr.os.unlink = tracking_unlink # type: ignore[assignment]
202
+ try:
203
+ _screenshot(mock_page, "<html></html>", out, False, False)
204
+ finally:
205
+ sr.os.unlink = original # type: ignore[assignment]
206
+
207
+ assert len(recorded) == 1
208
+ assert recorded[0].endswith(".html")
209
+
210
+
211
+ # ── Hardening tests (Day 4) ───────────────────────────────────────────────────
212
+
213
+
214
+ def test_fallback_segment_reclassifies_to_key_insight() -> None:
215
+ from tutor.visual.slide_renderer import _fallback_segment
216
+
217
+ seg = _make_seg(visual_type="diagram", mermaid="classDiagram\n A <|-- B")
218
+ result = _fallback_segment(seg)
219
+ assert result.visual_type == "key_insight"
220
+ assert result.mermaid is None
221
+ assert result.body is not None
222
+
223
+
224
+ def test_fallback_segment_preserves_body_when_present() -> None:
225
+ from tutor.visual.slide_renderer import _fallback_segment
226
+
227
+ seg = _make_seg(body="Animal is a base class")
228
+ result = _fallback_segment(seg)
229
+ assert result.body == "Animal is a base class"
230
+
231
+
232
+ def test_fallback_segment_uses_title_when_body_is_none() -> None:
233
+ from tutor.visual.slide_renderer import _fallback_segment
234
+
235
+ seg = _make_seg(title="Class hierarchy", body=None)
236
+ result = _fallback_segment(seg)
237
+ assert "Class hierarchy" in result.body # type: ignore[operator]
238
+
239
+
240
+ def test_min_png_bytes_constant() -> None:
241
+ from tutor.visual.slide_renderer import _MIN_PNG_BYTES
242
+
243
+ assert _MIN_PNG_BYTES == 5_120
244
+
245
+
246
+ def test_screenshot_raises_on_small_file(tmp_path: Path) -> None:
247
+ """_screenshot raises RuntimeError when the output PNG is below the size threshold."""
248
+ from unittest.mock import MagicMock
249
+
250
+ from tutor.visual.slide_renderer import _screenshot
251
+
252
+ output = tmp_path / "test.png"
253
+ output.write_bytes(b"X") # 1 byte — below threshold
254
+
255
+ mock_page = MagicMock()
256
+
257
+ with pytest.raises(RuntimeError, match="too small"):
258
+ _screenshot(
259
+ mock_page, "<html><body>test</body></html>", output, wait_mermaid=False, wait_hljs=False
260
+ )
261
+
262
+
263
+ def test_screenshot_goto_retries_once_on_failure(tmp_path: Path) -> None:
264
+ """page.goto() is retried once before raising on persistent failure."""
265
+ from unittest.mock import MagicMock
266
+
267
+ from tutor.visual.slide_renderer import _screenshot
268
+
269
+ output = tmp_path / "out.png"
270
+ mock_page = MagicMock()
271
+ mock_page.goto.side_effect = [RuntimeError("connection reset"), None]
272
+
273
+ def fake_screenshot(**kwargs: object) -> None:
274
+ output.write_bytes(b"PNG" + b"\x00" * 6000)
275
+
276
+ mock_page.screenshot.side_effect = fake_screenshot
277
+
278
+ _screenshot(
279
+ mock_page, "<html><body>test</body></html>", output, wait_mermaid=False, wait_hljs=False
280
+ )
281
+ assert mock_page.goto.call_count == 2
282
+
283
+
284
+ @pytest.mark.slow
285
+ def test_invalid_mermaid_does_not_crash(tmp_path: Path) -> None:
286
+ title_spec = _make_spec()
287
+ outro_spec = _make_spec("Outro")
288
+ bad_diagram = _make_seg(
289
+ unit_index=1,
290
+ segment_index=0,
291
+ visual_type="diagram",
292
+ mermaid="this is not valid mermaid @@##!!",
293
+ )
294
+ segs = {1: [bad_diagram]}
295
+ paths = render_all_slides(title_spec, outro_spec, segs, tmp_path / "slides", "test")
296
+ diagram_path = next((p for p in paths if "diagram" in p.name), None)
297
+ assert diagram_path is not None
298
+ assert diagram_path.exists()
@@ -0,0 +1,165 @@
1
+ import re
2
+
3
+ import pytest
4
+
5
+ from tutor.models import DialogueLine
6
+ from tutor.visual.subtitle_writer import (
7
+ _format_timestamp,
8
+ _line_duration,
9
+ _wrap_subtitle,
10
+ build_srt,
11
+ get_line_start_offsets,
12
+ )
13
+
14
+
15
+ def _line(speaker: str, text: str, unit: int = 1) -> DialogueLine:
16
+ return DialogueLine(speaker=speaker, text=text, unit_number=unit)
17
+
18
+
19
+ # ── SRT format ───────────────────────────────────────────────────────────────
20
+
21
+
22
+ def test_srt_numbering_sequential():
23
+ lines = [
24
+ _line("ALEX", "Hello world", 1),
25
+ _line("MAYA", "Great point", 1),
26
+ _line("ALEX", "Indeed", 1),
27
+ ]
28
+ srt = build_srt(lines, [30.0])
29
+ numbers = re.findall(r"^(\d+)$", srt, re.MULTILINE)
30
+ assert numbers == ["1", "2", "3"]
31
+
32
+
33
+ def test_timestamp_format_correct():
34
+ result = _format_timestamp(83.456)
35
+ assert result == "00:01:23,456"
36
+
37
+
38
+ def test_timestamp_zero():
39
+ assert _format_timestamp(0.0) == "00:00:00,000"
40
+
41
+
42
+ def test_timestamp_hours():
43
+ assert _format_timestamp(3661.5) == "01:01:01,500"
44
+
45
+
46
+ # ── Subtitle wrapping ─────────────────────────────────────────────────────────
47
+
48
+
49
+ def test_line_wrap_at_60_chars():
50
+ short = _wrap_subtitle("ALEX", "Short text.")
51
+ assert len(short) <= 60
52
+ assert "\n" not in short
53
+
54
+
55
+ def test_line_wrap_long_text_splits():
56
+ long_text = (
57
+ "This is an extremely long piece of dialogue that definitely exceeds sixty characters"
58
+ )
59
+ result = _wrap_subtitle("MAYA", long_text)
60
+ for segment in result.split("\n"):
61
+ assert len(segment) <= 60
62
+
63
+
64
+ def test_wrap_preserves_speaker():
65
+ result = _wrap_subtitle("SAM", "Hello world")
66
+ assert result.startswith("SAM: ")
67
+
68
+
69
+ # ── Unit duration scaling ─────────────────────────────────────────────────────
70
+
71
+
72
+ def test_unit_scaling_when_duration_mismatch():
73
+ lines = [
74
+ _line("ALEX", "One two three four five six seven eight nine ten", 1),
75
+ _line("MAYA", "One two three four five six seven eight nine ten", 1),
76
+ ]
77
+ estimated = sum(_line_duration(ln.text) for ln in lines)
78
+ # Supply actual = 150% of estimated — should scale up
79
+ actual_s = estimated * 1.5
80
+ _srt = build_srt(lines, [actual_s])
81
+ offsets = get_line_start_offsets(lines, [actual_s])
82
+ # The second line's start should be later than without scaling
83
+ offsets_noscale = get_line_start_offsets(lines, [estimated])
84
+ assert offsets[1] > offsets_noscale[1]
85
+
86
+
87
+ def test_no_scaling_within_10_percent():
88
+ lines = [_line("ALEX", "Short line", 1)]
89
+ estimated = _line_duration(lines[0].text)
90
+ actual = estimated * 1.08 # 8% difference, below threshold
91
+ offsets_scaled = get_line_start_offsets(lines, [actual])
92
+ offsets_exact = get_line_start_offsets(lines, [estimated])
93
+ assert offsets_scaled[0] == offsets_exact[0]
94
+
95
+
96
+ # ── Offset consistency ────────────────────────────────────────────────────────
97
+
98
+
99
+ def test_get_line_start_offsets_matches_srt_timestamps():
100
+ lines = [
101
+ _line("ALEX", "First line here now", 1),
102
+ _line("MAYA", "Second line here now", 1),
103
+ ]
104
+ unit_dur = [30.0]
105
+ offsets = get_line_start_offsets(lines, unit_dur)
106
+ srt = build_srt(lines, unit_dur)
107
+
108
+ # Extract first timestamp from SRT
109
+ match = re.search(r"(\d{2}:\d{2}:\d{2},\d{3}) -->", srt)
110
+ assert match
111
+ ts_str = match.group(1)
112
+ # First line always starts at 0.0
113
+ assert offsets[0] == 0.0
114
+ assert ts_str == "00:00:00,000"
115
+
116
+
117
+ # ── Exact timing tests ────────────────────────────────────────────────────────
118
+
119
+
120
+ def _make_timing_json(unit_num: int, entries: list[dict]) -> dict:
121
+ return {"version": 1, "units": {str(unit_num): entries}}
122
+
123
+
124
+ def test_exact_offsets_from_timing_json() -> None:
125
+
126
+ lines = [_line("ALEX", "First line", 1)]
127
+ timing_json = _make_timing_json(1, [{"line_index": 0, "start_ms": 2000, "end_ms": 4000}])
128
+ offsets = get_line_start_offsets(lines, [30.0], timing_json)
129
+ # unit_start[1] = 0 + 2000ms/1000 = 2.0 s
130
+ assert offsets[0] == pytest.approx(2.0, abs=0.01)
131
+
132
+
133
+ def test_fallback_when_timing_absent() -> None:
134
+ lines = [_line("ALEX", "Hello world", 1), _line("MAYA", "Great", 1)]
135
+ offsets_v2 = get_line_start_offsets(lines, [30.0])
136
+ offsets_v3 = get_line_start_offsets(lines, [30.0], None)
137
+ assert offsets_v2 == offsets_v3
138
+
139
+
140
+ def test_inter_unit_silence_included() -> None:
141
+ from tutor.constants import SILENCE_UNIT_MS # noqa: PLC0415
142
+
143
+ unit1_dur = 20.0
144
+ lines = [_line("ALEX", "Unit one", 1), _line("ALEX", "Unit two", 2)]
145
+ timing_json = {
146
+ "version": 1,
147
+ "units": {
148
+ "1": [{"line_index": 0, "start_ms": 0, "end_ms": 1000}],
149
+ "2": [{"line_index": 0, "start_ms": 0, "end_ms": 1000}],
150
+ },
151
+ }
152
+ offsets = get_line_start_offsets(lines, [unit1_dur, 20.0], timing_json)
153
+ # unit_start[2] = unit1_dur + SILENCE_UNIT_MS/1000
154
+ expected = unit1_dur + SILENCE_UNIT_MS / 1000
155
+ assert offsets[1] == pytest.approx(expected, abs=0.01)
156
+
157
+
158
+ def test_intro_lines_fall_back_to_estimation() -> None:
159
+ # Lines with unit_number=0 are not in timing_json → WPM estimation
160
+ lines = [_line("ALEX", "Intro text here", 0), _line("ALEX", "Unit one content", 1)]
161
+ timing_json = _make_timing_json(1, [{"line_index": 0, "start_ms": 5000, "end_ms": 6000}])
162
+ offsets_exact = get_line_start_offsets(lines, [30.0], timing_json)
163
+ offsets_wpm = get_line_start_offsets(lines, [30.0], None)
164
+ # Intro line (unit 0) uses WPM: offset should equal the WPM result
165
+ assert offsets_exact[0] == pytest.approx(offsets_wpm[0], abs=0.01)
@@ -0,0 +1,108 @@
1
+ import subprocess
2
+
3
+ import pytest
4
+
5
+ import tutor.visual.video_assembler as va
6
+ from tutor.exceptions import VideoError
7
+
8
+
9
+ def test_concat_script_written_correctly(tmp_path):
10
+ entries = [
11
+ (tmp_path / "01_hook.png", 6.31),
12
+ (tmp_path / "01_concept.png", 38.44),
13
+ (tmp_path / "01_memory.png", 9.25),
14
+ ]
15
+ script = tmp_path / "test.concat.txt"
16
+ va._write_concat_script(entries, script)
17
+
18
+ content = script.read_text(encoding="utf-8")
19
+ assert "ffconcat version 1.0" in content
20
+ assert "duration 6.310" in content
21
+ assert "duration 38.440" in content
22
+ # Last file appears twice (no duration on second appearance)
23
+ lines = content.strip().splitlines()
24
+ # Path is normalised to forward slashes in the concat script
25
+ expected_path = str(entries[-1][0].resolve()).replace("\\", "/")
26
+ assert lines[-1] == f"file '{expected_path}'"
27
+ assert "duration" not in lines[-1]
28
+
29
+
30
+ def test_ffmpeg_called_with_yuv420p(tmp_path, monkeypatch):
31
+ captured = []
32
+
33
+ def mock_run(args, **kwargs):
34
+ captured.append(args)
35
+ return subprocess.CompletedProcess(args, 0, b"", b"")
36
+
37
+ monkeypatch.setattr(subprocess, "run", mock_run)
38
+
39
+ slides_with_dur = [(tmp_path / "01_hook.png", 5.0)]
40
+ mp3 = tmp_path / "unit_01.mp3"
41
+ mp3.touch()
42
+ output = tmp_path / "unit_01.mp4"
43
+
44
+ va._build_unit_video(slides_with_dur, mp3, output)
45
+
46
+ assert captured, "subprocess.run was not called"
47
+ assert "-pix_fmt" in captured[0]
48
+ yuv_idx = captured[0].index("-pix_fmt")
49
+ assert captured[0][yuv_idx + 1] == "yuv420p"
50
+
51
+
52
+ def test_run_ffmpeg_raises_on_nonzero_exit(monkeypatch):
53
+ def failing_run(args, **kwargs):
54
+ return subprocess.CompletedProcess(args, 1, b"", b"some error")
55
+
56
+ monkeypatch.setattr(subprocess, "run", failing_run)
57
+
58
+ with pytest.raises(VideoError, match="ffmpeg failed"):
59
+ va._run_ffmpeg(["ffmpeg", "-version"])
60
+
61
+
62
+ def test_run_ffmpeg_no_error_on_success(monkeypatch):
63
+ monkeypatch.setattr(
64
+ subprocess, "run", lambda args, **kw: subprocess.CompletedProcess(args, 0, b"", b"")
65
+ )
66
+ va._run_ffmpeg(["ffmpeg", "-version"]) # should not raise
67
+
68
+
69
+ def test_output_paths_in_video_dir(tmp_path):
70
+ audio_dir = tmp_path / "audio" / "week2_3" / "tutorial_units"
71
+ audio_dir.mkdir(parents=True)
72
+ video_dir = tmp_path / "video" / "week2_3"
73
+ video_dir.mkdir(parents=True)
74
+
75
+ # Verify _write_concat_script output is relative to video_dir, not audio_dir
76
+ entries = [(video_dir / "slides" / "01_hook.png", 5.0)]
77
+ script = video_dir / "unit_01.concat.txt"
78
+ va._write_concat_script(entries, script)
79
+
80
+ content = script.read_text()
81
+ # No reference to audio dir in concat script
82
+ assert str(audio_dir) not in content
83
+
84
+
85
+ def test_concat_unit_videos_re_encodes_audio(tmp_path):
86
+ """_concat_unit_videos must NOT use bare -c copy — audio must be re-encoded."""
87
+ import inspect
88
+
89
+ from tutor.visual.video_assembler import _concat_unit_videos
90
+
91
+ src = inspect.getsource(_concat_unit_videos)
92
+ assert '"-c", "copy"' not in src, (
93
+ "_concat_unit_videos must re-encode audio (use -c:v copy + -c:a aac), "
94
+ "not bare -c copy, to fix timestamp discontinuities after concat"
95
+ )
96
+ assert '"-c:a", "aac"' in src
97
+
98
+
99
+ def test_concat_script_single_entry(tmp_path):
100
+ entries = [(tmp_path / "only_slide.png", 4.0)]
101
+ script = tmp_path / "single.concat.txt"
102
+ va._write_concat_script(entries, script)
103
+
104
+ content = script.read_text()
105
+ lines = [ln for ln in content.strip().splitlines() if ln.startswith("file")]
106
+ # Single entry repeated twice
107
+ assert len(lines) == 2
108
+ assert lines[0] == lines[1]