from __future__ import annotations from queue import Queue from threading import Event, Lock, current_thread from unittest.mock import patch import pytest from cognitive_agent.application.mode_schedule import ( MODE_SCHEDULE_SCHEMA, ModeSchedule, ModeSchedulePhase, ModeScheduleRunner, ) def operation(capability="climate.set_temperature", target="whole_vehicle", **parameters): return { "domain": capability.split(".")[0], "capability": capability, "target_scope": target, "operation": "set", "parameters": parameters or {"temperature_c": 24}, "reason": "舒适阶段", } def document(*, repeat=1, duration=1000): return { "schema": MODE_SCHEDULE_SCHEMA, "repeat_count": repeat, "phases": [ {"id": "warmup", "name": "预热", "duration_ms": duration, "operations": [operation(temperature_c=28)]}, {"id": "comfort", "name": "舒适", "duration_ms": duration, "operations": [operation(temperature_c=24)]}, ], } def test_round_trip_has_independent_parameters_and_counts(): source = document(repeat=3, duration=1500) schedule = ModeSchedule.from_dict(source) assert schedule.to_dict() == source assert schedule.total_duration_ms == 9000 assert schedule.operation_count == 2 source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 10 returned = schedule.to_dict() returned["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 11 assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28 @pytest.mark.parametrize("value", [None, [], "schedule", 1, True]) def test_schedule_root_must_be_object(value): with pytest.raises(ValueError): ModeSchedule.from_dict(value) @pytest.mark.parametrize("field", ["schema", "repeat_count", "phases"]) def test_schedule_requires_all_contract_fields(field): value = document() del value[field] with pytest.raises(ValueError, match="missing"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("value", [0, 21, -1, 1.0, "2", True, False, None, float("nan")]) def test_repeat_count_is_a_bounded_non_boolean_integer(value): with pytest.raises(ValueError, match="repeat_count"): ModeSchedule.from_dict(document(repeat=value)) @pytest.mark.parametrize("value", [0, 999, 900001, 1000.0, "1000", True, None, float("inf")]) def test_phase_duration_is_a_bounded_non_boolean_integer(value): with pytest.raises(ValueError, match="duration_ms"): ModeSchedule.from_dict(document(duration=value)) @pytest.mark.parametrize("count", [0, 1, 9]) def test_phase_count_bounds(count): value = document() value["phases"] = [dict(value["phases"][0], id=f"phase-{index}") for index in range(count)] with pytest.raises(ValueError, match="2 to 8"): ModeSchedule.from_dict(value) def test_total_duration_includes_repeats_and_enforces_one_hour(): assert ModeSchedule.from_dict(document(repeat=2, duration=900000)).total_duration_ms == 3600000 with pytest.raises(ValueError, match="60 minutes"): ModeSchedule.from_dict(document(repeat=3, duration=900000)) @pytest.mark.parametrize("path", [(), ("phases", 0), ("phases", 0, "operations", 0)]) def test_unknown_fields_rejected_at_every_contract_level(path): value = document() target = value for key in path: target = target[key] target["untrusted_instruction"] = "bypass validation" with pytest.raises(ValueError, match="unknown"): ModeSchedule.from_dict(value) def test_non_string_object_keys_are_rejected(): value = document() value[1] = "unknown" with pytest.raises(ValueError, match="string keys"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("schema", ["v2", "", None, 1, True]) def test_schema_is_exact(schema): value = document() value["schema"] = schema with pytest.raises(ValueError, match="schema"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("field", ["id", "name", "duration_ms", "operations"]) def test_phase_requires_all_contract_fields(field): value = document() del value["phases"][0][field] with pytest.raises(ValueError, match="missing"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("phase_id", ["", "Bad ID", "hello.world", "座椅", "x" * 65, 5]) def test_phase_ids_are_bounded_identifiers(phase_id): value = document() value["phases"][0]["id"] = phase_id with pytest.raises(ValueError): ModeSchedule.from_dict(value) def test_duplicate_phase_ids_rejected(): value = document() value["phases"][1]["id"] = value["phases"][0]["id"] with pytest.raises(ValueError, match="ids must be unique"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("value", [[], [operation()] * 13, {}, "operations"]) def test_operation_count_and_array_contract(value): source = document() source["phases"][0]["operations"] = value with pytest.raises(ValueError): ModeSchedule.from_dict(source) @pytest.mark.parametrize("capability", [ "seat.set_driver_axis_native", "seat.set_driver_position_native", "seat.move", "seat.apply_dynamic_mode", "door.set_vehicle_lock", "window.set_motion", "custom.anything", ]) def test_only_stable_scene_composable_capabilities_allowed(capability): value = document() value["phases"][0]["operations"] = [operation(capability)] with pytest.raises(ValueError, match="stable"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("kind", ["toggle", "increase", "decrease", "activate", "apply", None, True]) def test_relative_or_non_set_operation_types_rejected(kind): value = document() value["phases"][0]["operations"][0]["operation"] = kind with pytest.raises(ValueError, match="absolute set"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("name", ["duration_ms", "duration", "seconds", "direction", "delta", "relative", "axis", "toggle"]) def test_phase_holds_cannot_be_smuggled_as_axis_motion(name): value = document() value["phases"][0]["operations"][0]["parameters"][name] = 1000 with pytest.raises(ValueError, match="relative, motion or timing"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), {}, [], None]) def test_non_json_or_non_scalar_parameters_rejected(value): source = document() source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = value with pytest.raises(ValueError): ModeSchedule.from_dict(source) @pytest.mark.parametrize("value", ["toggle", "relative", "increase", "decrease"]) def test_relative_parameter_values_rejected(value): source = document() source["phases"][0]["operations"][0]["parameters"]["state"] = value with pytest.raises(ValueError, match="relative state"): ModeSchedule.from_dict(source) def test_domain_must_match_capability(): value = document() value["phases"][0]["operations"][0]["domain"] = "seat" with pytest.raises(ValueError, match="stable"): ModeSchedule.from_dict(value) @pytest.mark.parametrize("target", ["passenger", "front_right"]) def test_duplicate_physical_seat_target_aliases_rejected(target): value = document() value["phases"][0]["operations"] = [ operation("seat.set_ventilation", "passenger", state="on", level=1), operation("seat.set_ventilation", target, state="off"), ] with pytest.raises(ValueError, match="duplicate"): ModeSchedule.from_dict(value) def test_driver_and_passenger_comfort_are_valid_independent_operations(): value = document() value["phases"][0]["operations"] = [ operation("seat.set_driver_ventilation_native", "driver", state="on", level=2), operation("seat.set_driver_massage_native", "driver", action="mode", mode="relax"), operation("seat.set_ventilation", "passenger", state="on", level=1), operation("seat.set_backrest_recline", "passenger", position_pct=40), ] assert ModeSchedule.from_dict(value).operation_count == 5 def test_direct_dataclass_construction_cannot_bypass_duration_or_repeat_limits(): with pytest.raises(ValueError, match="duration_ms"): ModeSchedulePhase("a", "a", True, (operation(),)) phase = ModeSchedulePhase.from_dict(document()["phases"][0]) second = ModeSchedulePhase.from_dict(document()["phases"][1]) with pytest.raises(ValueError, match="repeat_count"): ModeSchedule((phase, second), True) def test_simulation_has_no_thread_or_real_wait_and_preserves_repetition_order(): schedule = ModeSchedule.from_dict(document(repeat=3, duration=2000)) calls = [] caller = current_thread() def execute(phase): assert current_thread() is caller calls.append(phase.id) return {"success": True} with ( patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("thread")), patch("cognitive_agent.application.mode_schedule.Event.wait", side_effect=AssertionError("wait")), ): result = ModeScheduleRunner.simulate(schedule, execute) assert result["status"] == "completed" assert result["completed"] is True assert result["execution_mode"] == "dry_run" assert result["virtual_elapsed_ms"] == 12000 assert result["executed_phases"] == 6 assert result["phase_index"] == 1 assert result["repeat_index"] == 2 assert calls == ["warmup", "comfort"] * 3 assert [item["virtual_start_ms"] for item in result["phase_results"]] == list(range(0, 12000, 2000)) @pytest.mark.parametrize("failed_result", [ None, [], {}, {"success": False}, {"status": "failed"}, {"status": "submitted_unverified"}, {"status": "error", "success": True}, {"status": "succeeded", "success": False}, {"success": True, "error": "rejected by safety"}, {"status": "pending"}, {"success": 1}, ]) def test_simulation_fails_closed_on_unconfirmed_or_failed_execution(failed_result): calls = [] def execute(phase): calls.append(phase.id) return failed_result result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute) assert result["status"] == "failed" assert result["completed"] is False assert result["error"] assert result["virtual_elapsed_ms"] == 0 assert calls == ["warmup"] def test_simulation_stops_on_second_phase_exception_and_reports_prior_hold(): def execute(phase): if phase.id == "comfort": raise RuntimeError("runtime safety rejection") return {"status": "succeeded"} result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute) assert result["completed"] is False assert result["error"] == "RuntimeError: runtime safety rejection" assert result["virtual_elapsed_ms"] == 1000 assert result["executed_phases"] == 1 def test_executor_mutations_do_not_change_next_repetition_or_source(): values = [] schedule = ModeSchedule.from_dict(document(repeat=2)) def execute(phase): values.append(phase.operations[0]["parameters"]["temperature_c"]) phase.operations[0]["parameters"]["temperature_c"] = 999 return {"status": "ok"} assert ModeScheduleRunner.simulate(schedule, execute)["completed"] assert values == [28, 24, 28, 24] assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28 class ManualClock: def __init__(self): self.value = 0.0 self.holds = Queue() def __call__(self): return self.value def wait(self, cancelled, seconds): release = Event() self.holds.put((seconds, release)) while not cancelled.is_set(): if release.wait(0.01): self.value += seconds return False return True def release(self, seconds=1.0): expected, release = self.holds.get(timeout=2) assert expected == seconds release.set() def test_runner_holds_without_repeated_dispatch_and_finishes_finite_repeats(): clock = ManualClock() calls = Queue() runner = ModeScheduleRunner(clock=clock, wait=clock.wait) schedule = ModeSchedule.from_dict(document(repeat=2)) try: runner.start("car", schedule, lambda phase: calls.put(phase.id) or {"success": True}, session_id="a") for expected in ["warmup", "comfort", "warmup", "comfort"]: assert calls.get(timeout=2) == expected assert calls.empty() state = runner.status("car") assert state["status"] == "running" assert state["completed"] is False assert state["session_id"] == "a" clock.release() # Joining a bounded, virtual-time worker is deterministic and does not # cancel it as close() would do while it leaves its final hold. runner._runs["car"].thread.join(2) assert runner.status("car")["status"] == "completed" assert runner.status("car")["elapsed_ms"] == 4000 assert runner.status("car")["executed_phases"] == 4 finally: assert runner.close() def test_cancel_interrupts_real_hold_before_next_phase(): started = Event() calls = [] runner = ModeScheduleRunner() def execute(phase): calls.append(phase.id) started.set() return {"status": "success"} runner.start("car", ModeSchedule.from_dict(document(duration=900000)), execute) assert started.wait(2) assert runner.cancel("car")["status"] == "cancelled" assert runner.close(timeout=1) assert calls == ["warmup"] assert runner.status("car")["completed"] is False def test_wrong_session_cannot_cancel_active_timeline(): started = Event() runner = ModeScheduleRunner() runner.start("car", ModeSchedule.from_dict(document()), lambda phase: started.set() or {"success": True}, session_id="a") assert started.wait(2) assert runner.cancel("car", session_id="b")["status"] == "running" assert runner.cancel("car", session_id="a")["status"] == "cancelled" assert runner.close() def test_replace_waits_for_inflight_callback_and_never_overlaps_same_car(): entered = Event() release = Event() second_entered = Event() calls = [] inside = 0 guard = Lock() runner = ModeScheduleRunner() def first(phase): nonlocal inside with guard: inside += 1 entered.set() assert release.wait(2) calls.append("old") with guard: inside -= 1 return {"success": True} def second(phase): with guard: assert inside == 0 calls.append("new") second_entered.set() return {"success": True} try: old = runner.start("car", ModeSchedule.from_dict(document()), first, session_id="old") old_run = runner._runs["car"] assert entered.wait(2) new = runner.start("car", ModeSchedule.from_dict(document()), second, session_id="new") assert old["run_id"] != new["run_id"] assert old_run.status == "cancelled" assert old_run.cancel_reason == "replaced" assert not second_entered.is_set() release.set() assert second_entered.wait(2) assert calls == ["old", "new"] finally: release.set() assert runner.close() def test_different_vehicles_do_not_share_a_global_execution_lock(): first_entered, second_entered, release = Event(), Event(), Event() runner = ModeScheduleRunner() def first(phase): first_entered.set() assert release.wait(2) return {"success": True} try: runner.start("car-a", ModeSchedule.from_dict(document()), first) assert first_entered.wait(2) runner.start("car-b", ModeSchedule.from_dict(document()), lambda phase: second_entered.set() or {"success": True}) assert second_entered.wait(2) finally: release.set() assert runner.close() @pytest.mark.parametrize("failure", ["return", "exception"]) def test_runner_stops_later_phases_and_repeats_after_failure(failure): calls = [] runner = ModeScheduleRunner(wait=lambda event, duration: False) def execute(phase): calls.append(phase.id) if phase.id == "comfort": if failure == "exception": raise ValueError("bad runtime capability") return {"status": "rejected", "error": "bad runtime capability"} return {"status": "ok"} runner.start("car", ModeSchedule.from_dict(document(repeat=3)), execute) runner._runs["car"].thread.join(2) assert runner.status("car")["status"] == "failed" assert "bad runtime capability" in runner.status("car")["error"] assert runner.status("car")["executed_phases"] == 1 assert calls == ["warmup", "comfort"] assert runner.close() def test_close_prevents_start_and_reports_uninterruptible_callback_honestly(): entered, release = Event(), Event() runner = ModeScheduleRunner() runner.start("car", ModeSchedule.from_dict(document()), lambda phase: entered.set() or release.wait(2) or {"success": True}) assert entered.wait(2) assert runner.close(timeout=0) is False assert runner.status("car")["status"] == "cancelled" assert runner.status("car")["cancel_reason"] == "closed" with pytest.raises(RuntimeError, match="closed"): runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) release.set() assert runner.close() def test_idle_and_terminal_cancellation_are_idempotent(): runner = ModeScheduleRunner(wait=lambda event, duration: False) assert runner.status("unknown")["status"] == "idle" assert runner.cancel("unknown")["status"] == "idle" runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) runner._runs["car"].thread.join(2) assert runner.cancel("car")["status"] == "completed" assert runner.close() assert runner.close() def test_start_revalidates_caller_mutation_before_accepting_schedule(): runner = ModeScheduleRunner() schedule = ModeSchedule.from_dict(document()) schedule.phases[0].operations[0]["operation"] = "toggle" with pytest.raises(ValueError, match="absolute set"): runner.start("car", schedule, lambda phase: {"success": True}) assert runner.status("car")["status"] == "idle" assert runner.close() def test_failed_thread_start_is_not_left_running_and_close_remains_safe(): runner = ModeScheduleRunner() with ( patch("cognitive_agent.application.mode_schedule.Thread.start", side_effect=RuntimeError("thread unavailable")), pytest.raises(RuntimeError, match="thread unavailable"), ): runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) assert runner.status("car")["status"] == "failed" assert runner.close() def test_injected_cancelled_wait_cannot_leave_stale_running_status(): runner = ModeScheduleRunner(wait=lambda event, duration: True) runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) runner._runs["car"].thread.join(2) assert runner.status("car")["status"] == "cancelled" assert runner.close()