cgcardona / muse public
test_muse_emotion_diff.py python
807 lines 27.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse emotion-diff`` — CLI interface, service logic, and output formats.
2
3 Covers:
4 - Emotion vector lookup from canonical table.
5 - Inference from commit metadata (tempo-based).
6 - Per-dimension delta computation and drift distance.
7 - Narrative generation across drift magnitudes.
8 - Explicit-tag sourcing, inferred sourcing, and mixed sourcing.
9 - CLI text and JSON output formats.
10 - Edge cases: same commit, no metadata, unknown labels, missing commits.
11 - Commit ref resolution: HEAD, HEAD~N, abbreviated hash.
12 - --track and --section flag handling (stub boundary).
13
14 Naming convention: ``test_<behaviour>_<scenario>``
15 """
16 from __future__ import annotations
17
18 import datetime
19 import hashlib
20 import json
21 import os
22 import pathlib
23 import uuid
24 from collections.abc import AsyncGenerator
25
26 import pytest
27 import pytest_asyncio
28 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
29 from sqlalchemy.pool import StaticPool
30 from typer.testing import CliRunner
31
32 from maestro.db.database import Base
33 import maestro.muse_cli.models # noqa: F401 — registers MuseCli* with Base.metadata
34 from maestro.muse_cli.app import cli
35 from maestro.muse_cli.commands.emotion_diff import (
36 _emotion_diff_async,
37 render_json,
38 render_text,
39 )
40 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot, MuseCliTag
41 from maestro.muse_cli.errors import ExitCode
42 from maestro.services.muse_emotion_diff import (
43 EMOTION_DIMENSIONS,
44 EMOTION_VECTORS,
45 EmotionDiffResult,
46 EmotionDimDelta,
47 EmotionVector,
48 build_narrative,
49 compute_dimension_deltas,
50 compute_emotion_diff,
51 get_emotion_tag,
52 infer_vector_from_metadata,
53 resolve_commit_id,
54 vector_from_label,
55 )
56
57 runner = CliRunner()
58
59 # ---------------------------------------------------------------------------
60 # Fixtures
61 # ---------------------------------------------------------------------------
62
63 _REPO_ID = "test-repo-emotion-001"
64 _BRANCH = "main"
65 _COUNTER = 0
66
67
68 def _unique_hash(prefix: str) -> str:
69 global _COUNTER
70 _COUNTER += 1
71 raw = f"{prefix}-{_COUNTER}"
72 return hashlib.sha256(raw.encode()).hexdigest()
73
74
75 def _make_snapshot(manifest: dict[str, str] | None = None) -> MuseCliSnapshot:
76 m = manifest or {"placeholder.mid": _unique_hash("blob")}
77 parts = sorted(f"{k}:{v}" for k, v in m.items())
78 snap_id = hashlib.sha256("|".join(parts).encode()).hexdigest()
79 return MuseCliSnapshot(snapshot_id=snap_id, manifest=m)
80
81
82 def _make_commit(
83 snapshot: MuseCliSnapshot,
84 branch: str = _BRANCH,
85 parent: MuseCliCommit | None = None,
86 seq: int = 0,
87 metadata: dict[str, object] | None = None,
88 ) -> MuseCliCommit:
89 cid = _unique_hash(f"commit-{branch}-{seq}")
90 # Use epoch offset to avoid day-out-of-range when seq is large
91 base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
92 committed_at = base + datetime.timedelta(hours=seq)
93 return MuseCliCommit(
94 commit_id=cid,
95 repo_id=_REPO_ID,
96 branch=branch,
97 snapshot_id=snapshot.snapshot_id,
98 parent_commit_id=parent.commit_id if parent else None,
99 message=f"commit seq={seq}",
100 author="test",
101 committed_at=committed_at,
102 commit_metadata=metadata,
103 )
104
105
106 @pytest_asyncio.fixture
107 async def db_session() -> AsyncGenerator[AsyncSession, None]:
108 """In-memory SQLite session with the full Maestro schema."""
109 engine = create_async_engine(
110 "sqlite+aiosqlite:///:memory:",
111 connect_args={"check_same_thread": False},
112 poolclass=StaticPool,
113 )
114 async with engine.begin() as conn:
115 await conn.run_sync(Base.metadata.create_all)
116 factory = async_sessionmaker(bind=engine, expire_on_commit=False)
117 async with factory() as session:
118 yield session
119 async with engine.begin() as conn:
120 await conn.run_sync(Base.metadata.drop_all)
121 await engine.dispose()
122
123
124 def _init_muse_repo(root: pathlib.Path, branch: str = "main") -> str:
125 """Create a minimal .muse/ layout."""
126 rid = str(uuid.uuid4())
127 muse = root / ".muse"
128 (muse / "refs" / "heads").mkdir(parents=True)
129 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
130 (muse / "HEAD").write_text(f"refs/heads/{branch}")
131 return rid
132
133
134 def _write_head_ref(root: pathlib.Path, commit_id: str, branch: str = "main") -> None:
135 muse = root / ".muse"
136 (muse / "refs" / "heads" / branch).write_text(commit_id)
137
138
139 # ---------------------------------------------------------------------------
140 # Unit — EmotionVector
141 # ---------------------------------------------------------------------------
142
143
144 def test_emotion_vector_drift_from_identical_is_zero() -> None:
145 """Drift from a vector to itself is exactly 0.0."""
146 vec = EmotionVector(energy=0.5, valence=0.5, tension=0.5, darkness=0.5)
147 assert vec.drift_from(vec) == 0.0
148
149
150 def test_emotion_vector_drift_from_opposite_is_max() -> None:
151 """Drift between (0,0,0,0) and (1,1,1,1) is 2.0."""
152 lo = EmotionVector(energy=0.0, valence=0.0, tension=0.0, darkness=0.0)
153 hi = EmotionVector(energy=1.0, valence=1.0, tension=1.0, darkness=1.0)
154 assert abs(hi.drift_from(lo) - 2.0) < 0.001
155
156
157 def test_emotion_vector_as_tuple_order() -> None:
158 """as_tuple() returns (energy, valence, tension, darkness) order."""
159 vec = EmotionVector(energy=0.1, valence=0.2, tension=0.3, darkness=0.4)
160 assert vec.as_tuple() == (0.1, 0.2, 0.3, 0.4)
161
162
163 # ---------------------------------------------------------------------------
164 # Unit — vector_from_label
165 # ---------------------------------------------------------------------------
166
167
168 def test_vector_from_label_known_label_returns_vector() -> None:
169 """Known emotion labels return non-None EmotionVector."""
170 for label in EMOTION_VECTORS:
171 result = vector_from_label(label)
172 assert result is not None, f"Expected vector for {label!r}"
173 assert isinstance(result, EmotionVector)
174
175
176 def test_vector_from_label_unknown_returns_none() -> None:
177 """Unknown emotion labels return None."""
178 assert vector_from_label("nonexistent_emotion") is None
179
180
181 def test_vector_from_label_case_insensitive() -> None:
182 """Label lookup is case-insensitive."""
183 assert vector_from_label("JOYFUL") == vector_from_label("joyful")
184
185
186 def test_vector_from_label_joyful_high_valence() -> None:
187 """Joyful vector has valence > 0.8."""
188 vec = vector_from_label("joyful")
189 assert vec is not None
190 assert vec.valence > 0.8
191
192
193 def test_vector_from_label_melancholic_low_energy() -> None:
194 """Melancholic vector has energy < 0.5."""
195 vec = vector_from_label("melancholic")
196 assert vec is not None
197 assert vec.energy < 0.5
198
199
200 def test_vector_from_label_tense_high_tension() -> None:
201 """Tense vector has tension > 0.7."""
202 vec = vector_from_label("tense")
203 assert vec is not None
204 assert vec.tension > 0.7
205
206
207 # ---------------------------------------------------------------------------
208 # Unit — infer_vector_from_metadata
209 # ---------------------------------------------------------------------------
210
211
212 def test_infer_vector_from_metadata_none_returns_neutral() -> None:
213 """None metadata returns a neutral midpoint vector."""
214 vec = infer_vector_from_metadata(None)
215 assert vec.energy == 0.50
216 assert vec.valence == 0.50
217 assert vec.tension == 0.50
218 assert vec.darkness == 0.50
219
220
221 def test_infer_vector_from_metadata_no_tempo_returns_neutral() -> None:
222 """Metadata without tempo_bpm returns a neutral vector."""
223 vec = infer_vector_from_metadata({"key": "Am"})
224 assert vec.energy == 0.50
225
226
227 def test_infer_vector_from_metadata_fast_tempo_high_energy() -> None:
228 """Fast tempo (180 BPM) yields high energy."""
229 vec = infer_vector_from_metadata({"tempo_bpm": 180.0})
230 assert vec.energy > 0.8
231
232
233 def test_infer_vector_from_metadata_slow_tempo_low_energy() -> None:
234 """Slow tempo (60 BPM) yields low energy."""
235 vec = infer_vector_from_metadata({"tempo_bpm": 60.0})
236 assert vec.energy == 0.0
237
238
239 def test_infer_vector_from_metadata_tempo_not_numeric_returns_neutral() -> None:
240 """Non-numeric tempo_bpm falls back to neutral."""
241 vec = infer_vector_from_metadata({"tempo_bpm": "allegro"})
242 assert vec.energy == 0.50
243
244
245 def test_infer_vector_dimensions_in_range() -> None:
246 """All inferred dimensions are in [0.0, 1.0] for any reasonable tempo."""
247 for bpm in [40, 60, 80, 100, 120, 140, 160, 180, 200]:
248 vec = infer_vector_from_metadata({"tempo_bpm": float(bpm)})
249 for dim_val in vec.as_tuple():
250 assert 0.0 <= dim_val <= 1.0, f"Dimension out of range at {bpm} BPM"
251
252
253 # ---------------------------------------------------------------------------
254 # Unit — compute_dimension_deltas
255 # ---------------------------------------------------------------------------
256
257
258 def test_compute_dimension_deltas_correct_order() -> None:
259 """Deltas are returned in EMOTION_DIMENSIONS order."""
260 a = EmotionVector(energy=0.3, valence=0.3, tension=0.4, darkness=0.6)
261 b = EmotionVector(energy=0.8, valence=0.9, tension=0.2, darkness=0.1)
262 deltas = compute_dimension_deltas(a, b)
263 assert len(deltas) == 4
264 assert tuple(d.dimension for d in deltas) == EMOTION_DIMENSIONS
265
266
267 def test_compute_dimension_deltas_positive_delta() -> None:
268 """Delta is positive when commit B > commit A."""
269 a = EmotionVector(energy=0.3, valence=0.3, tension=0.4, darkness=0.6)
270 b = EmotionVector(energy=0.8, valence=0.9, tension=0.2, darkness=0.1)
271 deltas = compute_dimension_deltas(a, b)
272 energy_delta = next(d for d in deltas if d.dimension == "energy")
273 assert energy_delta.delta > 0
274
275
276 def test_compute_dimension_deltas_negative_delta() -> None:
277 """Delta is negative when commit B < commit A."""
278 a = EmotionVector(energy=0.3, valence=0.3, tension=0.4, darkness=0.6)
279 b = EmotionVector(energy=0.8, valence=0.9, tension=0.2, darkness=0.1)
280 deltas = compute_dimension_deltas(a, b)
281 darkness_delta = next(d for d in deltas if d.dimension == "darkness")
282 assert darkness_delta.delta < 0
283
284
285 def test_compute_dimension_deltas_zero_when_same() -> None:
286 """Delta is 0 when both commits have the same vector."""
287 vec = EmotionVector(energy=0.5, valence=0.5, tension=0.5, darkness=0.5)
288 deltas = compute_dimension_deltas(vec, vec)
289 for d in deltas:
290 assert d.delta == 0.0
291
292
293 # ---------------------------------------------------------------------------
294 # Unit — build_narrative
295 # ---------------------------------------------------------------------------
296
297
298 def test_build_narrative_minimal_drift() -> None:
299 """Drift < 0.05 → 'unchanged' in narrative."""
300 dims = compute_dimension_deltas(
301 EmotionVector(0.5, 0.5, 0.5, 0.5),
302 EmotionVector(0.5, 0.5, 0.5, 0.5),
303 )
304 narrative = build_narrative("joyful", "joyful", dims, 0.01, "explicit_tags")
305 assert "unchanged" in narrative.lower()
306
307
308 def test_build_narrative_major_drift() -> None:
309 """Drift > 0.8 → 'major' or 'dramatic' in narrative."""
310 dims = compute_dimension_deltas(
311 EmotionVector(0.0, 0.0, 0.0, 0.0),
312 EmotionVector(1.0, 1.0, 1.0, 1.0),
313 )
314 narrative = build_narrative(None, None, dims, 1.5, "inferred")
315 assert "major" in narrative.lower() or "dramatic" in narrative.lower()
316
317
318 def test_build_narrative_includes_label_transition() -> None:
319 """Narrative includes label → label transition when both labels known."""
320 dims = compute_dimension_deltas(
321 EmotionVector(0.3, 0.3, 0.4, 0.6),
322 EmotionVector(0.8, 0.9, 0.2, 0.1),
323 )
324 narrative = build_narrative("melancholic", "joyful", dims, 0.97, "explicit_tags")
325 assert "melancholic" in narrative
326 assert "joyful" in narrative
327
328
329 def test_build_narrative_inferred_source_note() -> None:
330 """Inferred sourcing adds '[inferred' notice to narrative."""
331 dims = compute_dimension_deltas(
332 EmotionVector(0.5, 0.5, 0.5, 0.5),
333 EmotionVector(0.7, 0.7, 0.7, 0.7),
334 )
335 narrative = build_narrative(None, None, dims, 0.4, "inferred")
336 assert "inferred" in narrative.lower()
337
338
339 # ---------------------------------------------------------------------------
340 # Integration — get_emotion_tag
341 # ---------------------------------------------------------------------------
342
343
344 @pytest.mark.anyio
345 async def test_get_emotion_tag_returns_label_when_present(
346 db_session: AsyncSession,
347 ) -> None:
348 """get_emotion_tag returns the label portion of an emotion:* tag."""
349 snap = _make_snapshot()
350 commit = _make_commit(snap, seq=1)
351 db_session.add(snap)
352 db_session.add(commit)
353 db_session.add(
354 MuseCliTag(
355 repo_id=_REPO_ID,
356 commit_id=commit.commit_id,
357 tag="emotion:melancholic",
358 )
359 )
360 await db_session.flush()
361
362 label = await get_emotion_tag(db_session, _REPO_ID, commit.commit_id)
363 assert label == "melancholic"
364
365
366 @pytest.mark.anyio
367 async def test_get_emotion_tag_returns_none_when_absent(
368 db_session: AsyncSession,
369 ) -> None:
370 """get_emotion_tag returns None when no emotion:* tag exists."""
371 snap = _make_snapshot()
372 commit = _make_commit(snap, seq=2)
373 db_session.add(snap)
374 db_session.add(commit)
375 db_session.add(
376 MuseCliTag(repo_id=_REPO_ID, commit_id=commit.commit_id, tag="stage:rough-mix")
377 )
378 await db_session.flush()
379
380 label = await get_emotion_tag(db_session, _REPO_ID, commit.commit_id)
381 assert label is None
382
383
384 # ---------------------------------------------------------------------------
385 # Integration — resolve_commit_id
386 # ---------------------------------------------------------------------------
387
388
389 @pytest.mark.anyio
390 async def test_resolve_commit_id_head(db_session: AsyncSession) -> None:
391 """HEAD resolves to the most recent commit on the branch."""
392 snap = _make_snapshot()
393 commit = _make_commit(snap, seq=10)
394 db_session.add(snap)
395 db_session.add(commit)
396 await db_session.flush()
397
398 resolved = await resolve_commit_id(db_session, _REPO_ID, "HEAD", _BRANCH)
399 assert resolved == commit.commit_id
400
401
402 @pytest.mark.anyio
403 async def test_resolve_commit_id_head_tilde_1(db_session: AsyncSession) -> None:
404 """HEAD~1 resolves to the parent commit."""
405 snap1 = _make_snapshot()
406 snap2 = _make_snapshot()
407 c1 = _make_commit(snap1, seq=20)
408 c2 = _make_commit(snap2, seq=21, parent=c1)
409 for obj in (snap1, snap2, c1, c2):
410 db_session.add(obj)
411 await db_session.flush()
412
413 resolved = await resolve_commit_id(db_session, _REPO_ID, "HEAD~1", _BRANCH)
414 assert resolved == c1.commit_id
415
416
417 @pytest.mark.anyio
418 async def test_resolve_commit_id_head_tilde_beyond_root(
419 db_session: AsyncSession,
420 ) -> None:
421 """HEAD~N where N exceeds depth returns None."""
422 snap = _make_snapshot()
423 commit = _make_commit(snap, seq=30)
424 db_session.add(snap)
425 db_session.add(commit)
426 await db_session.flush()
427
428 resolved = await resolve_commit_id(db_session, _REPO_ID, "HEAD~5", _BRANCH)
429 assert resolved is None
430
431
432 @pytest.mark.anyio
433 async def test_resolve_commit_id_full_hash(db_session: AsyncSession) -> None:
434 """A full 64-char commit hash resolves to itself."""
435 snap = _make_snapshot()
436 commit = _make_commit(snap, seq=40)
437 db_session.add(snap)
438 db_session.add(commit)
439 await db_session.flush()
440
441 resolved = await resolve_commit_id(db_session, _REPO_ID, commit.commit_id, _BRANCH)
442 assert resolved == commit.commit_id
443
444
445 @pytest.mark.anyio
446 async def test_resolve_commit_id_abbreviated_hash(db_session: AsyncSession) -> None:
447 """An 8-char abbreviated hash resolves via prefix match."""
448 snap = _make_snapshot()
449 commit = _make_commit(snap, seq=50)
450 db_session.add(snap)
451 db_session.add(commit)
452 await db_session.flush()
453
454 short = commit.commit_id[:8]
455 resolved = await resolve_commit_id(db_session, _REPO_ID, short, _BRANCH)
456 assert resolved == commit.commit_id
457
458
459 @pytest.mark.anyio
460 async def test_resolve_commit_id_nonexistent_returns_none(
461 db_session: AsyncSession,
462 ) -> None:
463 """A non-existent ref returns None."""
464 resolved = await resolve_commit_id(db_session, _REPO_ID, "deadbeef", _BRANCH)
465 assert resolved is None
466
467
468 # ---------------------------------------------------------------------------
469 # Integration — compute_emotion_diff
470 # ---------------------------------------------------------------------------
471
472
473 @pytest.mark.anyio
474 async def test_emotion_diff_explicit_tags_correct_source(
475 db_session: AsyncSession,
476 ) -> None:
477 """Two commits with explicit emotion tags → source='explicit_tags'."""
478 snap1, snap2 = _make_snapshot(), _make_snapshot()
479 c1 = _make_commit(snap1, seq=60)
480 c2 = _make_commit(snap2, seq=61, parent=c1)
481 for obj in (snap1, snap2, c1, c2):
482 db_session.add(obj)
483 db_session.add(MuseCliTag(repo_id=_REPO_ID, commit_id=c1.commit_id, tag="emotion:melancholic"))
484 db_session.add(MuseCliTag(repo_id=_REPO_ID, commit_id=c2.commit_id, tag="emotion:joyful"))
485 await db_session.flush()
486
487 result = await compute_emotion_diff(
488 db_session,
489 repo_id=_REPO_ID,
490 commit_a=c1.commit_id,
491 commit_b=c2.commit_id,
492 branch=_BRANCH,
493 )
494 assert result.source == "explicit_tags"
495 assert result.label_a == "melancholic"
496 assert result.label_b == "joyful"
497
498
499 @pytest.mark.anyio
500 async def test_emotion_diff_outputs_readable_description(
501 db_session: AsyncSession,
502 ) -> None:
503 """Regression: emotion-diff produces a non-empty human-readable narrative."""
504 snap1, snap2 = _make_snapshot(), _make_snapshot()
505 c1 = _make_commit(snap1, seq=70)
506 c2 = _make_commit(snap2, seq=71, parent=c1)
507 for obj in (snap1, snap2, c1, c2):
508 db_session.add(obj)
509 db_session.add(MuseCliTag(repo_id=_REPO_ID, commit_id=c1.commit_id, tag="emotion:anxious"))
510 db_session.add(MuseCliTag(repo_id=_REPO_ID, commit_id=c2.commit_id, tag="emotion:cinematic"))
511 await db_session.flush()
512
513 result = await compute_emotion_diff(
514 db_session,
515 repo_id=_REPO_ID,
516 commit_a=c1.commit_id,
517 commit_b=c2.commit_id,
518 branch=_BRANCH,
519 )
520 assert result.narrative, "Narrative must be non-empty"
521 assert len(result.narrative) > 20, "Narrative should be a meaningful sentence"
522 # The shift from anxious to cinematic should be noted
523 assert "anxious" in result.narrative
524 assert "cinematic" in result.narrative
525
526
527 @pytest.mark.anyio
528 async def test_emotion_diff_inferred_source_no_tags(
529 db_session: AsyncSession,
530 ) -> None:
531 """Commits without emotion tags → source='inferred'."""
532 snap1, snap2 = _make_snapshot(), _make_snapshot()
533 c1 = _make_commit(snap1, seq=80, metadata={"tempo_bpm": 90.0})
534 c2 = _make_commit(snap2, seq=81, parent=c1, metadata={"tempo_bpm": 150.0})
535 for obj in (snap1, snap2, c1, c2):
536 db_session.add(obj)
537 await db_session.flush()
538
539 result = await compute_emotion_diff(
540 db_session,
541 repo_id=_REPO_ID,
542 commit_a=c1.commit_id,
543 commit_b=c2.commit_id,
544 branch=_BRANCH,
545 )
546 assert result.source == "inferred"
547 assert result.label_a is None
548 assert result.label_b is None
549 assert result.vector_a is not None
550 assert result.vector_b is not None
551 # Faster tempo → higher energy
552 assert result.vector_b.energy > result.vector_a.energy
553
554
555 @pytest.mark.anyio
556 async def test_emotion_diff_mixed_source_one_tag(
557 db_session: AsyncSession,
558 ) -> None:
559 """One commit with tag, one without → source='mixed'."""
560 snap1, snap2 = _make_snapshot(), _make_snapshot()
561 c1 = _make_commit(snap1, seq=90)
562 c2 = _make_commit(snap2, seq=91, parent=c1)
563 for obj in (snap1, snap2, c1, c2):
564 db_session.add(obj)
565 db_session.add(MuseCliTag(repo_id=_REPO_ID, commit_id=c1.commit_id, tag="emotion:dark"))
566 await db_session.flush()
567
568 result = await compute_emotion_diff(
569 db_session,
570 repo_id=_REPO_ID,
571 commit_a=c1.commit_id,
572 commit_b=c2.commit_id,
573 branch=_BRANCH,
574 )
575 assert result.source == "mixed"
576
577
578 @pytest.mark.anyio
579 async def test_emotion_diff_drift_is_nonnegative(
580 db_session: AsyncSession,
581 ) -> None:
582 """Drift is always ≥ 0."""
583 snap1, snap2 = _make_snapshot(), _make_snapshot()
584 c1 = _make_commit(snap1, seq=100)
585 c2 = _make_commit(snap2, seq=101, parent=c1)
586 for obj in (snap1, snap2, c1, c2):
587 db_session.add(obj)
588 await db_session.flush()
589
590 result = await compute_emotion_diff(
591 db_session,
592 repo_id=_REPO_ID,
593 commit_a=c1.commit_id,
594 commit_b=c2.commit_id,
595 branch=_BRANCH,
596 )
597 assert result.drift >= 0.0
598
599
600 @pytest.mark.anyio
601 async def test_emotion_diff_has_four_dimension_deltas(
602 db_session: AsyncSession,
603 ) -> None:
604 """Result always contains exactly 4 dimension deltas."""
605 snap1, snap2 = _make_snapshot(), _make_snapshot()
606 c1 = _make_commit(snap1, seq=110)
607 c2 = _make_commit(snap2, seq=111, parent=c1)
608 for obj in (snap1, snap2, c1, c2):
609 db_session.add(obj)
610 await db_session.flush()
611
612 result = await compute_emotion_diff(
613 db_session,
614 repo_id=_REPO_ID,
615 commit_a=c1.commit_id,
616 commit_b=c2.commit_id,
617 branch=_BRANCH,
618 )
619 assert len(result.dimensions) == 4
620 assert tuple(d.dimension for d in result.dimensions) == EMOTION_DIMENSIONS
621
622
623 @pytest.mark.anyio
624 async def test_emotion_diff_raises_for_unresolvable_commit(
625 db_session: AsyncSession,
626 ) -> None:
627 """ValueError is raised when commit_a cannot be resolved."""
628 with pytest.raises(ValueError, match="Cannot resolve commit ref"):
629 await compute_emotion_diff(
630 db_session,
631 repo_id=_REPO_ID,
632 commit_a="nonexistent00",
633 commit_b="alsobad00000",
634 branch=_BRANCH,
635 )
636
637
638 @pytest.mark.anyio
639 async def test_emotion_diff_track_filter_noted_in_result(
640 db_session: AsyncSession,
641 ) -> None:
642 """--track value is preserved in the result."""
643 snap1, snap2 = _make_snapshot(), _make_snapshot()
644 c1 = _make_commit(snap1, seq=120)
645 c2 = _make_commit(snap2, seq=121, parent=c1)
646 for obj in (snap1, snap2, c1, c2):
647 db_session.add(obj)
648 await db_session.flush()
649
650 result = await compute_emotion_diff(
651 db_session,
652 repo_id=_REPO_ID,
653 commit_a=c1.commit_id,
654 commit_b=c2.commit_id,
655 branch=_BRANCH,
656 track="keys",
657 )
658 assert result.track == "keys"
659
660
661 @pytest.mark.anyio
662 async def test_emotion_diff_section_filter_noted_in_result(
663 db_session: AsyncSession,
664 ) -> None:
665 """--section value is preserved in the result."""
666 snap1, snap2 = _make_snapshot(), _make_snapshot()
667 c1 = _make_commit(snap1, seq=130)
668 c2 = _make_commit(snap2, seq=131, parent=c1)
669 for obj in (snap1, snap2, c1, c2):
670 db_session.add(obj)
671 await db_session.flush()
672
673 result = await compute_emotion_diff(
674 db_session,
675 repo_id=_REPO_ID,
676 commit_a=c1.commit_id,
677 commit_b=c2.commit_id,
678 branch=_BRANCH,
679 section="chorus",
680 )
681 assert result.section == "chorus"
682
683
684 # ---------------------------------------------------------------------------
685 # Unit — renderers
686 # ---------------------------------------------------------------------------
687
688
689 def _make_diff_result(
690 label_a: str | None = "melancholic",
691 label_b: str | None = "joyful",
692 source: str = "explicit_tags",
693 ) -> EmotionDiffResult:
694 """Build a synthetic EmotionDiffResult for renderer tests."""
695 vec_a = vector_from_label(label_a) if label_a else EmotionVector(0.5, 0.5, 0.5, 0.5)
696 vec_b = vector_from_label(label_b) if label_b else EmotionVector(0.6, 0.6, 0.6, 0.6)
697 assert vec_a is not None
698 assert vec_b is not None
699 dims = compute_dimension_deltas(vec_a, vec_b)
700 drift = vec_b.drift_from(vec_a)
701 narrative = build_narrative(label_a, label_b, dims, drift, source)
702 return EmotionDiffResult(
703 commit_a="a1b2c3d4",
704 commit_b="f9e8d7c6",
705 source=source,
706 label_a=label_a,
707 label_b=label_b,
708 vector_a=vec_a,
709 vector_b=vec_b,
710 dimensions=dims,
711 drift=drift,
712 narrative=narrative,
713 track=None,
714 section=None,
715 )
716
717
718 def test_render_text_includes_commit_refs(capsys: pytest.CaptureFixture[str]) -> None:
719 """render_text output includes both commit short refs."""
720 render_text(_make_diff_result())
721 out = capsys.readouterr().out
722 assert "a1b2c3d4" in out
723 assert "f9e8d7c6" in out
724
725
726 def test_render_text_includes_labels(capsys: pytest.CaptureFixture[str]) -> None:
727 """render_text output includes emotion labels."""
728 render_text(_make_diff_result())
729 out = capsys.readouterr().out
730 assert "melancholic" in out
731 assert "joyful" in out
732
733
734 def test_render_text_includes_drift(capsys: pytest.CaptureFixture[str]) -> None:
735 """render_text output includes the drift value."""
736 render_text(_make_diff_result())
737 out = capsys.readouterr().out
738 assert "Drift" in out
739
740
741 def test_render_text_includes_all_dimensions(capsys: pytest.CaptureFixture[str]) -> None:
742 """render_text output includes all 4 dimension names."""
743 render_text(_make_diff_result())
744 out = capsys.readouterr().out
745 for dim in EMOTION_DIMENSIONS:
746 assert dim in out
747
748
749 def test_render_json_valid_json(capsys: pytest.CaptureFixture[str]) -> None:
750 """render_json produces valid JSON."""
751 render_json(_make_diff_result())
752 raw = capsys.readouterr().out
753 payload = json.loads(raw)
754 assert payload["commit_a"] == "a1b2c3d4"
755 assert payload["commit_b"] == "f9e8d7c6"
756
757
758 def test_render_json_has_all_required_keys(capsys: pytest.CaptureFixture[str]) -> None:
759 """render_json output includes all required top-level keys."""
760 render_json(_make_diff_result())
761 raw = capsys.readouterr().out
762 payload = json.loads(raw)
763 required = {
764 "commit_a", "commit_b", "source", "label_a", "label_b",
765 "vector_a", "vector_b", "dimensions", "drift", "narrative",
766 "track", "section",
767 }
768 assert required <= set(payload.keys())
769
770
771 def test_render_json_vector_has_four_keys(capsys: pytest.CaptureFixture[str]) -> None:
772 """vector_a and vector_b in JSON each have all 4 dimension keys."""
773 render_json(_make_diff_result())
774 raw = capsys.readouterr().out
775 payload = json.loads(raw)
776 for vec_key in ("vector_a", "vector_b"):
777 assert set(payload[vec_key].keys()) == {"energy", "valence", "tension", "darkness"}
778
779
780 def test_render_json_dimensions_list_length(capsys: pytest.CaptureFixture[str]) -> None:
781 """dimensions list in JSON has exactly 4 entries."""
782 render_json(_make_diff_result())
783 raw = capsys.readouterr().out
784 payload = json.loads(raw)
785 assert len(payload["dimensions"]) == 4
786
787
788 # ---------------------------------------------------------------------------
789 # CLI end-to-end
790 # ---------------------------------------------------------------------------
791
792
793 def test_cli_emotion_diff_no_repo(tmp_path: pathlib.Path) -> None:
794 """Running emotion-diff outside a Muse repo exits with REPO_NOT_FOUND."""
795 os.environ["MUSE_REPO_ROOT"] = str(tmp_path)
796 try:
797 result = runner.invoke(cli, ["emotion-diff"])
798 finally:
799 del os.environ["MUSE_REPO_ROOT"]
800 assert result.exit_code == ExitCode.REPO_NOT_FOUND
801
802
803 def test_cli_emotion_diff_help() -> None:
804 """emotion-diff --help exits 0 and includes usage information."""
805 result = runner.invoke(cli, ["emotion-diff", "--help"])
806 assert result.exit_code == 0
807 assert "COMMIT_A" in result.output or "commit" in result.output.lower()