Jayant2304/commitment-os
0
1"""Comprehensive test suite for CommitmentOS.2 3Tests cover:4 - Grader (perfect/partial/zero for each component)5 - Environment lifecycle (reset/step/state/multi-turn)6 - Commitment ledger (creation, violation, renegotiation)7 - Task dataset integrity8 - API endpoints9 - Difficulty verification10"""11 12from __future__ import annotations13 14import sys15from pathlib import Path16 17sys.path.insert(0, str(Path(__file__).resolve().parent.parent))18 19import json20from typing import Any, Dict21 22import pytest23 24from models import CommitmentAction, CommitmentObservation, CommitmentState25from server.domain import CalendarEvent, ConstraintDef, ScenarioDef26from server.environment import CommitmentEnvironment27from server.graders import (28 _calendar_has_no_overlaps,29 _keyword_score,30 _score_commitment_coherence,31 _score_conflict_resolution,32 _score_step_efficiency,33 grade_scenario,34)35from server.tasks import get_all_scenarios, get_scenario, get_scenarios_by_difficulty36from server.world import WorldState, _time_to_min37 38 39# ===================================================================40# Fixtures41# ===================================================================42 43@pytest.fixture44def env() -> CommitmentEnvironment:45 return CommitmentEnvironment()46 47 48@pytest.fixture49def easy_env(env: CommitmentEnvironment) -> CommitmentEnvironment:50 env.reset(task_id="easy_001")51 return env52 53 54# ===================================================================55# 1. Task dataset integrity56# ===================================================================57 58class TestTaskDataset:59 def test_15_scenarios_loaded(self) -> None:60 scenarios = get_all_scenarios()61 assert len(scenarios) == 1562 63 def test_5_easy_5_medium_5_hard(self) -> None:64 for difficulty, count in [("easy", 5), ("medium", 5), ("hard", 5)]:65 tasks = get_scenarios_by_difficulty(difficulty)66 assert len(tasks) == count, f"Expected {count} {difficulty} tasks, got {len(tasks)}"67 68 def test_each_scenario_has_required_fields(self) -> None:69 for sid, scenario in get_all_scenarios().items():70 assert scenario.scenario_id == sid71 assert scenario.difficulty in ("easy", "medium", "hard")72 assert len(scenario.briefing) > 20, f"{sid}: briefing too short"73 assert scenario.optimal_steps >= 2, f"{sid}: optimal_steps too low"74 assert scenario.max_steps >= scenario.optimal_steps75 assert len(scenario.constraints) >= 1, f"{sid}: no constraints defined"76 77 def test_scenario_ids_unique(self) -> None:78 ids = list(get_all_scenarios().keys())79 assert len(ids) == len(set(ids))80 81 def test_get_scenario_returns_none_for_missing(self) -> None:82 assert get_scenario("nonexistent_999") is None83 84 def test_get_scenario_returns_correct(self) -> None:85 s = get_scenario("easy_001")86 assert s is not None87 assert s.difficulty == "easy"88 89 90# ===================================================================91# 2. Grader unit tests92# ===================================================================93 94class TestKeywordScore:95 def test_full_match(self) -> None:96 score, matched = _keyword_score("I need to reschedule the standup meeting", ["reschedule", "standup"], min_matches=2)97 assert score == 1.098 assert len(matched) == 299 100 def test_partial_match(self) -> None:101 score, matched = _keyword_score("I need to reschedule", ["reschedule", "standup"], min_matches=2)102 assert score == 0.5103 assert len(matched) == 1104 105 def test_no_match(self) -> None:106 score, matched = _keyword_score("Hello world", ["reschedule", "standup"], min_matches=2)107 assert score == 0.0108 assert len(matched) == 0109 110 def test_case_insensitive(self) -> None:111 score, _ = _keyword_score("RESCHEDULE THE STANDUP", ["reschedule", "standup"], min_matches=2)112 assert score == 1.0113 114 115class TestCalendarConflicts:116 def test_no_conflicts(self) -> None:117 scenario = get_scenario("easy_002")118 assert scenario is not None119 world = WorldState(scenario)120 assert _calendar_has_no_overlaps(world) is True121 122 def test_conflict_detected(self) -> None:123 scenario = get_scenario("easy_001")124 assert scenario is not None125 world = WorldState(scenario)126 assert _calendar_has_no_overlaps(world) is False127 128 129class TestCommitmentCoherence:130 def test_no_commitments_full_score(self) -> None:131 scenario = get_scenario("easy_005")132 assert scenario is not None133 world = WorldState(scenario)134 score, _ = _score_commitment_coherence(world)135 assert score == 1.0136 137 def test_honored_commitment(self, env: CommitmentEnvironment) -> None:138 env.reset(task_id="easy_001")139 env.step(CommitmentAction(action_type="reschedule_event", event_id="evt_2", new_time="15:00"))140 assert env._world is not None141 score, feedback = _score_commitment_coherence(env._world)142 assert score == 1.0143 144 def test_silent_violation_detected(self, env: CommitmentEnvironment) -> None:145 env.reset(task_id="easy_001")146 env.step(CommitmentAction(action_type="schedule_meeting", title="New Meeting", date="2026-04-25", time="16:00", participants=["Alice"]))147 assert env._world is not None148 env._world.calendar.pop("evt_100", None)149 for c in env._world.commitment_ledger:150 if c.commitment_type == "meeting_scheduled" and "16:00" in c.constraint:151 event_key = c.constraint152 for eid, ev in list(env._world.calendar.items()):153 if ev.time == "16:00" and ev.date == "2026-04-25" and ev.title == "New Meeting":154 del env._world.calendar[eid]155 break156 violations = env._world.get_silent_violations()157 assert len(violations) >= 1158 159 160class TestStepEfficiency:161 def test_optimal_steps(self) -> None:162 scenario = get_scenario("easy_001")163 assert scenario is not None164 world = WorldState(scenario)165 world.step_count = 3166 score, _ = _score_step_efficiency(scenario, world)167 assert score == 1.0168 169 def test_over_optimal(self) -> None:170 scenario = get_scenario("easy_001")171 assert scenario is not None172 world = WorldState(scenario)173 world.step_count = 8174 score, _ = _score_step_efficiency(scenario, world)175 assert score == 0.5176 177 178# ===================================================================179# 3. Environment lifecycle180# ===================================================================181 182class TestEnvironmentLifecycle:183 def test_reset_returns_observation(self, env: CommitmentEnvironment) -> None:184 obs = env.reset(task_id="easy_001")185 assert isinstance(obs, CommitmentObservation)186 assert obs.scenario_id == "easy_001"187 assert obs.done is False188 assert obs.reward == 0.0189 assert len(obs.briefing) > 0190 191 def test_step_before_reset_raises(self, env: CommitmentEnvironment) -> None:192 with pytest.raises(ValueError, match="No active episode"):193 env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))194 195 def test_step_after_done_raises(self, env: CommitmentEnvironment) -> None:196 env.reset(task_id="easy_001")197 env.step(CommitmentAction(action_type="submit_plan"))198 with pytest.raises(ValueError, match="already completed"):199 env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))200 201 def test_state_property(self, env: CommitmentEnvironment) -> None:202 env.reset(task_id="easy_001")203 state = env.state204 assert isinstance(state, CommitmentState)205 assert state.scenario_id == "easy_001"206 assert state.completed is False207 assert len(state.available_tasks) == 15208 209 def test_multi_turn_episode(self, env: CommitmentEnvironment) -> None:210 env.reset(task_id="easy_001")211 obs = env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))212 assert obs.done is False213 assert obs.step_number == 1214 215 obs = env.step(CommitmentAction(action_type="reschedule_event", event_id="evt_2", new_time="15:00"))216 assert obs.done is False217 assert obs.step_number == 2218 219 obs = env.step(CommitmentAction(action_type="submit_plan"))220 assert obs.done is True221 assert obs.reward > 0222 223 def test_max_steps_auto_submits(self, env: CommitmentEnvironment) -> None:224 env.reset(task_id="easy_002")225 for _ in range(20):226 obs = env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))227 if obs.done:228 break229 assert obs.done is True230 231 def test_reset_clears_state(self, env: CommitmentEnvironment) -> None:232 env.reset(task_id="easy_001")233 env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))234 env.reset(task_id="easy_002")235 assert env.state.scenario_id == "easy_002"236 assert env.state.step_count == 0237 238 def test_unknown_action_type(self, env: CommitmentEnvironment) -> None:239 env.reset(task_id="easy_001")240 obs = env.step(CommitmentAction(action_type="fly_to_moon"))241 assert "Unknown action_type" in obs.tool_result242 243 def test_random_reset(self, env: CommitmentEnvironment) -> None:244 obs = env.reset(seed=42)245 assert obs.scenario_id in get_all_scenarios()246 247 def test_difficulty_filter_reset(self, env: CommitmentEnvironment) -> None:248 obs = env.reset(difficulty="hard", seed=1)249 assert obs.difficulty == "hard"250 251 252# ===================================================================253# 4. World simulation (tool functions)254# ===================================================================255 256class TestWorldTools:257 def test_view_calendar(self) -> None:258 scenario = get_scenario("easy_001")259 assert scenario is not None260 world = WorldState(scenario)261 result = world.view_calendar("2026-04-25")262 assert "evt_1" in result263 assert "14:00" in result264 265 def test_view_calendar_empty(self) -> None:266 scenario = get_scenario("easy_001")267 assert scenario is not None268 world = WorldState(scenario)269 result = world.view_calendar("2099-01-01")270 assert "No events" in result271 272 def test_check_availability(self) -> None:273 scenario = get_scenario("easy_003")274 assert scenario is not None275 world = WorldState(scenario)276 result = world.check_availability("Client_Jones")277 assert "09:00" in result278 279 def test_check_availability_unknown(self) -> None:280 scenario = get_scenario("easy_001")281 assert scenario is not None282 world = WorldState(scenario)283 result = world.check_availability("NonExistentPerson")284 assert "not found" in result285 286 def test_search_restaurants_filters(self) -> None:287 scenario = get_scenario("med_007")288 assert scenario is not None289 world = WorldState(scenario)290 result = world.search_restaurants(dietary="vegan", max_price=45, max_distance_miles=3.0)291 assert "Green Garden" in result292 assert "Steak House Prime" not in result293 294 def test_schedule_meeting_creates_commitment(self) -> None:295 scenario = get_scenario("easy_002")296 assert scenario is not None297 world = WorldState(scenario)298 result = world.schedule_meeting("Test Meeting", "2026-04-25", "14:00", turn=1)299 assert "scheduled" in result.lower()300 assert len(world.commitment_ledger) == 1301 assert world.commitment_ledger[0].commitment_type == "meeting_scheduled"302 303 def test_schedule_meeting_conflict(self) -> None:304 scenario = get_scenario("easy_001")305 assert scenario is not None306 world = WorldState(scenario)307 result = world.schedule_meeting("Conflicting", "2026-04-25", "14:00", turn=1)308 assert "CONFLICT" in result309 310 def test_reschedule_event(self) -> None:311 scenario = get_scenario("easy_001")312 assert scenario is not None313 world = WorldState(scenario)314 result = world.reschedule_event("evt_2", "15:00", turn=1)315 assert "Rescheduled" in result316 assert world.calendar["evt_2"].time == "15:00"317 318 def test_cancel_event(self) -> None:319 scenario = get_scenario("easy_001")320 assert scenario is not None321 world = WorldState(scenario)322 result = world.cancel_event("evt_2", turn=1)323 assert "Cancelled" in result324 assert "evt_2" not in world.calendar325 326 def test_send_email(self) -> None:327 scenario = get_scenario("easy_001")328 assert scenario is not None329 world = WorldState(scenario)330 result = world.send_email("Team", "Hello", "Testing email body", turn=1)331 assert "sent" in result.lower()332 assert len(world.emails_sent) == 1333 334 def test_book_restaurant(self) -> None:335 scenario = get_scenario("easy_002")336 assert scenario is not None337 world = WorldState(scenario)338 result = world.book_restaurant("Bella Italia", turn=1)339 assert "confirmed" in result.lower()340 assert world.booked_restaurant == "Bella Italia"341 342 343# ===================================================================344# 5. Commitment ledger behaviour345# ===================================================================346 347class TestCommitmentLedger:348 def test_schedule_creates_commitment(self) -> None:349 scenario = get_scenario("easy_002")350 assert scenario is not None351 world = WorldState(scenario)352 world.schedule_meeting("Test", "2026-04-25", "10:00", turn=1)353 assert len(world.commitment_ledger) == 1354 c = world.commitment_ledger[0]355 assert c.turn_created == 1356 assert c.active is True357 assert c.renegotiated_at is None358 359 def test_reschedule_marks_old_renegotiated(self) -> None:360 scenario = get_scenario("easy_001")361 assert scenario is not None362 world = WorldState(scenario)363 world.reschedule_event("evt_2", "15:00", turn=1)364 renegotiated = [c for c in world.commitment_ledger if c.renegotiated_at is not None]365 assert len(renegotiated) == 0 # initial events don't create ledger entries366 new_commits = [c for c in world.commitment_ledger if c.active]367 assert len(new_commits) >= 1368 369 def test_email_renegotiation_detection(self) -> None:370 scenario = get_scenario("easy_001")371 assert scenario is not None372 world = WorldState(scenario)373 world.schedule_meeting("Important", "2026-04-25", "16:00", participants=["Alice"], turn=1)374 world.send_email("Alice", "Change of plans", "I need to reschedule our meeting", turn=2)375 renegotiated = [c for c in world.commitment_ledger if c.renegotiated_at is not None]376 assert len(renegotiated) >= 1377 378 def test_cancel_personal_marks_renegotiated(self) -> None:379 scenario = get_scenario("easy_001")380 assert scenario is not None381 world = WorldState(scenario)382 # evt_3 is Lunch (personal)383 world.cancel_event("evt_3", turn=1)384 # Personal cancellations are auto-OK385 386 387# ===================================================================388# 6. Full scenario scoring389# ===================================================================390 391class TestFullScoring:392 def test_perfect_easy_001(self, env: CommitmentEnvironment) -> None:393 env.reset(task_id="easy_001")394 env.step(CommitmentAction(action_type="reschedule_event", event_id="evt_2", new_time="15:00"))395 env.step(CommitmentAction(action_type="send_email", to="Team", subject="Standup moved", body="Hi team, I've rescheduled the standup to 3:00 PM. Sorry for the move."))396 obs = env.step(CommitmentAction(action_type="submit_plan"))397 assert obs.done is True398 assert obs.reward >= 0.85399 400 def test_zero_effort_gets_low_score(self, env: CommitmentEnvironment) -> None:401 env.reset(task_id="easy_001")402 obs = env.step(CommitmentAction(action_type="submit_plan"))403 assert obs.done is True404 assert obs.reward <= 0.50405 406 def test_hard_011_perfect_run(self, env: CommitmentEnvironment) -> None:407 env.reset(task_id="hard_011")408 env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))409 env.step(CommitmentAction(action_type="cancel_event", event_id="evt_90"))410 env.step(CommitmentAction(action_type="search_restaurants", dietary="vegetarian", near_airport=True, max_price=60))411 env.step(CommitmentAction(action_type="book_restaurant", restaurant_name="Sky Lounge"))412 env.step(CommitmentAction(action_type="send_email", to="Team", subject="Happy Hour Rescheduled", body="Sorry team, I need to reschedule the happy hour to Thursday. An investor dinner came up tonight. Apologies!"))413 env.step(CommitmentAction(action_type="send_email", to="VP_Chen", subject="Investor dinner plan", body="I've booked Sky Lounge for dinner tonight with Investor_Park. Vegetarian options available, near the airport."))414 obs = env.step(CommitmentAction(action_type="submit_plan"))415 assert obs.done is True416 assert obs.reward >= 0.85417 418 def test_hard_015_sre_crisis(self, env: CommitmentEnvironment) -> None:419 env.reset(task_id="hard_015")420 env.step(CommitmentAction(action_type="view_calendar", date="2026-04-25"))421 env.step(CommitmentAction(action_type="cancel_event", event_id="evt_130"))422 env.step(CommitmentAction(action_type="send_email", to="Team", subject="Lunch cancelled - incident", body="Team, I'm cancelling our lunch due to a production incident. Payment service returning 503s. Will handle this first."))423 env.step(CommitmentAction(action_type="send_email", to="Client_Jones", subject="Demo reschedule needed", body="Hi Client_Jones, I sincerely apologize but I need to reschedule our demo. We have a production incident with the payment system. Can we find another time this week?"))424 env.step(CommitmentAction(action_type="send_email", to="VP_Chen", subject="Incident + 1-on-1", body="VP_Chen, we have a production incident โ payment service is returning 503s. I'm on-call and handling it. May need to reschedule our 1-on-1 depending on resolution time."))425 obs = env.step(CommitmentAction(action_type="submit_plan"))426 assert obs.done is True427 assert obs.reward >= 0.60428 429 430# ===================================================================431# 7. Reward clamping432# ===================================================================433 434class TestRewardClamping:435 def test_reward_never_zero(self, env: CommitmentEnvironment) -> None:436 env.reset(task_id="easy_001")437 obs = env.step(CommitmentAction(action_type="submit_plan"))438 assert obs.reward >= 0.01439 440 def test_reward_never_one(self, env: CommitmentEnvironment) -> None:441 env.reset(task_id="easy_001")442 env.step(CommitmentAction(action_type="reschedule_event", event_id="evt_2", new_time="15:00"))443 env.step(CommitmentAction(action_type="send_email", to="Team", subject="Standup moved", body="Hi team, the standup is rescheduled to 3pm. Sorry for the move."))444 obs = env.step(CommitmentAction(action_type="submit_plan"))445 assert obs.reward <= 0.99446 assert obs.reward > 0.01447 448 449# ===================================================================450# 8. Time utility451# ===================================================================452 453class TestTimeUtil:454 def test_time_to_min(self) -> None:455 assert _time_to_min("00:00") == 0456 assert _time_to_min("09:30") == 570457 assert _time_to_min("14:00") == 840458 assert _time_to_min("23:59") == 1439459 460 461# ===================================================================462# 9. API endpoint tests (via TestClient)463# ===================================================================464 465class TestAPI:466 @pytest.fixture467 def client(self):468 from fastapi.testclient import TestClient469 from server.app import app470 return TestClient(app)471 472 def test_health(self, client) -> None:473 resp = client.get("/health")474 assert resp.status_code == 200475 476 def test_tasks(self, client) -> None:477 resp = client.get("/tasks")478 assert resp.status_code == 200479 data = resp.json()480 assert len(data["easy"]) == 5481 assert len(data["medium"]) == 5482 assert len(data["hard"]) == 5483 484 def test_reset_step_state(self, client) -> None:485 resp = client.post("/reset", params={"task_id": "easy_001"})486 assert resp.status_code == 200487 488 resp = client.post("/step", json={"action": {"action_type": "view_calendar", "date": "2026-04-25"}})489 assert resp.status_code == 200490 data = resp.json()491 assert data.get("done") is False492 493 resp = client.get("/state")494 assert resp.status_code == 200495 state = resp.json()496 assert "step_count" in state497 498 def test_mcp_initialize(self, client) -> None:499 resp = client.post("/mcp", json={500 "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {},501 })502 assert resp.status_code == 200503 data = resp.json()504 assert data["result"]["serverInfo"]["name"] == "commitment-os"505 506 def test_mcp_tools_list(self, client) -> None:507 resp = client.post("/mcp", json={508 "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {},509 })510 assert resp.status_code == 200511 tools = resp.json()["result"]["tools"]512 assert len(tools) == 3513 names = {t["name"] for t in tools}514 assert names == {515 "cos_episode_reset",516 "cos_environment_step",517 "cos_session_snapshot",518 }519 520 521# ===================================================================522# 10. Metadata523# ===================================================================524 525class TestMetadata:526 def test_get_metadata(self, env: CommitmentEnvironment) -> None:527 meta = env.get_metadata()528 assert meta.name == "commitment-os"529 assert "Jayant" in meta.author530 