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,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