Files
meeting-lab/tests/test_protocol_history.py
T

170 lines
6.8 KiB
Python

"""Publication fault injection without inference or audio processing."""
import json
import multiprocessing
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.meeting_lab.models.meeting_context import create_meeting_context, load_meeting_context
from src.meeting_lab.protocol import history
from src.meeting_lab.orchestration import mvp
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
from tests import test_mvp_api as fixtures
def result(text):
return DirectProtocolResult(
protocol_text=text,
exact_prompt="prompt " + text,
transcript_input="Lumini original",
model_metadata={"name": "test"},
runtime_metadata={
"glossary_aliases_configured": {"Lumini": "Luminy"},
"glossary_replacements": [],
"num_thread": 10,
},
raw_response={"response": text},
)
def interrupt_publication(root):
original = history._replace_link
def interrupted(path, target):
if path.name == "current":
os._exit(77)
original(path, target)
history._replace_link = interrupted
history.persist_protocol_generation(
Path(root), result("new"), context=create_meeting_context(fixtures.context_data())
)
class HistoryTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.context = create_meeting_context(fixtures.context_data())
def persist(self, text):
return history.persist_protocol_generation(self.root, result(text), context=self.context)
def snapshot(self):
names = [
"protocol.md",
"context/meeting_context.yaml",
*["protocol/" + n for n in history._DIAGNOSTICS],
]
return {name: (self.root / name).read_bytes() for name in names}
def test_success_numbering_provenance_and_immutable_context(self):
self.persist("first")
first = self.snapshot()
self.persist("second")
self.assertEqual((self.root / "protocol.md").read_text(), "second")
old = self.root / "protocol/generations/001"
self.assertEqual((old / "protocol.md").read_bytes(), first["protocol.md"])
self.assertEqual(
(old / "meeting_context.yaml").read_bytes(), first["context/meeting_context.yaml"]
)
current = (self.root / "protocol/current").resolve()
self.assertEqual(current.name, "002")
metadata = json.loads((current / "runtime_metadata.json").read_text())
self.assertEqual(metadata["generation_index"], 2)
self.assertEqual(metadata["speaker_mapping"], self.context.speaker_mappings)
self.assertEqual(metadata["glossary_aliases_configured"], {"Lumini": "Luminy"})
self.assertEqual(metadata["glossary_replacements"], [])
self.assertEqual(metadata["num_thread"], 10)
self.assertEqual(metadata["source_run_id"], self.root.name)
self.assertEqual(
json.loads((current / "model_metadata.json").read_text()), {"name": "test"}
)
self.assertIn("sha", metadata["git"]["meeting_lab"])
self.assertEqual(
load_meeting_context(current / "meeting_context.yaml").data, self.context.data
)
for name in history._DIAGNOSTICS:
self.assertEqual((self.root / "protocol" / name).resolve(), current / name)
def test_failure_writing_record_preserves_previous(self):
self.persist("first")
before = self.snapshot()
with patch.object(history, "_write_json", side_effect=OSError("disk full")):
with self.assertRaises(OSError):
self.persist("second")
self.assertEqual(self.snapshot(), before)
self.persist("third")
self.assertEqual((self.root / "protocol/current").resolve().name, "002")
def test_failure_at_atomic_publication_preserves_all_stable_paths(self):
self.persist("first")
before = self.snapshot()
original = history.os.replace
def fail_current(source, destination):
if Path(destination).name == "current":
raise OSError("publication stopped")
original(source, destination)
with patch.object(history.os, "replace", side_effect=fail_current):
with self.assertRaises(OSError):
self.persist("second")
self.assertEqual(self.snapshot(), before)
self.assertFalse((self.root / "protocol/generations/002").exists())
def test_process_interruption_before_pointer_swap_preserves_current(self):
self.persist("first")
before = self.snapshot()
worker = multiprocessing.get_context("fork").Process(
target=interrupt_publication, args=(str(self.root),)
)
worker.start()
worker.join(timeout=10)
self.assertFalse(worker.is_alive())
self.assertEqual(worker.exitcode, 77)
self.assertEqual(self.snapshot(), before)
self.persist("third")
self.assertEqual((self.root / "protocol.md").read_text(), "third")
def test_legacy_migration_and_failure_during_link_conversion(self):
(self.root / "protocol").mkdir()
(self.root / "context").mkdir()
(self.root / "protocol.md").write_text("legacy")
for name in history._DIAGNOSTICS:
(self.root / "protocol" / name).write_text("legacy " + name)
(self.root / "context/meeting_context.yaml").write_text("legacy context")
before = self.snapshot()
original = history._replace_link
def fail_conversion(path, target):
if path.name == "raw_response.json":
raise OSError("link conversion stopped")
original(path, target)
with patch.object(history, "_replace_link", side_effect=fail_conversion):
with self.assertRaises(OSError):
self.persist("new")
self.assertEqual(self.snapshot(), before)
self.persist("new")
self.assertEqual((self.root / "protocol/generations/001/protocol.md").read_text(), "legacy")
self.assertEqual((self.root / "protocol/current").resolve().name, "002")
def test_failed_model_does_not_change_context_or_latest_generation(self):
self.persist("first")
before = self.snapshot()
(self.root / "transcript").mkdir()
(self.root / "transcript/transcript.json").write_text('{"text":"original"}')
different = fixtures.context_data()
different["speaker_mappings"] = {}
with patch.object(
mvp, "generate_direct_protocol", side_effect=RuntimeError("model stopped")
):
with self.assertRaises(RuntimeError):
mvp.regenerate_mvp_protocol(self.root, meeting_context=different)
self.assertEqual(self.snapshot(), before)