Systematic rename of all semantic anchors (#region, [DEF], @RELATION) across 1400+ files — backend Python, frontend Svelte/TS, specs, docs: - Flat anchors become Namespace.Module.Entity - @RELATION references updated to match new anchor paths - Zero business logic changes
383 lines
16 KiB
Python
383 lines
16 KiB
Python
# #region Test.TranslationEventLog [C:3] [TYPE Module] [SEMANTICS test, event, log, audit]
|
|
# @BRIEF Verify TranslationEventLog contracts — log_event, query_events, prune_expired, get_run_event_summary.
|
|
# @RELATION BINDS_TO -> [Plugin.Events.TranslationEventLog]
|
|
# @TEST_EDGE: invalid_event_type -> Raises ValueError
|
|
# @TEST_EDGE: terminal_event_twice -> Raises ValueError
|
|
# @TEST_EDGE: missing_run_started -> Raises ValueError for run events
|
|
# @TEST_EDGE: prune_expired_noop -> Returns zero when no expired events
|
|
# @TEST_EDGE: prune_expired_creates_snapshot -> MetricSnapshot created before pruning
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.models.translate import (
|
|
TranslationEvent, MetricSnapshot, TranslationRun,
|
|
)
|
|
from src.plugins.translate.events import (
|
|
TranslationEventLog, VALID_EVENT_TYPES, TERMINAL_EVENT_TYPES,
|
|
)
|
|
|
|
from .conftest import JOB_ID, RUN_ID
|
|
|
|
|
|
class TestLogEvent:
|
|
"""Verify log_event method."""
|
|
|
|
def test_log_invalid_event_type(self, db_session):
|
|
"""Negative: unknown event type raises ValueError."""
|
|
el = TranslationEventLog(db_session)
|
|
with pytest.raises(ValueError, match="Invalid event_type"):
|
|
el.log_event(job_id=JOB_ID, event_type="UNKNOWN_TYPE")
|
|
|
|
def test_log_valid_event_no_run(self, db_session):
|
|
"""Happy: job-level event without run_id."""
|
|
el = TranslationEventLog(db_session)
|
|
event = el.log_event(job_id=JOB_ID, event_type="JOB_CREATED",
|
|
created_by="test_user")
|
|
assert event.job_id == JOB_ID
|
|
assert event.event_type == "JOB_CREATED"
|
|
assert event.created_by == "test_user"
|
|
|
|
def test_log_run_started(self, db_with_run):
|
|
"""Happy: RUN_STARTED event for a run."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
event = el.log_event(job_id=JOB_ID, run_id=run_id,
|
|
event_type="RUN_STARTED")
|
|
assert event.run_id == run_id
|
|
|
|
def test_log_run_event_without_started(self, db_session):
|
|
"""Negative: other run events require RUN_STARTED first."""
|
|
el = TranslationEventLog(db_session)
|
|
run = TranslationRun(id="orphan-run", job_id=JOB_ID, status="RUNNING",
|
|
started_at=datetime.now(UTC))
|
|
db_session.add(run)
|
|
db_session.commit()
|
|
with pytest.raises(ValueError, match="RUN_STARTED event must precede"):
|
|
el.log_event(job_id=JOB_ID, run_id="orphan-run",
|
|
event_type="BATCH_STARTED")
|
|
|
|
def test_log_terminal_event_twice(self, db_with_run):
|
|
"""Negative: cannot log two terminal events for same run."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
|
|
with pytest.raises(ValueError, match="already has a terminal event"):
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_FAILED")
|
|
|
|
def test_log_terminal_event_first_time_ok(self, db_with_run):
|
|
"""Happy: first terminal event succeeds."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
event = el.log_event(job_id=JOB_ID, run_id=run_id,
|
|
event_type="RUN_COMPLETED")
|
|
assert event.event_type == "RUN_COMPLETED"
|
|
|
|
def test_log_with_payload(self, db_with_run):
|
|
"""Happy: event with structured payload."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
payload = {"rows": 10, "language": "es"}
|
|
event = el.log_event(job_id=JOB_ID, run_id=run_id,
|
|
event_type="RUN_STARTED", payload=payload)
|
|
assert event.event_data == payload
|
|
|
|
def test_log_event_flushes(self, db_session):
|
|
"""Verify flush is called after adding event."""
|
|
el = TranslationEventLog(db_session)
|
|
event = el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
|
|
fetched = db_session.query(TranslationEvent).filter(
|
|
TranslationEvent.id == event.id
|
|
).first()
|
|
assert fetched is not None
|
|
|
|
def test_run_started_before_terminal(self, db_with_run):
|
|
"""Edge: RUN_STARTED precedes terminal event."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
ev = el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
|
|
assert ev is not None
|
|
|
|
|
|
class TestQueryEvents:
|
|
"""Verify query_events method."""
|
|
|
|
def test_query_all_events(self, db_session):
|
|
"""Happy: query returns all events."""
|
|
el = TranslationEventLog(db_session)
|
|
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
|
|
el.log_event(job_id=JOB_ID, event_type="JOB_UPDATED")
|
|
results = el.query_events()
|
|
assert len(results) >= 1
|
|
|
|
def test_query_filter_by_job(self, db_session):
|
|
"""Happy: filter by job_id."""
|
|
el = TranslationEventLog(db_session)
|
|
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
|
|
results = el.query_events(job_id=JOB_ID)
|
|
assert len(results) >= 1
|
|
assert results[0]["job_id"] == JOB_ID
|
|
|
|
def test_query_filter_by_run(self, db_with_run):
|
|
"""Happy: filter by run_id."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
results = el.query_events(run_id=run_id)
|
|
# db_with_run fixture already logs RUN_STARTED during setup, plus this test logs another = 2
|
|
assert len(results) == 2
|
|
|
|
def test_query_filter_by_type(self, db_with_run):
|
|
"""Happy: filter by event_type."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
results = el.query_events(event_type="JOB_CREATED")
|
|
assert len(results) == 1
|
|
|
|
def test_query_limit_offset(self, db_session):
|
|
"""Edge: pagination with limit and offset."""
|
|
el = TranslationEventLog(db_session)
|
|
for i in range(5):
|
|
el.log_event(job_id=JOB_ID, event_type="JOB_UPDATED",
|
|
payload={"seq": i})
|
|
results = el.query_events(limit=2, offset=0)
|
|
assert len(results) == 2
|
|
results_page2 = el.query_events(limit=2, offset=2)
|
|
assert len(results_page2) == 2
|
|
|
|
|
|
class TestPruneExpired:
|
|
"""Verify prune_expired method."""
|
|
|
|
def test_no_expired_events(self, db_session):
|
|
"""Edge: no events older than cutoff."""
|
|
el = TranslationEventLog(db_session)
|
|
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
|
|
result = el.prune_expired(retention_days=365)
|
|
assert result["pruned"] == 0
|
|
assert result["snapshot_id"] is None
|
|
|
|
def test_prune_expired_events(self, db_session):
|
|
"""Happy: prune old events and create snapshot."""
|
|
el = TranslationEventLog(db_session)
|
|
old_event = TranslationEvent(
|
|
id="old-event", job_id=JOB_ID, event_type="JOB_CREATED",
|
|
event_data={}, created_at=datetime.now(UTC) - timedelta(days=400),
|
|
)
|
|
db_session.add(old_event)
|
|
db_session.commit()
|
|
|
|
result = el.prune_expired(retention_days=90)
|
|
assert result["pruned"] >= 1
|
|
assert result["snapshot_id"] is not None
|
|
remaining = db_session.query(TranslationEvent).all()
|
|
assert len(remaining) == 0
|
|
|
|
def test_prune_with_metric_snapshot_created(self, db_session):
|
|
"""Invariant: MetricSnapshot created before pruning."""
|
|
el = TranslationEventLog(db_session)
|
|
old_event = TranslationEvent(
|
|
id="old-with-metrics", job_id=JOB_ID, event_type="BATCH_COMPLETED",
|
|
event_data={"token_count": 500, "cost": 0.01,
|
|
"language_code": "es", "run_id": "run-1"},
|
|
created_at=datetime.now(UTC) - timedelta(days=400),
|
|
)
|
|
db_session.add(old_event)
|
|
db_session.commit()
|
|
|
|
result = el.prune_expired(retention_days=90)
|
|
assert result["snapshot_id"] is not None
|
|
snapshot = db_session.query(MetricSnapshot).first()
|
|
assert snapshot is not None
|
|
assert snapshot.total_records == 1
|
|
|
|
def test_prune_language_metrics_aggregation(self, db_session):
|
|
"""Edge: per-language metrics aggregated correctly."""
|
|
el = TranslationEventLog(db_session)
|
|
for i, lang in enumerate(["es", "es", "fr"]):
|
|
e = TranslationEvent(
|
|
id=f"old-{lang}-{i}", job_id=JOB_ID, event_type="BATCH_COMPLETED",
|
|
event_data={"token_count": 100, "cost": 0.01,
|
|
"language_code": lang},
|
|
created_at=datetime.now(UTC) - timedelta(days=400),
|
|
)
|
|
db_session.add(e)
|
|
db_session.commit()
|
|
|
|
result = el.prune_expired(retention_days=90)
|
|
assert result["pruned"] == 3
|
|
snapshot = db_session.query(MetricSnapshot).first()
|
|
assert snapshot is not None
|
|
metrics = snapshot.per_language_metrics or {}
|
|
assert "es" in metrics
|
|
assert metrics["es"]["cumulative_tokens"] == 200
|
|
assert "fr" in metrics
|
|
|
|
def test_prune_event_without_language(self, db_session):
|
|
"""Edge: event without language_code grouped as _unknown_."""
|
|
el = TranslationEventLog(db_session)
|
|
e = TranslationEvent(
|
|
id="no-lang", job_id=JOB_ID, event_type="BATCH_COMPLETED",
|
|
event_data={"token_count": 50},
|
|
created_at=datetime.now(UTC) - timedelta(days=400),
|
|
)
|
|
db_session.add(e)
|
|
db_session.commit()
|
|
result = el.prune_expired(retention_days=90)
|
|
assert result["pruned"] == 1
|
|
|
|
|
|
class TestGetRunEventSummary:
|
|
"""Verify get_run_event_summary method."""
|
|
|
|
def test_valid_run_summary(self, db_with_run):
|
|
"""Happy: run with started and completed events."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
|
|
summary = el.get_run_event_summary(run_id)
|
|
assert summary["run_id"] == run_id
|
|
assert summary["has_run_started"] is True
|
|
assert summary["terminal_event_count"] == 1
|
|
assert summary["invariant_valid"] is True
|
|
|
|
def test_empty_run_summary(self, db_session):
|
|
"""Edge: run with no events."""
|
|
el = TranslationEventLog(db_session)
|
|
summary = el.get_run_event_summary("run-empty")
|
|
assert summary["event_count"] == 0
|
|
assert summary["has_run_started"] is False
|
|
assert summary["invariant_valid"] is False
|
|
|
|
def test_run_missing_start(self, db_session):
|
|
"""Edge: run with terminal but no start."""
|
|
el = TranslationEventLog(db_session)
|
|
# Need a run record to satisfy FK constraint on translation_events.run_id
|
|
run = TranslationRun(id="run-no-start-2", job_id=JOB_ID, status="RUNNING",
|
|
started_at=datetime.now(UTC))
|
|
db_session.add(run)
|
|
db_session.flush()
|
|
e = TranslationEvent(
|
|
id="orphan-completed", job_id=JOB_ID, run_id="run-no-start-2",
|
|
event_type="RUN_COMPLETED", event_data={},
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
db_session.add(e)
|
|
db_session.commit()
|
|
summary = el.get_run_event_summary("run-no-start-2")
|
|
assert summary["has_run_started"] is False
|
|
assert summary["invariant_valid"] is False
|
|
|
|
def test_run_two_terminals_violation(self, db_with_run):
|
|
"""Edge: run with two terminal events violates invariant."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
|
|
# Force-add a second terminal
|
|
e2 = TranslationEvent(
|
|
id="second-terminal", job_id=JOB_ID, run_id=run_id,
|
|
event_type="RUN_FAILED", event_data={},
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
session.add(e2)
|
|
session.commit()
|
|
session.expire_all()
|
|
summary = el.get_run_event_summary(run_id)
|
|
assert summary["terminal_event_count"] > 1
|
|
assert summary["invariant_valid"] is False
|
|
|
|
|
|
class TestNewObservabilityEvents:
|
|
"""Verify new aggregate observability events — LANGUAGE_DETECTION_COMPLETED and CACHE_SUMMARY."""
|
|
|
|
def test_language_detection_completed_is_valid(self):
|
|
"""LANGUAGE_DETECTION_COMPLETED is a recognized event type."""
|
|
assert "LANGUAGE_DETECTION_COMPLETED" in VALID_EVENT_TYPES
|
|
|
|
def test_cache_summary_is_valid(self):
|
|
"""CACHE_SUMMARY is a recognized event type."""
|
|
assert "CACHE_SUMMARY" in VALID_EVENT_TYPES
|
|
|
|
def test_new_events_not_terminal(self):
|
|
"""Neither new event type is a terminal event (no invariant conflict)."""
|
|
assert "LANGUAGE_DETECTION_COMPLETED" not in TERMINAL_EVENT_TYPES
|
|
assert "CACHE_SUMMARY" not in TERMINAL_EVENT_TYPES
|
|
|
|
def test_log_language_detection_completed(self, db_with_run):
|
|
"""Happy: LANGUAGE_DETECTION_COMPLETED event with aggregate language stats (no source text)."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
event = el.log_event(
|
|
job_id=JOB_ID, run_id=run_id,
|
|
event_type="LANGUAGE_DETECTION_COMPLETED",
|
|
payload={
|
|
"detector": "lingua",
|
|
"source_language_distribution": {"en": 5, "fr": 3},
|
|
"und_count": 1,
|
|
"und_rate": 0.1111,
|
|
"total_rows": 9,
|
|
"target_language_count": 2,
|
|
},
|
|
)
|
|
assert event.event_type == "LANGUAGE_DETECTION_COMPLETED"
|
|
assert event.event_data["detector"] == "lingua"
|
|
assert event.event_data["und_count"] == 1
|
|
assert event.event_data["total_rows"] == 9
|
|
# No source text in payload
|
|
assert "source_text" not in event.event_data
|
|
|
|
def test_log_cache_summary(self, db_with_run):
|
|
"""Happy: CACHE_SUMMARY event with cache eligibility/hits/misses."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
event = el.log_event(
|
|
job_id=JOB_ID, run_id=run_id,
|
|
event_type="CACHE_SUMMARY",
|
|
payload={
|
|
"batch_size": 10,
|
|
"eligible": 8,
|
|
"hits": 3,
|
|
"misses": 5,
|
|
},
|
|
)
|
|
assert event.event_type == "CACHE_SUMMARY"
|
|
assert event.event_data["eligible"] == 8
|
|
assert event.event_data["hits"] == 3
|
|
assert event.event_data["misses"] == 5
|
|
# No source text in payload
|
|
assert "source_text" not in event.event_data
|
|
|
|
def test_log_language_detection_completed_no_run(self, db_session):
|
|
"""Edge: LANGUAGE_DETECTION_COMPLETED can be logged without run_id (job-level)."""
|
|
el = TranslationEventLog(db_session)
|
|
event = el.log_event(
|
|
job_id=JOB_ID,
|
|
event_type="LANGUAGE_DETECTION_COMPLETED",
|
|
payload={"detector": "lingua", "total_rows": 0},
|
|
)
|
|
assert event.run_id is None
|
|
assert event.event_type == "LANGUAGE_DETECTION_COMPLETED"
|
|
|
|
def test_cache_summary_terminal_invariant_preserved(self, db_with_run):
|
|
"""Invariant: CACHE_SUMMARY does not break terminal event invariant."""
|
|
session, run_id = db_with_run
|
|
el = TranslationEventLog(session)
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
|
|
# Log cache event — should succeed
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="CACHE_SUMMARY")
|
|
# Log terminal event — should still succeed
|
|
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
|
|
summary = el.get_run_event_summary(run_id)
|
|
assert summary["invariant_valid"] is True
|
|
assert summary["terminal_event_count"] == 1
|
|
# #endregion Test.TranslationEventLog
|