Files
Cognitive_Agent/tests/unit/test_self_extension_delay.py
T

287 lines
9.8 KiB
Python

from __future__ import annotations
import json
import os
import unittest
from unittest.mock import MagicMock, patch
from cognitive_agent.application.self_extension import (
ExtensionIntegrationError,
MAX_PLAN_DELAY_MS,
MAX_TOTAL_PLAN_DELAY_MS,
SelfExtensionService,
SimulationGateway,
)
class SelfExtensionDelayPlanTests(unittest.TestCase):
def setUp(self) -> None:
self.service = SelfExtensionService(SimulationGateway())
@staticmethod
def _vehicle(command: str) -> dict[str, object]:
return {
"kind": "vehicle",
"capability_id": "",
"command": command,
"label": command,
"delay_ms": 0,
}
@staticmethod
def _delay(value: object) -> dict[str, object]:
return {
"kind": "delay",
"capability_id": "",
"command": "",
"label": "wait",
"delay_ms": value,
}
def _plan(
self,
steps: list[object],
*,
peripherals: list[dict[str, object]] | None = None,
) -> dict[str, object]:
proposed = {
"summary": "test plan",
"needs_confirmation": False,
"question": "",
"steps": steps,
}
vehicle_commands = [
str(step.get("command"))
for step in steps
if isinstance(step, dict)
and step.get("kind") == "vehicle"
and step.get("command")
]
with patch.dict(os.environ, {"OPENAI_API_KEY": "offline-test-key"}, clear=False):
with patch.object(
self.service,
"_request_gpt_plan",
return_value=json.dumps(proposed, ensure_ascii=False),
):
return self.service.plan(
{
"utterance": "test utterance",
"peripherals": peripherals or [],
"vehicle_devices": [
{
"name": "test vehicle device",
"actions": [
{"name": command, "command": command}
for command in vehicle_commands
],
}
],
}
)
def test_preserves_action_delay_action_order_and_canonicalizes_label(self) -> None:
peripherals = [
{
"id": "cactus-on",
"status": "learned",
"device_name": "跳舞仙人掌",
"action": "开启",
"risk": "low",
},
{
"id": "cactus-off",
"status": "learned",
"device_name": "跳舞仙人掌",
"action": "关闭",
"risk": "low",
},
]
steps = [
{
"kind": "peripheral",
"capability_id": "cactus-on",
"command": "",
"label": "ignored",
"delay_ms": 0,
},
self._delay(5_000),
{
"kind": "peripheral",
"capability_id": "cactus-off",
"command": "",
"label": "ignored",
"delay_ms": 0,
},
]
result = self._plan(steps, peripherals=peripherals)
self.assertEqual(
[step["kind"] for step in result["steps"]],
["peripheral", "delay", "peripheral"],
)
self.assertEqual(result["steps"][1]["delay_ms"], 5_000)
self.assertEqual(result["steps"][1]["label"], "等待 5 秒")
self.assertEqual(result["steps"][1]["risk"], "low")
def test_rejects_non_integer_or_out_of_range_delay_values(self) -> None:
invalid_values: tuple[object, ...] = (
"5000",
True,
False,
1.5,
None,
0,
-1,
MAX_PLAN_DELAY_MS + 1,
)
for value in invalid_values:
with self.subTest(value=value):
with self.assertRaises(ExtensionIntegrationError):
self._plan(
[
self._vehicle("light ambient on"),
self._delay(value),
self._vehicle("light ambient off"),
]
)
def test_rejects_delay_at_plan_boundaries_or_next_to_another_delay(self) -> None:
invalid_plans = (
[self._delay(1), self._vehicle("ac on")],
[self._vehicle("ac on"), self._delay(1)],
[
self._vehicle("ac on"),
self._delay(1),
self._delay(1),
self._vehicle("ac off"),
],
)
for steps in invalid_plans:
with self.subTest(steps=steps):
with self.assertRaises(ExtensionIntegrationError):
self._plan(steps)
def test_rejects_delay_that_becomes_boundary_after_invalid_action_is_removed(self) -> None:
with self.assertRaisesRegex(ExtensionIntegrationError, "两个可执行动作之间"):
self._plan(
[
{
"kind": "peripheral",
"capability_id": "not-learned",
"command": "",
"label": "invalid",
"delay_ms": 0,
},
self._delay(1_000),
self._vehicle("ac off"),
]
)
def test_enforces_cumulative_delay_limit(self) -> None:
accepted = self._plan(
[
self._vehicle("ac on"),
self._delay(MAX_PLAN_DELAY_MS),
self._vehicle("ac temp driver 24"),
self._delay(MAX_PLAN_DELAY_MS),
self._vehicle("ac off"),
]
)
self.assertEqual(
sum(
step.get("delay_ms", 0)
for step in accepted["steps"]
if step["kind"] == "delay"
),
MAX_TOTAL_PLAN_DELAY_MS,
)
with self.assertRaisesRegex(ExtensionIntegrationError, "累计延时"):
self._plan(
[
self._vehicle("ac on"),
self._delay(MAX_PLAN_DELAY_MS),
self._vehicle("ac temp driver 24"),
self._delay(MAX_PLAN_DELAY_MS),
self._vehicle("ac fan 3"),
self._delay(1),
self._vehicle("ac off"),
]
)
def test_rejects_device_fields_on_delay_step(self) -> None:
for field_name, value in (("command", "wait 5 seconds"), ("capability_id", "cap-1")):
with self.subTest(field_name=field_name):
delay_step = self._delay(5_000)
delay_step[field_name] = value
with self.assertRaisesRegex(ExtensionIntegrationError, "不能包含"):
self._plan(
[
self._vehicle("ac on"),
delay_step,
self._vehicle("ac off"),
]
)
def test_rejects_implicit_or_unknown_delay_representation(self) -> None:
non_delay_with_wait = self._vehicle("ac on")
non_delay_with_wait["delay_ms"] = 5_000
with self.assertRaisesRegex(ExtensionIntegrationError, "非延时步骤"):
self._plan([non_delay_with_wait, self._vehicle("ac off")])
with self.assertRaisesRegex(ExtensionIntegrationError, "未知步骤类型"):
self._plan(
[
self._vehicle("ac on"),
{
"kind": "wait",
"capability_id": "",
"command": "",
"label": "wait",
"delay_ms": 5_000,
},
self._vehicle("ac off"),
]
)
def test_gpt_schema_and_prompt_define_millisecond_delay_contract(self) -> None:
response = MagicMock()
response.__enter__.return_value = response
response.read.return_value = json.dumps({"output_text": "{}"}).encode("utf-8")
with patch.object(self.service, "openai_streaming", False):
with patch(
"cognitive_agent.application.self_extension.urlopen",
return_value=response,
) as open_request:
self.service._request_gpt_plan(
"开启跳舞仙人掌,5s 后关闭", [], [], "test-key"
)
request_payload = json.loads(open_request.call_args.args[0].data.decode("utf-8"))
plan_format = request_payload["text"]["format"]
step_schema = plan_format["schema"]["properties"]["steps"]["items"]
self.assertTrue(plan_format["strict"])
self.assertIn("delay", step_schema["properties"]["kind"]["enum"])
self.assertIn("delay_ms", step_schema["required"])
self.assertEqual(
step_schema["properties"]["delay_ms"]["maximum"],
MAX_PLAN_DELAY_MS,
)
prompt = "\n".join(
content["text"]
for message in request_payload["input"]
if message.get("role") == "system"
for content in message.get("content", [])
if content.get("type") == "input_text"
)
self.assertIn("delay_ms=5000", prompt)
self.assertIn(str(MAX_TOTAL_PLAN_DELAY_MS), prompt)
self.assertIn("不能位于首尾", prompt)
self.assertIn("绝不能把等待时间写进 command", prompt)
if __name__ == "__main__":
unittest.main()