gitmap-core 0.1.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.
- gitmap_core/README.md +46 -0
- gitmap_core/__init__.py +100 -0
- gitmap_core/communication.py +346 -0
- gitmap_core/compat.py +408 -0
- gitmap_core/connection.py +232 -0
- gitmap_core/context.py +709 -0
- gitmap_core/diff.py +283 -0
- gitmap_core/maps.py +385 -0
- gitmap_core/merge.py +449 -0
- gitmap_core/models.py +332 -0
- gitmap_core/py.typed +0 -0
- gitmap_core/pyproject.toml +48 -0
- gitmap_core/remote.py +728 -0
- gitmap_core/repository.py +1632 -0
- gitmap_core/tests/__init__.py +1 -0
- gitmap_core/tests/test_communication.py +695 -0
- gitmap_core/tests/test_compat.py +310 -0
- gitmap_core/tests/test_connection.py +314 -0
- gitmap_core/tests/test_context.py +814 -0
- gitmap_core/tests/test_diff.py +567 -0
- gitmap_core/tests/test_init.py +153 -0
- gitmap_core/tests/test_maps.py +642 -0
- gitmap_core/tests/test_merge.py +694 -0
- gitmap_core/tests/test_models.py +410 -0
- gitmap_core/tests/test_remote.py +3014 -0
- gitmap_core/tests/test_repository.py +1639 -0
- gitmap_core/tests/test_visualize.py +902 -0
- gitmap_core/visualize.py +1217 -0
- gitmap_core-0.1.0.dist-info/METADATA +961 -0
- gitmap_core-0.1.0.dist-info/RECORD +32 -0
- gitmap_core-0.1.0.dist-info/WHEEL +4 -0
- gitmap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""Tests for context graph visualization module.
|
|
2
|
+
|
|
3
|
+
Tests Mermaid diagram generation, ASCII art output, HTML generation,
|
|
4
|
+
and the main visualization function.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Test module - run via pytest
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- pytest: Test framework
|
|
11
|
+
- gitmap_core: Module under test
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tempfile
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from datetime import timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from gitmap_core.context import Annotation
|
|
23
|
+
from gitmap_core.context import ContextStore
|
|
24
|
+
from gitmap_core.context import Edge
|
|
25
|
+
from gitmap_core.context import Event
|
|
26
|
+
from gitmap_core.visualize import (
|
|
27
|
+
GraphData,
|
|
28
|
+
_format_event_label,
|
|
29
|
+
_sanitize_mermaid_text,
|
|
30
|
+
_wrap_text,
|
|
31
|
+
generate_ascii_graph,
|
|
32
|
+
generate_ascii_timeline,
|
|
33
|
+
generate_html_visualization,
|
|
34
|
+
generate_mermaid_flowchart,
|
|
35
|
+
generate_mermaid_git_graph,
|
|
36
|
+
generate_mermaid_timeline,
|
|
37
|
+
visualize_context,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---- Fixtures ------------------------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def sample_events() -> list[Event]:
|
|
46
|
+
"""Create sample events for testing."""
|
|
47
|
+
base_time = datetime.now()
|
|
48
|
+
return [
|
|
49
|
+
Event(
|
|
50
|
+
id="event-001-uuid",
|
|
51
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
52
|
+
event_type="commit",
|
|
53
|
+
actor="user1",
|
|
54
|
+
repo="/test/repo",
|
|
55
|
+
ref="abc12345",
|
|
56
|
+
payload={"message": "Initial commit", "layers": 3},
|
|
57
|
+
),
|
|
58
|
+
Event(
|
|
59
|
+
id="event-002-uuid",
|
|
60
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
61
|
+
event_type="push",
|
|
62
|
+
actor="user1",
|
|
63
|
+
repo="/test/repo",
|
|
64
|
+
ref="abc12345",
|
|
65
|
+
payload={"remote": "portal", "status": "success"},
|
|
66
|
+
),
|
|
67
|
+
Event(
|
|
68
|
+
id="event-003-uuid",
|
|
69
|
+
timestamp=base_time.isoformat(),
|
|
70
|
+
event_type="commit",
|
|
71
|
+
actor="user2",
|
|
72
|
+
repo="/test/repo",
|
|
73
|
+
ref="def67890",
|
|
74
|
+
payload={"message": "Add accessibility layer", "layers": 4},
|
|
75
|
+
),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.fixture
|
|
80
|
+
def sample_edges() -> list[Edge]:
|
|
81
|
+
"""Create sample edges for testing."""
|
|
82
|
+
return [
|
|
83
|
+
Edge(
|
|
84
|
+
source_id="event-002-uuid",
|
|
85
|
+
target_id="event-001-uuid",
|
|
86
|
+
relationship="caused_by",
|
|
87
|
+
metadata={"auto": True},
|
|
88
|
+
),
|
|
89
|
+
Edge(
|
|
90
|
+
source_id="event-003-uuid",
|
|
91
|
+
target_id="event-001-uuid",
|
|
92
|
+
relationship="related_to",
|
|
93
|
+
metadata=None,
|
|
94
|
+
),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.fixture
|
|
99
|
+
def sample_annotations() -> dict[str, list[Annotation]]:
|
|
100
|
+
"""Create sample annotations for testing."""
|
|
101
|
+
base_time = datetime.now()
|
|
102
|
+
return {
|
|
103
|
+
"event-001-uuid": [
|
|
104
|
+
Annotation(
|
|
105
|
+
id="ann-001",
|
|
106
|
+
event_id="event-001-uuid",
|
|
107
|
+
annotation_type="rationale",
|
|
108
|
+
content="Setting up initial map structure",
|
|
109
|
+
source="user",
|
|
110
|
+
timestamp=base_time.isoformat(),
|
|
111
|
+
),
|
|
112
|
+
],
|
|
113
|
+
"event-003-uuid": [
|
|
114
|
+
Annotation(
|
|
115
|
+
id="ann-002",
|
|
116
|
+
event_id="event-003-uuid",
|
|
117
|
+
annotation_type="rationale",
|
|
118
|
+
content="Client requested accessibility features",
|
|
119
|
+
source="user",
|
|
120
|
+
timestamp=base_time.isoformat(),
|
|
121
|
+
),
|
|
122
|
+
Annotation(
|
|
123
|
+
id="ann-003",
|
|
124
|
+
event_id="event-003-uuid",
|
|
125
|
+
annotation_type="lesson",
|
|
126
|
+
content="Always test color contrast",
|
|
127
|
+
source="agent",
|
|
128
|
+
timestamp=base_time.isoformat(),
|
|
129
|
+
),
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.fixture
|
|
135
|
+
def graph_data(
|
|
136
|
+
sample_events: list[Event],
|
|
137
|
+
sample_edges: list[Edge],
|
|
138
|
+
sample_annotations: dict[str, list[Annotation]],
|
|
139
|
+
) -> GraphData:
|
|
140
|
+
"""Create GraphData instance for testing."""
|
|
141
|
+
return GraphData(
|
|
142
|
+
events=sample_events,
|
|
143
|
+
edges=sample_edges,
|
|
144
|
+
annotations=sample_annotations,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.fixture
|
|
149
|
+
def temp_db() -> Path:
|
|
150
|
+
"""Create temporary database path."""
|
|
151
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
152
|
+
yield Path(tmpdir) / "context.db"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---- Helper Function Tests -----------------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestSanitizeMermaidText:
|
|
159
|
+
"""Tests for _sanitize_mermaid_text function."""
|
|
160
|
+
|
|
161
|
+
def test_removes_quotes(self) -> None:
|
|
162
|
+
"""Test that double quotes are replaced."""
|
|
163
|
+
result = _sanitize_mermaid_text('Hello "world"')
|
|
164
|
+
assert '"' not in result
|
|
165
|
+
assert "'" in result
|
|
166
|
+
|
|
167
|
+
def test_removes_brackets(self) -> None:
|
|
168
|
+
"""Test that brackets are replaced."""
|
|
169
|
+
result = _sanitize_mermaid_text("Test [brackets] {braces}")
|
|
170
|
+
assert "[" not in result
|
|
171
|
+
assert "]" not in result
|
|
172
|
+
assert "{" not in result
|
|
173
|
+
assert "}" not in result
|
|
174
|
+
|
|
175
|
+
def test_removes_angle_brackets(self) -> None:
|
|
176
|
+
"""Test that angle brackets are replaced."""
|
|
177
|
+
result = _sanitize_mermaid_text("Test <html> tags")
|
|
178
|
+
assert "<" not in result
|
|
179
|
+
assert ">" not in result
|
|
180
|
+
|
|
181
|
+
def test_truncates_long_text(self) -> None:
|
|
182
|
+
"""Test that long text is truncated."""
|
|
183
|
+
long_text = "x" * 100
|
|
184
|
+
result = _sanitize_mermaid_text(long_text)
|
|
185
|
+
assert len(result) <= 40
|
|
186
|
+
assert result.endswith("...")
|
|
187
|
+
|
|
188
|
+
def test_replaces_newlines(self) -> None:
|
|
189
|
+
"""Test that newlines are replaced with spaces."""
|
|
190
|
+
result = _sanitize_mermaid_text("Line 1\nLine 2")
|
|
191
|
+
assert "\n" not in result
|
|
192
|
+
assert "Line 1 Line 2" == result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestFormatEventLabel:
|
|
196
|
+
"""Tests for _format_event_label function."""
|
|
197
|
+
|
|
198
|
+
def test_includes_event_type(self, sample_events: list[Event]) -> None:
|
|
199
|
+
"""Test that event type is included."""
|
|
200
|
+
label = _format_event_label(sample_events[0])
|
|
201
|
+
assert "COMMIT" in label
|
|
202
|
+
|
|
203
|
+
def test_includes_short_ref(self, sample_events: list[Event]) -> None:
|
|
204
|
+
"""Test that ref is truncated."""
|
|
205
|
+
label = _format_event_label(sample_events[0])
|
|
206
|
+
assert "abc12345" in label
|
|
207
|
+
|
|
208
|
+
def test_hides_time_when_disabled(self, sample_events: list[Event]) -> None:
|
|
209
|
+
"""Test that time can be hidden."""
|
|
210
|
+
label_with_time = _format_event_label(sample_events[0], show_time=True)
|
|
211
|
+
label_no_time = _format_event_label(sample_events[0], show_time=False)
|
|
212
|
+
assert len(label_no_time) < len(label_with_time)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestWrapText:
|
|
216
|
+
"""Tests for _wrap_text function."""
|
|
217
|
+
|
|
218
|
+
def test_wraps_long_text(self) -> None:
|
|
219
|
+
"""Test that long text is wrapped."""
|
|
220
|
+
text = "This is a very long line of text that should be wrapped"
|
|
221
|
+
result = _wrap_text(text, width=20)
|
|
222
|
+
assert len(result) > 1
|
|
223
|
+
assert all(len(line) <= 20 for line in result)
|
|
224
|
+
|
|
225
|
+
def test_preserves_short_text(self) -> None:
|
|
226
|
+
"""Test that short text is not wrapped."""
|
|
227
|
+
text = "Short text"
|
|
228
|
+
result = _wrap_text(text, width=50)
|
|
229
|
+
assert len(result) == 1
|
|
230
|
+
assert result[0] == text
|
|
231
|
+
|
|
232
|
+
def test_handles_empty_text(self) -> None:
|
|
233
|
+
"""Test that empty text returns single empty line."""
|
|
234
|
+
result = _wrap_text("", width=20)
|
|
235
|
+
assert result == [""]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---- Mermaid Generation Tests --------------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TestMermaidFlowchart:
|
|
242
|
+
"""Tests for generate_mermaid_flowchart function."""
|
|
243
|
+
|
|
244
|
+
def test_generates_valid_mermaid(self, graph_data: GraphData) -> None:
|
|
245
|
+
"""Test that valid Mermaid syntax is generated."""
|
|
246
|
+
result = generate_mermaid_flowchart(graph_data)
|
|
247
|
+
assert result.startswith("flowchart")
|
|
248
|
+
assert "TB" in result # Default direction
|
|
249
|
+
|
|
250
|
+
def test_includes_event_nodes(self, graph_data: GraphData) -> None:
|
|
251
|
+
"""Test that events are included as nodes."""
|
|
252
|
+
result = generate_mermaid_flowchart(graph_data)
|
|
253
|
+
assert "e_event-00" in result
|
|
254
|
+
|
|
255
|
+
def test_includes_edges(self, graph_data: GraphData) -> None:
|
|
256
|
+
"""Test that edges are included."""
|
|
257
|
+
result = generate_mermaid_flowchart(graph_data)
|
|
258
|
+
assert "-->" in result or "---" in result
|
|
259
|
+
|
|
260
|
+
def test_respects_direction(self, graph_data: GraphData) -> None:
|
|
261
|
+
"""Test that direction parameter is respected."""
|
|
262
|
+
result = generate_mermaid_flowchart(graph_data, direction="LR")
|
|
263
|
+
assert "flowchart LR" in result
|
|
264
|
+
|
|
265
|
+
def test_includes_annotations_when_enabled(self, graph_data: GraphData) -> None:
|
|
266
|
+
"""Test that annotations are included when enabled."""
|
|
267
|
+
result = generate_mermaid_flowchart(graph_data, show_annotations=True)
|
|
268
|
+
assert "a_" in result # Annotation node prefix
|
|
269
|
+
|
|
270
|
+
def test_excludes_annotations_when_disabled(self, graph_data: GraphData) -> None:
|
|
271
|
+
"""Test that annotations are excluded when disabled."""
|
|
272
|
+
result = generate_mermaid_flowchart(graph_data, show_annotations=False)
|
|
273
|
+
assert "rationale" not in result.lower()
|
|
274
|
+
|
|
275
|
+
def test_includes_styling(self, graph_data: GraphData) -> None:
|
|
276
|
+
"""Test that styling classes are included."""
|
|
277
|
+
result = generate_mermaid_flowchart(graph_data)
|
|
278
|
+
assert "classDef commit" in result
|
|
279
|
+
assert "classDef push" in result
|
|
280
|
+
|
|
281
|
+
def test_adds_title_comment(self, graph_data: GraphData) -> None:
|
|
282
|
+
"""Test that title is added as comment."""
|
|
283
|
+
result = generate_mermaid_flowchart(graph_data, title="Test Graph")
|
|
284
|
+
assert "Test Graph" in result
|
|
285
|
+
|
|
286
|
+
def test_links_branch_with_source_commit(self) -> None:
|
|
287
|
+
"""Test that branch events with source_commit link FROM that commit."""
|
|
288
|
+
base_time = datetime.now()
|
|
289
|
+
events = [
|
|
290
|
+
Event(
|
|
291
|
+
id="commit-001-uuid",
|
|
292
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
293
|
+
event_type="commit",
|
|
294
|
+
actor="user1",
|
|
295
|
+
repo="/test/repo",
|
|
296
|
+
ref="abc12345",
|
|
297
|
+
payload={"message": "Initial commit", "branch": "main"},
|
|
298
|
+
),
|
|
299
|
+
Event(
|
|
300
|
+
id="branch-001-uuid",
|
|
301
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
302
|
+
event_type="branch",
|
|
303
|
+
actor="user1",
|
|
304
|
+
repo="/test/repo",
|
|
305
|
+
ref=None,
|
|
306
|
+
payload={
|
|
307
|
+
"action": "create",
|
|
308
|
+
"branch_name": "feature/test",
|
|
309
|
+
"commit_id": "abc12345", # Source commit
|
|
310
|
+
},
|
|
311
|
+
),
|
|
312
|
+
]
|
|
313
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
314
|
+
result = generate_mermaid_flowchart(data)
|
|
315
|
+
# Should have dotted link from source commit to branch
|
|
316
|
+
assert "e_commit-0" in result
|
|
317
|
+
assert "e_branch-0" in result
|
|
318
|
+
assert "-.->" in result # Dotted arrow for branch creation
|
|
319
|
+
|
|
320
|
+
def test_links_branch_to_first_commit_on_branch(self) -> None:
|
|
321
|
+
"""Test that branch events link to first commit ON that branch."""
|
|
322
|
+
base_time = datetime.now()
|
|
323
|
+
events = [
|
|
324
|
+
Event(
|
|
325
|
+
id="branch-001-uuid",
|
|
326
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
327
|
+
event_type="branch",
|
|
328
|
+
actor="user1",
|
|
329
|
+
repo="/test/repo",
|
|
330
|
+
ref=None,
|
|
331
|
+
payload={"action": "create", "branch_name": "feature/new"},
|
|
332
|
+
),
|
|
333
|
+
Event(
|
|
334
|
+
id="commit-001-uuid",
|
|
335
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
336
|
+
event_type="commit",
|
|
337
|
+
actor="user1",
|
|
338
|
+
repo="/test/repo",
|
|
339
|
+
ref="abc12345",
|
|
340
|
+
payload={"message": "Feature commit", "branch": "feature/new"},
|
|
341
|
+
),
|
|
342
|
+
]
|
|
343
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
344
|
+
result = generate_mermaid_flowchart(data)
|
|
345
|
+
# Branch should link to commit on that branch
|
|
346
|
+
assert "e_branch-0" in result
|
|
347
|
+
assert "e_commit-0" in result
|
|
348
|
+
assert "-->" in result
|
|
349
|
+
|
|
350
|
+
def test_links_initial_branch_to_next_commit(self) -> None:
|
|
351
|
+
"""Test that initial branch without tracking links to first commit after it."""
|
|
352
|
+
base_time = datetime.now()
|
|
353
|
+
events = [
|
|
354
|
+
Event(
|
|
355
|
+
id="branch-001-uuid",
|
|
356
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
357
|
+
event_type="branch",
|
|
358
|
+
actor="user1",
|
|
359
|
+
repo="/test/repo",
|
|
360
|
+
ref=None,
|
|
361
|
+
payload={"action": "create", "branch_name": "main"},
|
|
362
|
+
),
|
|
363
|
+
Event(
|
|
364
|
+
id="commit-001-uuid",
|
|
365
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
366
|
+
event_type="commit",
|
|
367
|
+
actor="user1",
|
|
368
|
+
repo="/test/repo",
|
|
369
|
+
ref="abc12345",
|
|
370
|
+
payload={"message": "Initial commit"}, # No branch tracking
|
|
371
|
+
),
|
|
372
|
+
]
|
|
373
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
374
|
+
result = generate_mermaid_flowchart(data)
|
|
375
|
+
# Branch should link to next commit
|
|
376
|
+
assert "e_branch-0" in result
|
|
377
|
+
assert "e_commit-0" in result
|
|
378
|
+
|
|
379
|
+
def test_merge_commit_connects_to_both_parents(self) -> None:
|
|
380
|
+
"""Test that merge commits connect to both parent branches."""
|
|
381
|
+
base_time = datetime.now()
|
|
382
|
+
events = [
|
|
383
|
+
Event(
|
|
384
|
+
id="commit-001-uuid",
|
|
385
|
+
timestamp=(base_time - timedelta(hours=3)).isoformat(),
|
|
386
|
+
event_type="commit",
|
|
387
|
+
actor="user1",
|
|
388
|
+
repo="/test/repo",
|
|
389
|
+
ref="abc12345",
|
|
390
|
+
payload={"message": "Main commit", "branch": "main"},
|
|
391
|
+
),
|
|
392
|
+
Event(
|
|
393
|
+
id="commit-002-uuid",
|
|
394
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
395
|
+
event_type="commit",
|
|
396
|
+
actor="user1",
|
|
397
|
+
repo="/test/repo",
|
|
398
|
+
ref="def67890",
|
|
399
|
+
payload={"message": "Feature commit", "branch": "feature"},
|
|
400
|
+
),
|
|
401
|
+
Event(
|
|
402
|
+
id="commit-003-uuid",
|
|
403
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
404
|
+
event_type="commit",
|
|
405
|
+
actor="user1",
|
|
406
|
+
repo="/test/repo",
|
|
407
|
+
ref="merge1234",
|
|
408
|
+
payload={
|
|
409
|
+
"message": "Merge feature into main",
|
|
410
|
+
"parent": "abc12345",
|
|
411
|
+
"parent2": "def67890",
|
|
412
|
+
"branch": "main",
|
|
413
|
+
},
|
|
414
|
+
),
|
|
415
|
+
]
|
|
416
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
417
|
+
result = generate_mermaid_flowchart(data)
|
|
418
|
+
# Merge commit should show as merge shape
|
|
419
|
+
assert "{{" in result # Merge shape
|
|
420
|
+
# Should have MERGE label instead of COMMIT
|
|
421
|
+
assert "MERGE" in result
|
|
422
|
+
|
|
423
|
+
def test_merge_event_connects_source_and_target_branches(self) -> None:
|
|
424
|
+
"""Test that merge events connect source and target branches."""
|
|
425
|
+
base_time = datetime.now()
|
|
426
|
+
events = [
|
|
427
|
+
Event(
|
|
428
|
+
id="commit-001-uuid",
|
|
429
|
+
timestamp=(base_time - timedelta(hours=3)).isoformat(),
|
|
430
|
+
event_type="commit",
|
|
431
|
+
actor="user1",
|
|
432
|
+
repo="/test/repo",
|
|
433
|
+
ref="abc12345",
|
|
434
|
+
payload={"message": "Main commit", "branch": "main"},
|
|
435
|
+
),
|
|
436
|
+
Event(
|
|
437
|
+
id="commit-002-uuid",
|
|
438
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
439
|
+
event_type="commit",
|
|
440
|
+
actor="user1",
|
|
441
|
+
repo="/test/repo",
|
|
442
|
+
ref="def67890",
|
|
443
|
+
payload={"message": "Feature commit", "branch": "feature"},
|
|
444
|
+
),
|
|
445
|
+
Event(
|
|
446
|
+
id="merge-001-uuid",
|
|
447
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
448
|
+
event_type="merge",
|
|
449
|
+
actor="user1",
|
|
450
|
+
repo="/test/repo",
|
|
451
|
+
ref="merge1234",
|
|
452
|
+
payload={
|
|
453
|
+
"source_branch": "feature",
|
|
454
|
+
"target_branch": "main",
|
|
455
|
+
},
|
|
456
|
+
),
|
|
457
|
+
]
|
|
458
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
459
|
+
result = generate_mermaid_flowchart(data)
|
|
460
|
+
# Merge event should use merge shape
|
|
461
|
+
assert "{{" in result
|
|
462
|
+
# Should show MERGE label
|
|
463
|
+
assert "MERGE" in result
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class TestMermaidTimeline:
|
|
467
|
+
"""Tests for generate_mermaid_timeline function."""
|
|
468
|
+
|
|
469
|
+
def test_generates_timeline_syntax(self, graph_data: GraphData) -> None:
|
|
470
|
+
"""Test that valid timeline syntax is generated."""
|
|
471
|
+
result = generate_mermaid_timeline(graph_data)
|
|
472
|
+
assert result.startswith("timeline")
|
|
473
|
+
|
|
474
|
+
def test_includes_title(self, graph_data: GraphData) -> None:
|
|
475
|
+
"""Test that title is included."""
|
|
476
|
+
result = generate_mermaid_timeline(graph_data, title="My Timeline")
|
|
477
|
+
assert "My Timeline" in result
|
|
478
|
+
|
|
479
|
+
def test_groups_by_date(self, graph_data: GraphData) -> None:
|
|
480
|
+
"""Test that events are grouped by date."""
|
|
481
|
+
result = generate_mermaid_timeline(graph_data)
|
|
482
|
+
assert "section" in result
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class TestMermaidGitGraph:
|
|
486
|
+
"""Tests for generate_mermaid_git_graph function."""
|
|
487
|
+
|
|
488
|
+
def test_generates_git_graph_syntax(self, graph_data: GraphData) -> None:
|
|
489
|
+
"""Test that valid gitGraph syntax is generated."""
|
|
490
|
+
result = generate_mermaid_git_graph(graph_data)
|
|
491
|
+
assert result.startswith("gitGraph")
|
|
492
|
+
|
|
493
|
+
def test_includes_commits(self, graph_data: GraphData) -> None:
|
|
494
|
+
"""Test that commit events are included."""
|
|
495
|
+
result = generate_mermaid_git_graph(graph_data)
|
|
496
|
+
assert "commit" in result
|
|
497
|
+
|
|
498
|
+
def test_handles_empty_commits(self) -> None:
|
|
499
|
+
"""Test handling of no commit events."""
|
|
500
|
+
data = GraphData(events=[], edges=[], annotations={})
|
|
501
|
+
result = generate_mermaid_git_graph(data)
|
|
502
|
+
assert "No commits yet" in result
|
|
503
|
+
|
|
504
|
+
def test_handles_merge_commits_with_parent2(self) -> None:
|
|
505
|
+
"""Test that merge commits with parent2 are highlighted."""
|
|
506
|
+
base_time = datetime.now()
|
|
507
|
+
events = [
|
|
508
|
+
Event(
|
|
509
|
+
id="commit-001-uuid",
|
|
510
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
511
|
+
event_type="commit",
|
|
512
|
+
actor="user1",
|
|
513
|
+
repo="/test/repo",
|
|
514
|
+
ref="abc12345",
|
|
515
|
+
payload={"message": "Initial commit"},
|
|
516
|
+
),
|
|
517
|
+
Event(
|
|
518
|
+
id="commit-002-uuid",
|
|
519
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
520
|
+
event_type="commit",
|
|
521
|
+
actor="user1",
|
|
522
|
+
repo="/test/repo",
|
|
523
|
+
ref="def67890",
|
|
524
|
+
payload={
|
|
525
|
+
"message": "Merge feature into main",
|
|
526
|
+
"parent": "abc12345",
|
|
527
|
+
"parent2": "xyz98765",
|
|
528
|
+
},
|
|
529
|
+
),
|
|
530
|
+
]
|
|
531
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
532
|
+
result = generate_mermaid_git_graph(data)
|
|
533
|
+
assert "HIGHLIGHT" in result
|
|
534
|
+
|
|
535
|
+
def test_handles_merge_events(self) -> None:
|
|
536
|
+
"""Test that merge events generate proper merge commands."""
|
|
537
|
+
base_time = datetime.now()
|
|
538
|
+
events = [
|
|
539
|
+
Event(
|
|
540
|
+
id="commit-001-uuid",
|
|
541
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
542
|
+
event_type="commit",
|
|
543
|
+
actor="user1",
|
|
544
|
+
repo="/test/repo",
|
|
545
|
+
ref="abc12345",
|
|
546
|
+
payload={"message": "Initial commit"},
|
|
547
|
+
),
|
|
548
|
+
Event(
|
|
549
|
+
id="merge-001-uuid",
|
|
550
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
551
|
+
event_type="merge",
|
|
552
|
+
actor="user1",
|
|
553
|
+
repo="/test/repo",
|
|
554
|
+
ref="def67890",
|
|
555
|
+
payload={
|
|
556
|
+
"source_branch": "feature/login",
|
|
557
|
+
"target_branch": "main",
|
|
558
|
+
"commit_id": "def67890",
|
|
559
|
+
},
|
|
560
|
+
),
|
|
561
|
+
]
|
|
562
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
563
|
+
result = generate_mermaid_git_graph(data)
|
|
564
|
+
assert "merge" in result
|
|
565
|
+
# Branch name should be sanitized (slashes become dashes)
|
|
566
|
+
assert "feature-login" in result
|
|
567
|
+
|
|
568
|
+
def test_handles_lsm_events(self) -> None:
|
|
569
|
+
"""Test that LSM events are rendered as reverse commits."""
|
|
570
|
+
base_time = datetime.now()
|
|
571
|
+
events = [
|
|
572
|
+
Event(
|
|
573
|
+
id="lsm-001-uuid",
|
|
574
|
+
timestamp=base_time.isoformat(),
|
|
575
|
+
event_type="lsm",
|
|
576
|
+
actor="user1",
|
|
577
|
+
repo="/test/repo",
|
|
578
|
+
ref=None,
|
|
579
|
+
payload={
|
|
580
|
+
"source": "Portal",
|
|
581
|
+
"transferred_count": 5,
|
|
582
|
+
},
|
|
583
|
+
),
|
|
584
|
+
]
|
|
585
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
586
|
+
result = generate_mermaid_git_graph(data)
|
|
587
|
+
assert "REVERSE" in result
|
|
588
|
+
assert "LSM from Portal" in result
|
|
589
|
+
assert "5 transferred" in result
|
|
590
|
+
|
|
591
|
+
def test_handles_branch_creation_events(self) -> None:
|
|
592
|
+
"""Test that branch creation events generate branch commands."""
|
|
593
|
+
base_time = datetime.now()
|
|
594
|
+
events = [
|
|
595
|
+
Event(
|
|
596
|
+
id="commit-001-uuid",
|
|
597
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
598
|
+
event_type="commit",
|
|
599
|
+
actor="user1",
|
|
600
|
+
repo="/test/repo",
|
|
601
|
+
ref="abc12345",
|
|
602
|
+
payload={"message": "Initial commit"},
|
|
603
|
+
),
|
|
604
|
+
Event(
|
|
605
|
+
id="branch-001-uuid",
|
|
606
|
+
timestamp=(base_time - timedelta(hours=1)).isoformat(),
|
|
607
|
+
event_type="branch",
|
|
608
|
+
actor="user1",
|
|
609
|
+
repo="/test/repo",
|
|
610
|
+
ref=None,
|
|
611
|
+
payload={
|
|
612
|
+
"action": "create",
|
|
613
|
+
"branch_name": "feature/new-feature",
|
|
614
|
+
},
|
|
615
|
+
),
|
|
616
|
+
]
|
|
617
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
618
|
+
result = generate_mermaid_git_graph(data)
|
|
619
|
+
assert "branch feature-new-feature" in result
|
|
620
|
+
|
|
621
|
+
def test_handles_commit_without_message(self) -> None:
|
|
622
|
+
"""Test that commits without messages get default message."""
|
|
623
|
+
base_time = datetime.now()
|
|
624
|
+
events = [
|
|
625
|
+
Event(
|
|
626
|
+
id="commit-001-uuid",
|
|
627
|
+
timestamp=base_time.isoformat(),
|
|
628
|
+
event_type="commit",
|
|
629
|
+
actor="user1",
|
|
630
|
+
repo="/test/repo",
|
|
631
|
+
ref="abc12345",
|
|
632
|
+
payload={}, # No message
|
|
633
|
+
),
|
|
634
|
+
]
|
|
635
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
636
|
+
result = generate_mermaid_git_graph(data)
|
|
637
|
+
assert "Commit abc12345" in result
|
|
638
|
+
|
|
639
|
+
def test_handles_checkout_on_merge(self) -> None:
|
|
640
|
+
"""Test that merge to different branch triggers checkout."""
|
|
641
|
+
base_time = datetime.now()
|
|
642
|
+
events = [
|
|
643
|
+
Event(
|
|
644
|
+
id="branch-001-uuid",
|
|
645
|
+
timestamp=(base_time - timedelta(hours=2)).isoformat(),
|
|
646
|
+
event_type="branch",
|
|
647
|
+
actor="user1",
|
|
648
|
+
repo="/test/repo",
|
|
649
|
+
ref=None,
|
|
650
|
+
payload={"action": "create", "branch_name": "develop"},
|
|
651
|
+
),
|
|
652
|
+
Event(
|
|
653
|
+
id="merge-001-uuid",
|
|
654
|
+
timestamp=base_time.isoformat(),
|
|
655
|
+
event_type="merge",
|
|
656
|
+
actor="user1",
|
|
657
|
+
repo="/test/repo",
|
|
658
|
+
ref=None,
|
|
659
|
+
payload={
|
|
660
|
+
"source_branch": "develop",
|
|
661
|
+
"target_branch": "main",
|
|
662
|
+
},
|
|
663
|
+
),
|
|
664
|
+
]
|
|
665
|
+
data = GraphData(events=events, edges=[], annotations={})
|
|
666
|
+
result = generate_mermaid_git_graph(data)
|
|
667
|
+
assert "checkout main" in result
|
|
668
|
+
assert "merge develop" in result
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ---- ASCII Generation Tests ----------------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class TestAsciiTimeline:
|
|
675
|
+
"""Tests for generate_ascii_timeline function."""
|
|
676
|
+
|
|
677
|
+
def test_generates_ascii_output(self, graph_data: GraphData) -> None:
|
|
678
|
+
"""Test that ASCII output is generated."""
|
|
679
|
+
result = generate_ascii_timeline(graph_data)
|
|
680
|
+
assert isinstance(result, str)
|
|
681
|
+
assert len(result) > 0
|
|
682
|
+
|
|
683
|
+
def test_uses_unicode_by_default(self, graph_data: GraphData) -> None:
|
|
684
|
+
"""Test that Unicode characters are used by default."""
|
|
685
|
+
result = generate_ascii_timeline(graph_data, use_unicode=True)
|
|
686
|
+
assert "┌" in result or "─" in result
|
|
687
|
+
|
|
688
|
+
def test_uses_simple_ascii_when_requested(self, graph_data: GraphData) -> None:
|
|
689
|
+
"""Test that simple ASCII can be used."""
|
|
690
|
+
result = generate_ascii_timeline(graph_data, use_unicode=False)
|
|
691
|
+
assert "+" in result or "-" in result
|
|
692
|
+
|
|
693
|
+
def test_respects_width(self, graph_data: GraphData) -> None:
|
|
694
|
+
"""Test that width parameter is respected."""
|
|
695
|
+
result = generate_ascii_timeline(graph_data, width=40)
|
|
696
|
+
lines = result.split("\n")
|
|
697
|
+
# Box lines should respect width
|
|
698
|
+
assert all(len(line) <= 42 for line in lines) # Allow slight overflow
|
|
699
|
+
|
|
700
|
+
def test_handles_empty_events(self) -> None:
|
|
701
|
+
"""Test handling of no events."""
|
|
702
|
+
data = GraphData(events=[], edges=[], annotations={})
|
|
703
|
+
result = generate_ascii_timeline(data)
|
|
704
|
+
assert "No events" in result
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class TestAsciiGraph:
|
|
708
|
+
"""Tests for generate_ascii_graph function."""
|
|
709
|
+
|
|
710
|
+
def test_generates_ascii_output(self, graph_data: GraphData) -> None:
|
|
711
|
+
"""Test that ASCII output is generated."""
|
|
712
|
+
result = generate_ascii_graph(graph_data)
|
|
713
|
+
assert isinstance(result, str)
|
|
714
|
+
|
|
715
|
+
def test_includes_legend(self, graph_data: GraphData) -> None:
|
|
716
|
+
"""Test that legend is included."""
|
|
717
|
+
result = generate_ascii_graph(graph_data)
|
|
718
|
+
assert "Legend" in result
|
|
719
|
+
|
|
720
|
+
def test_shows_relationships(self, graph_data: GraphData) -> None:
|
|
721
|
+
"""Test that relationships are shown."""
|
|
722
|
+
result = generate_ascii_graph(graph_data)
|
|
723
|
+
# Should include arrow or relationship indicator
|
|
724
|
+
assert "→" in result or "->" in result
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ---- HTML Generation Tests -----------------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class TestHtmlVisualization:
|
|
731
|
+
"""Tests for generate_html_visualization function."""
|
|
732
|
+
|
|
733
|
+
def test_generates_valid_html(self, graph_data: GraphData) -> None:
|
|
734
|
+
"""Test that valid HTML is generated."""
|
|
735
|
+
result = generate_html_visualization(graph_data)
|
|
736
|
+
assert "<!DOCTYPE html>" in result
|
|
737
|
+
assert "</html>" in result
|
|
738
|
+
|
|
739
|
+
def test_includes_mermaid_script(self, graph_data: GraphData) -> None:
|
|
740
|
+
"""Test that Mermaid.js is included."""
|
|
741
|
+
result = generate_html_visualization(graph_data)
|
|
742
|
+
assert "mermaid" in result.lower()
|
|
743
|
+
|
|
744
|
+
def test_includes_title(self, graph_data: GraphData) -> None:
|
|
745
|
+
"""Test that title is included."""
|
|
746
|
+
result = generate_html_visualization(graph_data, title="My Graph")
|
|
747
|
+
assert "My Graph" in result
|
|
748
|
+
|
|
749
|
+
def test_respects_theme(self, graph_data: GraphData) -> None:
|
|
750
|
+
"""Test that theme affects colors."""
|
|
751
|
+
light = generate_html_visualization(graph_data, theme="light")
|
|
752
|
+
dark = generate_html_visualization(graph_data, theme="dark")
|
|
753
|
+
# Dark theme should have different background color
|
|
754
|
+
assert "#1e1e1e" in dark
|
|
755
|
+
assert "#ffffff" in light
|
|
756
|
+
|
|
757
|
+
def test_includes_stats(self, graph_data: GraphData) -> None:
|
|
758
|
+
"""Test that statistics are included."""
|
|
759
|
+
result = generate_html_visualization(graph_data)
|
|
760
|
+
assert "Events" in result
|
|
761
|
+
assert "Relationships" in result
|
|
762
|
+
assert "Annotations" in result
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ---- Main Visualization Function Tests -----------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class TestVisualizeContext:
|
|
769
|
+
"""Tests for visualize_context function."""
|
|
770
|
+
|
|
771
|
+
def test_supports_mermaid_format(self, temp_db: Path) -> None:
|
|
772
|
+
"""Test mermaid format output."""
|
|
773
|
+
store = ContextStore(temp_db)
|
|
774
|
+
store.record_event("commit", "/repo", {"message": "Test"}, ref="abc123")
|
|
775
|
+
|
|
776
|
+
result = visualize_context(store, output_format="mermaid")
|
|
777
|
+
assert "flowchart" in result
|
|
778
|
+
store.close()
|
|
779
|
+
|
|
780
|
+
def test_supports_mermaid_timeline_format(self, temp_db: Path) -> None:
|
|
781
|
+
"""Test mermaid-timeline format output."""
|
|
782
|
+
store = ContextStore(temp_db)
|
|
783
|
+
store.record_event("commit", "/repo", {"message": "Test"})
|
|
784
|
+
|
|
785
|
+
result = visualize_context(store, output_format="mermaid-timeline")
|
|
786
|
+
assert "timeline" in result
|
|
787
|
+
store.close()
|
|
788
|
+
|
|
789
|
+
def test_supports_ascii_format(self, temp_db: Path) -> None:
|
|
790
|
+
"""Test ascii format output."""
|
|
791
|
+
store = ContextStore(temp_db)
|
|
792
|
+
store.record_event("commit", "/repo", {"message": "Test"})
|
|
793
|
+
|
|
794
|
+
result = visualize_context(store, output_format="ascii")
|
|
795
|
+
assert isinstance(result, str)
|
|
796
|
+
store.close()
|
|
797
|
+
|
|
798
|
+
def test_supports_html_format(self, temp_db: Path) -> None:
|
|
799
|
+
"""Test html format output."""
|
|
800
|
+
store = ContextStore(temp_db)
|
|
801
|
+
store.record_event("commit", "/repo", {"message": "Test"})
|
|
802
|
+
|
|
803
|
+
result = visualize_context(store, output_format="html")
|
|
804
|
+
assert "<!DOCTYPE html>" in result
|
|
805
|
+
store.close()
|
|
806
|
+
|
|
807
|
+
def test_raises_for_unknown_format(self, temp_db: Path) -> None:
|
|
808
|
+
"""Test that unknown format raises ValueError."""
|
|
809
|
+
store = ContextStore(temp_db)
|
|
810
|
+
|
|
811
|
+
with pytest.raises(ValueError) as exc_info:
|
|
812
|
+
visualize_context(store, output_format="invalid")
|
|
813
|
+
|
|
814
|
+
assert "Unknown output format" in str(exc_info.value)
|
|
815
|
+
store.close()
|
|
816
|
+
|
|
817
|
+
def test_respects_limit(self, temp_db: Path) -> None:
|
|
818
|
+
"""Test that limit parameter is respected."""
|
|
819
|
+
store = ContextStore(temp_db)
|
|
820
|
+
# Create many events
|
|
821
|
+
for i in range(10):
|
|
822
|
+
store.record_event("commit", "/repo", {"message": f"Commit {i}"})
|
|
823
|
+
|
|
824
|
+
result = visualize_context(store, output_format="ascii", limit=3)
|
|
825
|
+
# Should only show limited events
|
|
826
|
+
store.close()
|
|
827
|
+
|
|
828
|
+
def test_filters_by_event_type(self, temp_db: Path) -> None:
|
|
829
|
+
"""Test that event_types filter works."""
|
|
830
|
+
store = ContextStore(temp_db)
|
|
831
|
+
store.record_event("commit", "/repo", {"message": "Commit"}, ref="c1")
|
|
832
|
+
store.record_event("push", "/repo", {"remote": "portal"}, ref="c1")
|
|
833
|
+
|
|
834
|
+
result = visualize_context(
|
|
835
|
+
store,
|
|
836
|
+
output_format="mermaid",
|
|
837
|
+
event_types=["commit"],
|
|
838
|
+
)
|
|
839
|
+
# Should only include commit events
|
|
840
|
+
assert "COMMIT" in result
|
|
841
|
+
store.close()
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# ---- GraphData Tests -----------------------------------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class TestGraphData:
|
|
848
|
+
"""Tests for GraphData class."""
|
|
849
|
+
|
|
850
|
+
def test_from_context_store(self, temp_db: Path) -> None:
|
|
851
|
+
"""Test building GraphData from context store."""
|
|
852
|
+
store = ContextStore(temp_db)
|
|
853
|
+
event = store.record_event(
|
|
854
|
+
"commit",
|
|
855
|
+
"/repo",
|
|
856
|
+
{"message": "Test"},
|
|
857
|
+
rationale="Test rationale",
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
data = GraphData.from_context_store(store)
|
|
861
|
+
|
|
862
|
+
assert len(data.events) == 1
|
|
863
|
+
assert data.events[0].id == event.id
|
|
864
|
+
assert event.id in data.annotations
|
|
865
|
+
store.close()
|
|
866
|
+
|
|
867
|
+
def test_from_context_store_with_limit(self, temp_db: Path) -> None:
|
|
868
|
+
"""Test that limit is respected."""
|
|
869
|
+
store = ContextStore(temp_db)
|
|
870
|
+
for i in range(10):
|
|
871
|
+
store.record_event("commit", "/repo", {"message": f"Commit {i}"})
|
|
872
|
+
|
|
873
|
+
data = GraphData.from_context_store(store, limit=5)
|
|
874
|
+
|
|
875
|
+
assert len(data.events) == 5
|
|
876
|
+
store.close()
|
|
877
|
+
|
|
878
|
+
def test_from_context_store_with_event_types(self, temp_db: Path) -> None:
|
|
879
|
+
"""Test filtering by event types."""
|
|
880
|
+
store = ContextStore(temp_db)
|
|
881
|
+
store.record_event("commit", "/repo", {"message": "Commit"})
|
|
882
|
+
store.record_event("push", "/repo", {"remote": "portal"})
|
|
883
|
+
|
|
884
|
+
data = GraphData.from_context_store(store, event_types=["commit"])
|
|
885
|
+
|
|
886
|
+
assert len(data.events) == 1
|
|
887
|
+
assert data.events[0].event_type == "commit"
|
|
888
|
+
store.close()
|
|
889
|
+
|
|
890
|
+
def test_from_context_store_includes_edges(self, temp_db: Path) -> None:
|
|
891
|
+
"""Test that edges between events are included."""
|
|
892
|
+
store = ContextStore(temp_db)
|
|
893
|
+
event1 = store.record_event("commit", "/repo", {"message": "First"})
|
|
894
|
+
event2 = store.record_event("push", "/repo", {"remote": "portal"})
|
|
895
|
+
store.add_edge(event2.id, event1.id, "caused_by")
|
|
896
|
+
|
|
897
|
+
data = GraphData.from_context_store(store)
|
|
898
|
+
|
|
899
|
+
assert len(data.edges) == 1
|
|
900
|
+
assert data.edges[0].source_id == event2.id
|
|
901
|
+
assert data.edges[0].target_id == event1.id
|
|
902
|
+
store.close()
|