PlanOpticon

planopticon / tests / test_agent_skills.py
Blame History Raw 628 lines
1
"""Tests for agent skill execute() methods with mocked context."""
2
3
import json
4
from dataclasses import dataclass
5
from unittest.mock import MagicMock
6
7
import pytest
8
9
from video_processor.agent.skills.base import (
10
AgentContext,
11
Artifact,
12
_skills,
13
)
14
15
# ---------------------------------------------------------------------------
16
# Fixtures
17
# ---------------------------------------------------------------------------
18
19
20
@pytest.fixture(autouse=True)
21
def _clean_skill_registry():
22
"""Save and restore the global skill registry between tests."""
23
original = dict(_skills)
24
yield
25
_skills.clear()
26
_skills.update(original)
27
28
29
@dataclass
30
class FakeEntity:
31
name: str
32
type: str
33
34
def __str__(self):
35
return self.name
36
37
38
class FakeQueryResult:
39
"""Mimics QueryResult.to_text()."""
40
41
def __init__(self, text="Stats: 10 entities, 5 relationships"):
42
self._text = text
43
44
def to_text(self):
45
return self._text
46
47
48
def _make_context(
49
chat_response="# Generated Content\n\nSome markdown content.",
50
planning_entities=None,
51
):
52
"""Build an AgentContext with mocked query_engine and provider_manager."""
53
ctx = AgentContext()
54
55
qe = MagicMock()
56
qe.stats.return_value = FakeQueryResult("Stats: 10 entities, 5 rels")
57
qe.entities.return_value = FakeQueryResult("Entity1, Entity2")
58
qe.relationships.return_value = FakeQueryResult("Entity1 -> Entity2")
59
ctx.query_engine = qe
60
61
pm = MagicMock()
62
pm.chat.return_value = chat_response
63
ctx.provider_manager = pm
64
65
ctx.knowledge_graph = MagicMock()
66
67
if planning_entities is not None:
68
ctx.planning_entities = planning_entities
69
else:
70
ctx.planning_entities = [
71
FakeEntity(name="Auth system", type="feature"),
72
FakeEntity(name="Launch v1", type="milestone"),
73
FakeEntity(name="Must be fast", type="constraint"),
74
FakeEntity(name="Build dashboard", type="goal"),
75
FakeEntity(name="API depends on auth", type="dependency"),
76
FakeEntity(name="User login", type="requirement"),
77
]
78
79
return ctx
80
81
82
# ---------------------------------------------------------------------------
83
# ProjectPlanSkill
84
# ---------------------------------------------------------------------------
85
86
87
class TestProjectPlanSkill:
88
def test_execute_returns_artifact(self):
89
from video_processor.agent.skills.project_plan import ProjectPlanSkill
90
91
skill = ProjectPlanSkill()
92
ctx = _make_context()
93
artifact = skill.execute(ctx)
94
95
assert isinstance(artifact, Artifact)
96
assert artifact.artifact_type == "project_plan"
97
assert artifact.format == "markdown"
98
assert len(artifact.content) > 0
99
100
def test_execute_calls_provider(self):
101
from video_processor.agent.skills.project_plan import ProjectPlanSkill
102
103
skill = ProjectPlanSkill()
104
ctx = _make_context()
105
skill.execute(ctx)
106
107
ctx.provider_manager.chat.assert_called_once()
108
call_args = ctx.provider_manager.chat.call_args
109
messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][0]
110
assert len(messages) == 1
111
assert messages[0]["role"] == "user"
112
113
def test_execute_queries_graph(self):
114
from video_processor.agent.skills.project_plan import ProjectPlanSkill
115
116
skill = ProjectPlanSkill()
117
ctx = _make_context()
118
skill.execute(ctx)
119
120
ctx.query_engine.stats.assert_called_once()
121
ctx.query_engine.entities.assert_called_once()
122
ctx.query_engine.relationships.assert_called_once()
123
124
125
# ---------------------------------------------------------------------------
126
# PRDSkill
127
# ---------------------------------------------------------------------------
128
129
130
class TestPRDSkill:
131
def test_execute_returns_artifact(self):
132
from video_processor.agent.skills.prd import PRDSkill
133
134
skill = PRDSkill()
135
ctx = _make_context()
136
artifact = skill.execute(ctx)
137
138
assert isinstance(artifact, Artifact)
139
assert artifact.artifact_type == "prd"
140
assert artifact.format == "markdown"
141
142
def test_execute_filters_relevant_entities(self):
143
from video_processor.agent.skills.prd import PRDSkill
144
145
skill = PRDSkill()
146
ctx = _make_context()
147
skill.execute(ctx)
148
149
# Should still call provider
150
ctx.provider_manager.chat.assert_called_once()
151
152
def test_execute_with_no_relevant_entities(self):
153
from video_processor.agent.skills.prd import PRDSkill
154
155
skill = PRDSkill()
156
ctx = _make_context(
157
planning_entities=[
158
FakeEntity(name="Some goal", type="goal"),
159
]
160
)
161
artifact = skill.execute(ctx)
162
163
assert isinstance(artifact, Artifact)
164
assert artifact.artifact_type == "prd"
165
166
167
# ---------------------------------------------------------------------------
168
# RoadmapSkill
169
# ---------------------------------------------------------------------------
170
171
172
class TestRoadmapSkill:
173
def test_execute_returns_artifact(self):
174
from video_processor.agent.skills.roadmap import RoadmapSkill
175
176
skill = RoadmapSkill()
177
ctx = _make_context()
178
artifact = skill.execute(ctx)
179
180
assert isinstance(artifact, Artifact)
181
assert artifact.artifact_type == "roadmap"
182
assert artifact.format == "markdown"
183
184
def test_execute_with_no_relevant_entities(self):
185
from video_processor.agent.skills.roadmap import RoadmapSkill
186
187
skill = RoadmapSkill()
188
ctx = _make_context(
189
planning_entities=[
190
FakeEntity(name="Some constraint", type="constraint"),
191
]
192
)
193
artifact = skill.execute(ctx)
194
195
assert isinstance(artifact, Artifact)
196
197
198
# ---------------------------------------------------------------------------
199
# TaskBreakdownSkill
200
# ---------------------------------------------------------------------------
201
202
203
class TestTaskBreakdownSkill:
204
def test_execute_returns_artifact_json(self):
205
from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill
206
207
tasks_json = json.dumps(
208
[
209
{
210
"id": "T1",
211
"title": "Setup",
212
"description": "Init",
213
"depends_on": [],
214
"priority": "high",
215
"estimate": "1d",
216
"assignee_role": "dev",
217
},
218
]
219
)
220
skill = TaskBreakdownSkill()
221
ctx = _make_context(chat_response=tasks_json)
222
artifact = skill.execute(ctx)
223
224
assert isinstance(artifact, Artifact)
225
assert artifact.artifact_type == "task_list"
226
assert artifact.format == "json"
227
assert "tasks" in artifact.metadata
228
assert len(artifact.metadata["tasks"]) == 1
229
230
def test_execute_with_non_json_response(self):
231
from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill
232
233
skill = TaskBreakdownSkill()
234
ctx = _make_context(chat_response="Not valid JSON at all")
235
artifact = skill.execute(ctx)
236
237
assert isinstance(artifact, Artifact)
238
assert artifact.artifact_type == "task_list"
239
240
def test_execute_with_no_relevant_entities(self):
241
from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill
242
243
tasks_json = json.dumps([])
244
skill = TaskBreakdownSkill()
245
ctx = _make_context(
246
chat_response=tasks_json,
247
planning_entities=[FakeEntity(name="X", type="constraint")],
248
)
249
artifact = skill.execute(ctx)
250
assert artifact.metadata["tasks"] == []
251
252
253
# ---------------------------------------------------------------------------
254
# DocGeneratorSkill
255
# ---------------------------------------------------------------------------
256
257
258
class TestDocGeneratorSkill:
259
def test_execute_default_type(self):
260
from video_processor.agent.skills.doc_generator import DocGeneratorSkill
261
262
skill = DocGeneratorSkill()
263
ctx = _make_context()
264
artifact = skill.execute(ctx)
265
266
assert isinstance(artifact, Artifact)
267
assert artifact.artifact_type == "document"
268
assert artifact.format == "markdown"
269
assert artifact.metadata["doc_type"] == "technical_doc"
270
271
def test_execute_adr_type(self):
272
from video_processor.agent.skills.doc_generator import DocGeneratorSkill
273
274
skill = DocGeneratorSkill()
275
ctx = _make_context()
276
artifact = skill.execute(ctx, doc_type="adr")
277
278
assert artifact.metadata["doc_type"] == "adr"
279
280
def test_execute_meeting_notes_type(self):
281
from video_processor.agent.skills.doc_generator import DocGeneratorSkill
282
283
skill = DocGeneratorSkill()
284
ctx = _make_context()
285
artifact = skill.execute(ctx, doc_type="meeting_notes")
286
287
assert artifact.metadata["doc_type"] == "meeting_notes"
288
289
def test_execute_unknown_type_falls_back(self):
290
from video_processor.agent.skills.doc_generator import DocGeneratorSkill
291
292
skill = DocGeneratorSkill()
293
ctx = _make_context()
294
artifact = skill.execute(ctx, doc_type="unknown_type")
295
296
# Falls back to technical_doc prompt
297
assert artifact.artifact_type == "document"
298
299
300
# ---------------------------------------------------------------------------
301
# RequirementsChatSkill
302
# ---------------------------------------------------------------------------
303
304
305
class TestRequirementsChatSkill:
306
def test_execute_returns_artifact(self):
307
from video_processor.agent.skills.requirements_chat import RequirementsChatSkill
308
309
questions = {
310
"questions": [
311
{"id": "Q1", "category": "goals", "question": "What?", "context": "Why"},
312
]
313
}
314
skill = RequirementsChatSkill()
315
ctx = _make_context(chat_response=json.dumps(questions))
316
artifact = skill.execute(ctx)
317
318
assert isinstance(artifact, Artifact)
319
assert artifact.artifact_type == "requirements"
320
assert artifact.format == "json"
321
assert artifact.metadata["stage"] == "questionnaire"
322
323
def test_gather_requirements(self):
324
from video_processor.agent.skills.requirements_chat import RequirementsChatSkill
325
326
reqs = {
327
"goals": ["Build auth"],
328
"constraints": ["Budget < 10k"],
329
"priorities": ["Security"],
330
"scope": {"in_scope": ["Login"], "out_of_scope": ["SSO"]},
331
}
332
skill = RequirementsChatSkill()
333
ctx = _make_context(chat_response=json.dumps(reqs))
334
result = skill.gather_requirements(ctx, {"Q1": "We need auth", "Q2": "Budget is limited"})
335
336
assert isinstance(result, dict)
337
338
def test_gather_requirements_non_json_response(self):
339
from video_processor.agent.skills.requirements_chat import RequirementsChatSkill
340
341
skill = RequirementsChatSkill()
342
ctx = _make_context(chat_response="Not JSON")
343
result = skill.gather_requirements(ctx, {"Q1": "answer"})
344
345
assert isinstance(result, dict)
346
347
348
# ---------------------------------------------------------------------------
349
# Skill metadata
350
# ---------------------------------------------------------------------------
351
352
353
class TestSkillMetadata:
354
def test_project_plan_name(self):
355
from video_processor.agent.skills.project_plan import ProjectPlanSkill
356
357
assert ProjectPlanSkill.name == "project_plan"
358
359
def test_prd_name(self):
360
from video_processor.agent.skills.prd import PRDSkill
361
362
assert PRDSkill.name == "prd"
363
364
def test_roadmap_name(self):
365
from video_processor.agent.skills.roadmap import RoadmapSkill
366
367
assert RoadmapSkill.name == "roadmap"
368
369
def test_task_breakdown_name(self):
370
from video_processor.agent.skills.task_breakdown import TaskBreakdownSkill
371
372
assert TaskBreakdownSkill.name == "task_breakdown"
373
374
def test_doc_generator_name(self):
375
from video_processor.agent.skills.doc_generator import DocGeneratorSkill
376
377
assert DocGeneratorSkill.name == "doc_generator"
378
379
def test_requirements_chat_name(self):
380
from video_processor.agent.skills.requirements_chat import RequirementsChatSkill
381
382
assert RequirementsChatSkill.name == "requirements_chat"
383
384
def test_can_execute_with_context(self):
385
from video_processor.agent.skills.project_plan import ProjectPlanSkill
386
387
skill = ProjectPlanSkill()
388
ctx = _make_context()
389
assert skill.can_execute(ctx) is True
390
391
def test_can_execute_without_kg(self):
392
from video_processor.agent.skills.project_plan import ProjectPlanSkill
393
394
skill = ProjectPlanSkill()
395
ctx = _make_context()
396
ctx.knowledge_graph = None
397
assert skill.can_execute(ctx) is False
398
399
def test_can_execute_without_provider(self):
400
from video_processor.agent.skills.project_plan import ProjectPlanSkill
401
402
skill = ProjectPlanSkill()
403
ctx = _make_context()
404
ctx.provider_manager = None
405
assert skill.can_execute(ctx) is False
406
407
408
# ---------------------------------------------------------------------------
409
# WikiGeneratorSkill
410
# ---------------------------------------------------------------------------
411
412
413
class TestWikiGeneratorSkill:
414
def _sample_kg_data(self):
415
return {
416
"nodes": [
417
{
418
"name": "Python",
419
"type": "technology",
420
"descriptions": ["A programming language"],
421
},
422
{
423
"name": "Alice",
424
"type": "person",
425
"descriptions": ["Lead developer"],
426
},
427
{
428
"name": "FastAPI",
429
"type": "technology",
430
"descriptions": ["Web framework"],
431
},
432
],
433
"relationships": [
434
{"source": "Alice", "target": "Python", "type": "uses"},
435
{"source": "FastAPI", "target": "Python", "type": "built_with"},
436
],
437
}
438
439
def test_generate_wiki(self):
440
from video_processor.agent.skills.wiki_generator import generate_wiki
441
442
pages = generate_wiki(self._sample_kg_data(), title="Test Wiki")
443
444
assert "Home" in pages
445
assert "_Sidebar" in pages
446
assert "Test Wiki" in pages["Home"]
447
assert "3" in pages["Home"] # 3 entities
448
assert "2" in pages["Home"] # 2 relationships
449
450
# Entity pages should exist
451
assert "Python" in pages
452
assert "Alice" in pages
453
assert "FastAPI" in pages
454
455
# Type index pages should exist
456
assert "Technology" in pages
457
assert "Person" in pages
458
459
# Alice's page should reference Python
460
assert "Python" in pages["Alice"]
461
assert "uses" in pages["Alice"]
462
463
def test_generate_wiki_with_artifacts(self):
464
from video_processor.agent.skills.wiki_generator import generate_wiki
465
466
art = Artifact(
467
name="Project Plan",
468
content="# Plan\n\nDo the thing.",
469
artifact_type="project_plan",
470
format="markdown",
471
)
472
pages = generate_wiki(self._sample_kg_data(), artifacts=[art])
473
474
assert "Project-Plan" in pages
475
assert "Do the thing." in pages["Project-Plan"]
476
assert "Planning Artifacts" in pages["Home"]
477
478
def test_write_wiki(self, tmp_path):
479
from video_processor.agent.skills.wiki_generator import write_wiki
480
481
pages = {
482
"Home": "# Home\n\nWelcome.",
483
"Page-One": "# Page One\n\nContent.",
484
}
485
paths = write_wiki(pages, tmp_path / "wiki")
486
487
assert len(paths) == 2
488
assert (tmp_path / "wiki" / "Home.md").exists()
489
assert (tmp_path / "wiki" / "Page-One.md").exists()
490
assert "Welcome." in (tmp_path / "wiki" / "Home.md").read_text()
491
492
def test_sanitize_filename(self):
493
from video_processor.agent.skills.wiki_generator import _sanitize_filename
494
495
assert _sanitize_filename("Hello World") == "Hello-World"
496
assert _sanitize_filename("path/to\\file") == "path-to-file"
497
assert _sanitize_filename("version.2") == "version-2"
498
499
def test_wiki_link(self):
500
from video_processor.agent.skills.wiki_generator import _wiki_link
501
502
result = _wiki_link("My Page")
503
assert result == "[My Page](My-Page)"
504
505
result = _wiki_link("Simple")
506
assert result == "[Simple](Simple)"
507
508
509
# ---------------------------------------------------------------------------
510
# NotesExportSkill
511
# ---------------------------------------------------------------------------
512
513
514
class TestNotesExportSkill:
515
def _sample_kg_data(self):
516
return {
517
"nodes": [
518
{
519
"name": "Python",
520
"type": "technology",
521
"descriptions": ["A programming language"],
522
},
523
{
524
"name": "Alice",
525
"type": "person",
526
"descriptions": ["Lead developer"],
527
},
528
],
529
"relationships": [
530
{"source": "Alice", "target": "Python", "type": "uses"},
531
],
532
}
533
534
def test_export_to_obsidian(self, tmp_path):
535
from video_processor.agent.skills.notes_export import export_to_obsidian
536
537
output_dir = tmp_path / "obsidian_vault"
538
export_to_obsidian(self._sample_kg_data(), output_dir)
539
540
assert output_dir.is_dir()
541
542
# Check entity files exist
543
python_file = output_dir / "Python.md"
544
alice_file = output_dir / "Alice.md"
545
assert python_file.exists()
546
assert alice_file.exists()
547
548
# Check frontmatter in entity file
549
python_content = python_file.read_text()
550
assert "---" in python_content
551
assert "type: technology" in python_content
552
assert "# Python" in python_content
553
554
# Check wiki-links in Alice file
555
alice_content = alice_file.read_text()
556
assert "[[Python]]" in alice_content
557
assert "uses" in alice_content
558
559
# Check index file
560
index_file = output_dir / "_Index.md"
561
assert index_file.exists()
562
index_content = index_file.read_text()
563
assert "[[Python]]" in index_content
564
assert "[[Alice]]" in index_content
565
566
def test_export_to_obsidian_with_artifacts(self, tmp_path):
567
from video_processor.agent.skills.notes_export import export_to_obsidian
568
569
art = Artifact(
570
name="Test Plan",
571
content="# Plan\n\nSteps here.",
572
artifact_type="project_plan",
573
format="markdown",
574
)
575
output_dir = tmp_path / "obsidian_arts"
576
export_to_obsidian(self._sample_kg_data(), output_dir, artifacts=[art])
577
578
art_file = output_dir / "Test Plan.md"
579
assert art_file.exists()
580
art_content = art_file.read_text()
581
assert "artifact" in art_content
582
assert "Steps here." in art_content
583
584
def test_export_to_notion_md(self, tmp_path):
585
from video_processor.agent.skills.notes_export import export_to_notion_md
586
587
output_dir = tmp_path / "notion_export"
588
export_to_notion_md(self._sample_kg_data(), output_dir)
589
590
assert output_dir.is_dir()
591
592
# Check CSV database file
593
csv_file = output_dir / "entities_database.csv"
594
assert csv_file.exists()
595
csv_content = csv_file.read_text()
596
assert "Name" in csv_content
597
assert "Type" in csv_content
598
assert "Python" in csv_content
599
assert "Alice" in csv_content
600
601
# Check entity markdown files
602
python_file = output_dir / "Python.md"
603
assert python_file.exists()
604
python_content = python_file.read_text()
605
assert "# Python" in python_content
606
assert "technology" in python_content
607
608
# Check overview file
609
overview_file = output_dir / "Overview.md"
610
assert overview_file.exists()
611
612
def test_export_to_notion_md_with_artifacts(self, tmp_path):
613
from video_processor.agent.skills.notes_export import export_to_notion_md
614
615
art = Artifact(
616
name="Roadmap",
617
content="# Roadmap\n\nQ1 goals.",
618
artifact_type="roadmap",
619
format="markdown",
620
)
621
output_dir = tmp_path / "notion_arts"
622
export_to_notion_md(self._sample_kg_data(), output_dir, artifacts=[art])
623
624
art_file = output_dir / "Roadmap.md"
625
assert art_file.exists()
626
art_content = art_file.read_text()
627
assert "Q1 goals." in art_content
628

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button