"""Tests for the user-tunable repetition / failsafe limits exposed via `ConversationLimits` and the `/api/chat/limits/defaults` endpoint. These guard: - defaults match the historical hard-coded values (no behavior change for anyone who doesn't touch the new settings); - clamping silently coerces missing / out-of-range / non-int values back to the per-field defaults rather than raising; - the defaults endpoint returns parallel `defaults` / `bounds` / `descriptions` maps with the same field names so the frontend can zip them into UI rows. """ from fastapi.testclient import TestClient from app.main import app from app.services.models import ( CONVERSATION_LIMIT_BOUNDS, CONVERSATION_LIMIT_DESCRIPTIONS, ConversationLimits, clamp_conversation_limits, ) # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- def test_defaults_match_historical_hardcoded_values(): """If anyone changes a default by accident, this test makes them think twice. The values here are the ones the orchestrator used before this knobs-as-settings refactor.""" limits = ConversationLimits() assert limits.critique_rounds == 2 assert limits.status_assessment_max == 3 assert limits.consensus_turns_per_participant == 6 assert limits.dyad_cap == 2 assert limits.stall_recovery_attempts == 1 assert limits.auto_disable_failures == 3 assert limits.participant_message_pause_at == 60 assert limits.participant_message_pause_inc == 20 assert limits.orchestrator_call_pause_at == 100 assert limits.orchestrator_call_pause_inc == 50 def test_bounds_cover_every_dataclass_field(): """Every tunable field needs a (min, max) bound; otherwise the clamp helper would silently leave it unprotected.""" field_names = set(ConversationLimits().__dict__.keys()) bound_names = set(CONVERSATION_LIMIT_BOUNDS.keys()) assert field_names == bound_names def test_descriptions_cover_every_dataclass_field(): """The settings UI is server-driven; if a field has no description block, the modal would render an empty row for it.""" field_names = set(ConversationLimits().__dict__.keys()) described = set(CONVERSATION_LIMIT_DESCRIPTIONS.keys()) assert field_names == described for entry in CONVERSATION_LIMIT_DESCRIPTIONS.values(): assert "group" in entry assert "label" in entry assert "help" in entry # --------------------------------------------------------------------------- # clamp_conversation_limits # --------------------------------------------------------------------------- def test_clamp_returns_defaults_for_none_or_empty(): assert clamp_conversation_limits(None) == ConversationLimits() assert clamp_conversation_limits({}) == ConversationLimits() def test_clamp_clamps_too_large_values(): """A value above the field's upper bound is coerced to the upper bound, not rejected. This keeps the API permissive.""" out = clamp_conversation_limits({"critique_rounds": 99}) assert out.critique_rounds == CONVERSATION_LIMIT_BOUNDS["critique_rounds"][1] def test_clamp_clamps_too_small_values(): out = clamp_conversation_limits({"critique_rounds": -10}) assert out.critique_rounds == CONVERSATION_LIMIT_BOUNDS["critique_rounds"][0] def test_clamp_ignores_unknown_fields(): out = clamp_conversation_limits({"there_is_no_such_field": 7}) assert out == ConversationLimits() def test_clamp_silently_drops_non_int_values(): """Stringy garbage that can't be coerced should fall back to the default for that one field, not raise.""" out = clamp_conversation_limits({"critique_rounds": "not-a-number"}) assert out.critique_rounds == ConversationLimits().critique_rounds def test_clamp_preserves_partial_overrides(): """Only-some-fields-supplied is the common case (UI sends just the overridden ones).""" out = clamp_conversation_limits({"dyad_cap": 4}) assert out.dyad_cap == 4 # All other fields untouched. base = ConversationLimits() for field_name in CONVERSATION_LIMIT_BOUNDS.keys(): if field_name == "dyad_cap": continue assert getattr(out, field_name) == getattr(base, field_name) def test_clamp_accepts_string_ints(): """JSON sometimes serializes numbers as strings - we should not refuse a perfectly valid int just because it arrived as '4'.""" out = clamp_conversation_limits({"dyad_cap": "4"}) assert out.dyad_cap == 4 # --------------------------------------------------------------------------- # /api/chat/limits/defaults endpoint # --------------------------------------------------------------------------- def test_limits_defaults_endpoint_returns_parallel_maps(): client = TestClient(app) resp = client.get("/api/chat/limits/defaults") assert resp.status_code == 200 body = resp.json() assert set(body.keys()) == {"defaults", "bounds", "descriptions"} # All three maps should agree on the same field set so the # frontend can zip them per row. field_names = set(ConversationLimits().__dict__.keys()) assert set(body["defaults"].keys()) == field_names assert set(body["bounds"].keys()) == field_names assert set(body["descriptions"].keys()) == field_names # Bounds shape sanity-check. for field_name, bound in body["bounds"].items(): assert "min" in bound and "max" in bound assert bound["min"] < bound["max"] assert bound["min"] <= body["defaults"][field_name] <= bound["max"]