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,94 @@
|
|
|
1
|
+
from tutor.constants import MIN_CHUNK_TOKENS
|
|
2
|
+
from tutor.ingestion.chunker import chunk
|
|
3
|
+
from tutor.models import DocProfile
|
|
4
|
+
|
|
5
|
+
SAMPLE_B = """
|
|
6
|
+
## The JVM
|
|
7
|
+
|
|
8
|
+
The JVM loads bytecode stored in .class files and executes it on any operating system.
|
|
9
|
+
JIT compilation identifies hot code paths and compiles them to native machine code at
|
|
10
|
+
runtime, so repeated calls become significantly faster than interpreted execution.
|
|
11
|
+
Memory is split into the stack for local variables and method calls, and the heap for
|
|
12
|
+
all objects created with the new keyword.
|
|
13
|
+
|
|
14
|
+
## Primitives
|
|
15
|
+
|
|
16
|
+
Java has eight primitive types: int, long, double, float, boolean, char, byte, and short.
|
|
17
|
+
Primitives are stored directly on the stack and hold their values inline, unlike reference
|
|
18
|
+
types which store a pointer to an object on the heap. Autoboxing converts primitives to
|
|
19
|
+
their wrapper classes such as Integer or Boolean automatically when needed.
|
|
20
|
+
|
|
21
|
+
## Pass-by-Value
|
|
22
|
+
|
|
23
|
+
Java is strictly pass-by-value. When you pass a variable to a method, Java copies the
|
|
24
|
+
value of that variable into the method parameter. For primitives this is the actual number.
|
|
25
|
+
For reference types this is the memory address, which means the caller and the method share
|
|
26
|
+
the same heap object, but reassigning the parameter inside the method does not affect the
|
|
27
|
+
caller's original variable.
|
|
28
|
+
""".strip()
|
|
29
|
+
|
|
30
|
+
SAMPLE_NO_HEADINGS = "Java is a language. It has classes and objects. " * 100
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _make_profile(text: str, strategy: str) -> DocProfile:
|
|
34
|
+
return DocProfile(
|
|
35
|
+
filepath="test.md",
|
|
36
|
+
raw_bytes=len(text.encode()),
|
|
37
|
+
estimated_tokens=int(len(text.split()) * 1.3),
|
|
38
|
+
strategy=strategy,
|
|
39
|
+
section_count=3,
|
|
40
|
+
has_code_blocks=False,
|
|
41
|
+
language_hint="java",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_strategy_b_produces_multiple_chunks():
|
|
46
|
+
profile = _make_profile(SAMPLE_B, "B")
|
|
47
|
+
chunks = chunk(SAMPLE_B, profile)
|
|
48
|
+
assert len(chunks) >= 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_strategy_b_chunk_ids_are_slugified():
|
|
52
|
+
profile = _make_profile(SAMPLE_B, "B")
|
|
53
|
+
chunks = chunk(SAMPLE_B, profile)
|
|
54
|
+
for c in chunks:
|
|
55
|
+
assert " " not in c.chunk_id
|
|
56
|
+
assert c.chunk_id == c.chunk_id.lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_strategy_b_no_headings_falls_back_to_c():
|
|
60
|
+
profile = _make_profile(SAMPLE_NO_HEADINGS, "B")
|
|
61
|
+
chunks = chunk(SAMPLE_NO_HEADINGS, profile)
|
|
62
|
+
assert any("window" in c.chunk_id for c in chunks)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_strategy_a_produces_single_chunk():
|
|
66
|
+
short = "Java is statically typed."
|
|
67
|
+
profile = _make_profile(short, "A")
|
|
68
|
+
chunks = chunk(short, profile)
|
|
69
|
+
assert len(chunks) == 1
|
|
70
|
+
assert chunks[0].chunk_id == "full_doc"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_strategy_c_produces_window_chunks():
|
|
74
|
+
long_text = "Java is a language. " * 500
|
|
75
|
+
profile = _make_profile(long_text, "C")
|
|
76
|
+
chunks = chunk(long_text, profile)
|
|
77
|
+
assert len(chunks) > 1
|
|
78
|
+
assert all("window" in c.chunk_id for c in chunks)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_strategy_c_overlap_flag():
|
|
82
|
+
long_text = "Java is a language. " * 500
|
|
83
|
+
profile = _make_profile(long_text, "C")
|
|
84
|
+
chunks = chunk(long_text, profile)
|
|
85
|
+
# First chunk is not overlapping, rest are
|
|
86
|
+
assert chunks[0].overlapping is False
|
|
87
|
+
assert all(c.overlapping is True for c in chunks[1:])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_no_chunk_under_min_tokens():
|
|
91
|
+
profile = _make_profile(SAMPLE_B, "B")
|
|
92
|
+
chunks = chunk(SAMPLE_B, profile)
|
|
93
|
+
for c in chunks:
|
|
94
|
+
assert c.token_count >= MIN_CHUNK_TOKENS
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from tutor.exceptions import IngestionError
|
|
4
|
+
from tutor.ingestion.doc_analyzer import analyze
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_small_doc_strategy_a(tmp_path):
|
|
8
|
+
doc = tmp_path / "small.md"
|
|
9
|
+
doc.write_text("# Title\n\nShort content. " * 20)
|
|
10
|
+
profile = analyze(str(doc))
|
|
11
|
+
assert profile.strategy == "A"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_medium_doc_strategy_b(tmp_path):
|
|
15
|
+
doc = tmp_path / "medium.md"
|
|
16
|
+
doc.write_text("# Title\n\nContent word. " * 8_000)
|
|
17
|
+
profile = analyze(str(doc))
|
|
18
|
+
assert profile.strategy == "B"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_large_doc_strategy_c(tmp_path):
|
|
22
|
+
doc = tmp_path / "large.md"
|
|
23
|
+
doc.write_text("# Title\n\nContent word. " * 50_000)
|
|
24
|
+
profile = analyze(str(doc))
|
|
25
|
+
assert profile.strategy == "C"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_java_language_hint(tmp_path):
|
|
29
|
+
doc = tmp_path / "java.md"
|
|
30
|
+
doc.write_text("# Java\n\n```java\nint x = 5;\n```\n")
|
|
31
|
+
profile = analyze(str(doc))
|
|
32
|
+
assert profile.language_hint == "java"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_has_code_blocks_detection(tmp_path):
|
|
36
|
+
doc = tmp_path / "code.md"
|
|
37
|
+
doc.write_text("# Title\n\n```python\nprint('hi')\n```\n")
|
|
38
|
+
profile = analyze(str(doc))
|
|
39
|
+
assert profile.has_code_blocks is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_code_blocks(tmp_path):
|
|
43
|
+
doc = tmp_path / "nocode.md"
|
|
44
|
+
doc.write_text("# Title\n\nJust plain text, no code blocks here.")
|
|
45
|
+
profile = analyze(str(doc))
|
|
46
|
+
assert profile.has_code_blocks is False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_nonexistent_file_raises():
|
|
50
|
+
with pytest.raises(IngestionError):
|
|
51
|
+
analyze("/nonexistent/path/file.md")
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from unittest.mock import MagicMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tutor.models import TeachingUnit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _make_unit(n: int = 1) -> TeachingUnit:
|
|
9
|
+
return TeachingUnit(
|
|
10
|
+
unit=n,
|
|
11
|
+
concept=f"Concept {n}",
|
|
12
|
+
source_sections=[],
|
|
13
|
+
complexity=2,
|
|
14
|
+
word_budget=400,
|
|
15
|
+
key_facts=["fact"],
|
|
16
|
+
common_misconception="wrong",
|
|
17
|
+
good_analogy="like a thing",
|
|
18
|
+
question_style="recall",
|
|
19
|
+
memory_hook="remember this",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def player(tmp_path):
|
|
25
|
+
"""Create a TutorPlayer with mocked pygame."""
|
|
26
|
+
fake_mp3 = tmp_path / "unit_01.mp3"
|
|
27
|
+
fake_mp3.write_bytes(b"fake")
|
|
28
|
+
|
|
29
|
+
with patch("tutor.player.player.pygame") as mock_pygame:
|
|
30
|
+
mock_pygame.USEREVENT = 0
|
|
31
|
+
mock_pygame.mixer = MagicMock()
|
|
32
|
+
|
|
33
|
+
from tutor.player.player import TutorPlayer
|
|
34
|
+
|
|
35
|
+
p = TutorPlayer(
|
|
36
|
+
unit_files=[str(fake_mp3)],
|
|
37
|
+
units=[_make_unit()],
|
|
38
|
+
)
|
|
39
|
+
p._state = "PAUSED"
|
|
40
|
+
yield p
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_initial_state_is_paused(player):
|
|
44
|
+
assert player._state == "PAUSED"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_toggle_play_pause_from_paused(player):
|
|
48
|
+
with patch.object(player, "_resume") as mock_resume:
|
|
49
|
+
player._toggle_play_pause()
|
|
50
|
+
mock_resume.assert_called_once()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_toggle_play_pause_from_playing(player):
|
|
54
|
+
player._state = "PLAYING"
|
|
55
|
+
with patch.object(player, "_pause") as mock_pause:
|
|
56
|
+
player._toggle_play_pause()
|
|
57
|
+
mock_pause.assert_called_once()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_quit_sets_stopped(player):
|
|
61
|
+
with patch("tutor.player.player.pygame"):
|
|
62
|
+
player._quit()
|
|
63
|
+
assert player._state == "STOPPED"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_no_qa_flag_skips_llm(player, capsys):
|
|
67
|
+
player.no_qa = True
|
|
68
|
+
player._state = "PAUSED"
|
|
69
|
+
player._ask_question()
|
|
70
|
+
out = capsys.readouterr().out
|
|
71
|
+
assert "disabled" in out.lower() or "no-qa" in out.lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_ask_question_pauses_if_playing(player):
|
|
75
|
+
player._state = "PLAYING"
|
|
76
|
+
player.no_qa = True
|
|
77
|
+
player._ask_question()
|
|
78
|
+
assert player._state in ("PAUSED", "PLAYING")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_next_unit_bounded(player):
|
|
82
|
+
player._current_idx = 0
|
|
83
|
+
with patch("tutor.player.player.pygame"):
|
|
84
|
+
with patch.object(player, "_load_unit") as mock_load:
|
|
85
|
+
with patch.object(player, "_play"):
|
|
86
|
+
player._state = "PLAYING"
|
|
87
|
+
player._next_unit()
|
|
88
|
+
mock_load.assert_called_with(0)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for tutor/assets/__init__.py — path constants."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from tutor.assets import ASSETS_DIR, FONTS_DIR, LOGO_PATH
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_assets_dir_is_path():
|
|
9
|
+
assert isinstance(ASSETS_DIR, Path)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_fonts_dir_is_path():
|
|
13
|
+
assert isinstance(FONTS_DIR, Path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_logo_path_is_path():
|
|
17
|
+
assert isinstance(LOGO_PATH, Path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_fonts_dir_is_child_of_assets_dir():
|
|
21
|
+
assert FONTS_DIR.parent == ASSETS_DIR
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_logo_path_is_inside_assets_dir():
|
|
25
|
+
assert LOGO_PATH.parent == ASSETS_DIR
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_assets_dir_exists():
|
|
29
|
+
"""The assets package directory should exist on disk."""
|
|
30
|
+
assert ASSETS_DIR.exists()
|
|
31
|
+
assert ASSETS_DIR.is_dir()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_fonts_dir_name_is_fonts():
|
|
35
|
+
assert FONTS_DIR.name == "fonts"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_logo_path_name():
|
|
39
|
+
assert LOGO_PATH.name == "logo_small.png"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for PR additions in tutor/models.py (VisualSpec) and tutor/exceptions.py (VideoError).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from tutor.exceptions import ConfigError, LLMError, TutorError, VideoError
|
|
10
|
+
from tutor.models import VisualSpec
|
|
11
|
+
|
|
12
|
+
# ── VideoError ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_video_error_is_tutor_error():
|
|
16
|
+
assert issubclass(VideoError, TutorError)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_video_error_can_be_raised_and_caught():
|
|
20
|
+
with pytest.raises(VideoError):
|
|
21
|
+
raise VideoError("pipeline failed")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_video_error_caught_as_tutor_error():
|
|
25
|
+
with pytest.raises(TutorError):
|
|
26
|
+
raise VideoError("wrapped")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_video_error_message_preserved():
|
|
30
|
+
try:
|
|
31
|
+
raise VideoError("step 3 failed")
|
|
32
|
+
except VideoError as exc:
|
|
33
|
+
assert "step 3 failed" in str(exc)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_video_error_not_same_as_config_error():
|
|
37
|
+
assert VideoError is not ConfigError
|
|
38
|
+
assert not issubclass(VideoError, ConfigError)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_video_error_not_same_as_llm_error():
|
|
42
|
+
assert VideoError is not LLMError
|
|
43
|
+
assert not issubclass(VideoError, LLMError)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── VisualSpec — defaults ─────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_visual_spec_minimal_construction():
|
|
50
|
+
spec = VisualSpec(unit_index=1, slide_type="unit")
|
|
51
|
+
assert spec.unit_index == 1
|
|
52
|
+
assert spec.slide_type == "unit"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_visual_spec_default_diagram_type_is_none():
|
|
56
|
+
spec = VisualSpec(unit_index=1, slide_type="unit")
|
|
57
|
+
assert spec.diagram_type == "none"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_visual_spec_default_diagram_spec_is_none():
|
|
61
|
+
spec = VisualSpec(unit_index=1, slide_type="unit")
|
|
62
|
+
assert spec.diagram_spec is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_visual_spec_default_key_points_is_empty_list():
|
|
66
|
+
spec = VisualSpec(unit_index=1, slide_type="unit")
|
|
67
|
+
assert spec.key_points == []
|
|
68
|
+
assert isinstance(spec.key_points, list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_visual_spec_default_memory_hooks_is_empty_list():
|
|
72
|
+
spec = VisualSpec(unit_index=0, slide_type="outro")
|
|
73
|
+
assert spec.memory_hooks == []
|
|
74
|
+
assert isinstance(spec.memory_hooks, list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_visual_spec_default_string_fields_are_empty():
|
|
78
|
+
spec = VisualSpec(unit_index=1, slide_type="unit")
|
|
79
|
+
assert spec.concept == ""
|
|
80
|
+
assert spec.hook_question == ""
|
|
81
|
+
assert spec.memory_hook == ""
|
|
82
|
+
assert spec.analogy == ""
|
|
83
|
+
assert spec.code_snippet is None
|
|
84
|
+
assert spec.title == ""
|
|
85
|
+
assert spec.subtitle == ""
|
|
86
|
+
assert spec.doc_source == ""
|
|
87
|
+
assert spec.session_stats == ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_visual_spec_title_card_fields():
|
|
91
|
+
spec = VisualSpec(
|
|
92
|
+
unit_index=0,
|
|
93
|
+
slide_type="title_card",
|
|
94
|
+
title="Java Basics",
|
|
95
|
+
subtitle="5 units · beginner",
|
|
96
|
+
doc_source="week1_1",
|
|
97
|
+
)
|
|
98
|
+
assert spec.title == "Java Basics"
|
|
99
|
+
assert spec.subtitle == "5 units · beginner"
|
|
100
|
+
assert spec.doc_source == "week1_1"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_visual_spec_unit_fields():
|
|
104
|
+
spec = VisualSpec(
|
|
105
|
+
unit_index=3,
|
|
106
|
+
slide_type="unit",
|
|
107
|
+
concept="Pass-by-Value",
|
|
108
|
+
hook_question="What really happens?",
|
|
109
|
+
key_points=["fact1", "fact2"],
|
|
110
|
+
code_snippet="int x = 5;",
|
|
111
|
+
diagram_type="flowchart",
|
|
112
|
+
diagram_spec="digraph G { A -> B }",
|
|
113
|
+
memory_hook="Copy the address",
|
|
114
|
+
analogy="Like copying an address",
|
|
115
|
+
)
|
|
116
|
+
assert spec.concept == "Pass-by-Value"
|
|
117
|
+
assert spec.hook_question == "What really happens?"
|
|
118
|
+
assert spec.key_points == ["fact1", "fact2"]
|
|
119
|
+
assert spec.code_snippet == "int x = 5;"
|
|
120
|
+
assert spec.diagram_type == "flowchart"
|
|
121
|
+
assert spec.diagram_spec == "digraph G { A -> B }"
|
|
122
|
+
assert spec.memory_hook == "Copy the address"
|
|
123
|
+
assert spec.analogy == "Like copying an address"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_visual_spec_outro_fields():
|
|
127
|
+
spec = VisualSpec(
|
|
128
|
+
unit_index=5,
|
|
129
|
+
slide_type="outro",
|
|
130
|
+
memory_hooks=["Hook A", "Hook B"],
|
|
131
|
+
session_stats="4 units",
|
|
132
|
+
)
|
|
133
|
+
assert spec.memory_hooks == ["Hook A", "Hook B"]
|
|
134
|
+
assert spec.session_stats == "4 units"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_visual_spec_is_dataclass():
|
|
138
|
+
assert dataclasses.is_dataclass(VisualSpec)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_visual_spec_mutable_list_fields_independent():
|
|
142
|
+
"""Each VisualSpec should have its own list instances, not share them."""
|
|
143
|
+
a = VisualSpec(unit_index=1, slide_type="unit")
|
|
144
|
+
b = VisualSpec(unit_index=2, slide_type="unit")
|
|
145
|
+
a.key_points.append("fact")
|
|
146
|
+
assert b.key_points == [], "Shared mutable default detected"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_visual_spec_asdict_roundtrip():
|
|
150
|
+
"""dataclasses.asdict / reconstruct should preserve all fields."""
|
|
151
|
+
original = VisualSpec(
|
|
152
|
+
unit_index=2,
|
|
153
|
+
slide_type="unit",
|
|
154
|
+
concept="Concept",
|
|
155
|
+
key_points=["a", "b"],
|
|
156
|
+
diagram_type="flowchart",
|
|
157
|
+
diagram_spec="digraph G { A -> B }",
|
|
158
|
+
memory_hook="remember",
|
|
159
|
+
analogy="like something",
|
|
160
|
+
)
|
|
161
|
+
d = dataclasses.asdict(original)
|
|
162
|
+
reconstructed = VisualSpec(**d)
|
|
163
|
+
assert reconstructed == original
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_visual_spec_code_comparison_diagram_spec():
|
|
167
|
+
"""diagram_spec can be a dict for code_comparison type."""
|
|
168
|
+
spec = VisualSpec(
|
|
169
|
+
unit_index=1,
|
|
170
|
+
slide_type="unit",
|
|
171
|
+
diagram_type="code_comparison",
|
|
172
|
+
diagram_spec={
|
|
173
|
+
"wrong": "a == b",
|
|
174
|
+
"right": "a.equals(b)",
|
|
175
|
+
"label_wrong": "ref check",
|
|
176
|
+
"label_right": "content check",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
assert isinstance(spec.diagram_spec, dict)
|
|
180
|
+
assert "wrong" in spec.diagram_spec
|
|
File without changes
|