514 lines
19 KiB
Python
514 lines
19 KiB
Python
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()
|