279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""Natural-language edits cannot flatten or silently broaden dynamic phases."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
|
from cognitive_agent.application.mode_schedule import ModeSchedule
|
|
from cognitive_agent.application.mode_schedule_edit import edit_schedule
|
|
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
|
|
|
|
|
@pytest.fixture
|
|
def service(tmp_path):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
|
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
|
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
|
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
|
|
},
|
|
):
|
|
instance = SeatDemoService(REPOSITORY_ROOT)
|
|
instance.artifact_root = tmp_path / "runs"
|
|
instance.mode_profiles = ModeProfileRegistry(
|
|
REPOSITORY_ROOT / "config" / "modes",
|
|
user_root=tmp_path / "user-modes",
|
|
)
|
|
try:
|
|
with patch.object(
|
|
instance.hardware_execution,
|
|
"reserve",
|
|
side_effect=AssertionError("schedule edit reached hardware"),
|
|
):
|
|
yield instance
|
|
finally:
|
|
instance.close()
|
|
|
|
|
|
def _build(service):
|
|
template = service.scene_templates.get_template("boarding-warmup")
|
|
result = service.preview_native_can(
|
|
"构建一个上车预热模式",
|
|
provider="local",
|
|
conversation_id="conv-schedule-edit",
|
|
execution_mode="hardware",
|
|
vehicle_id="denza_n7",
|
|
assistant_plan=template.plan(),
|
|
)
|
|
assert result["status"] == "succeeded", result.get("validation")
|
|
return result
|
|
|
|
|
|
def _edit(service, built, change):
|
|
draft = built["mode_profile_draft"]
|
|
reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}
|
|
return service.preview_native_can(
|
|
f"把上车预热模式的{change}",
|
|
provider="local",
|
|
conversation_id="conv-schedule-edit",
|
|
execution_mode="hardware",
|
|
vehicle_id="denza_n7",
|
|
mode_profile_draft_context=reference,
|
|
)
|
|
|
|
|
|
def _parameters(schedule, index, capability, target="vehicle"):
|
|
return next(
|
|
op["parameters"]
|
|
for op in schedule["phases"][index]["operations"]
|
|
if op["capability"] == capability and op["target_scope"] == target
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"change",
|
|
(
|
|
"第二阶段空调温度调整到26度",
|
|
"第2个阶段的空调温度变成26度",
|
|
"舒适恒温阶段空调温度改为26度",
|
|
),
|
|
)
|
|
def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change):
|
|
built = _build(service)
|
|
original = deepcopy(built["schedule"])
|
|
revised = _edit(service, built, change)
|
|
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
|
expected = deepcopy(original)
|
|
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
|
assert revised["schedule"] == expected
|
|
assert built["schedule"] == original
|
|
assert revised["mode_profile_draft"]["schedule"] == expected
|
|
assert revised["mode_profile_draft"]["draft_revision"] == 2
|
|
assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"]
|
|
assert revised["execution_mode"] == "dry_run"
|
|
assert revised["hardware_execution_allowed"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("change", "expected_hold", "expected_repeat"),
|
|
(
|
|
("第二阶段持续时间调整为3分钟", 180000, 1),
|
|
("第二阶段保持30秒", 30000, 1),
|
|
("第二阶段时长改成2.5分钟", 150000, 1),
|
|
("循环次数改成3次", 300000, 3),
|
|
("重复两次", 300000, 2),
|
|
),
|
|
)
|
|
def test_draft_time_and_repeat_edits_keep_operations(
|
|
service, change, expected_hold, expected_repeat
|
|
):
|
|
built = _build(service)
|
|
revised = _edit(service, built, change)
|
|
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
|
assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold
|
|
assert revised["schedule"]["repeat_count"] == expected_repeat
|
|
for index in (0, 1):
|
|
assert (
|
|
revised["schedule"]["phases"][index]["operations"]
|
|
== built["schedule"]["phases"][index]["operations"]
|
|
)
|
|
|
|
|
|
def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service):
|
|
built = _build(service)
|
|
revised = _edit(service, built, "全部阶段空调温度调整到25度")
|
|
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
|
expected = deepcopy(built["schedule"])
|
|
for index in (0, 1):
|
|
_parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25
|
|
assert revised["schedule"] == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"change",
|
|
(
|
|
"空调温度调整到25度",
|
|
"第三阶段空调温度调整到25度",
|
|
"第二阶段持续16分钟",
|
|
"循环21次",
|
|
"重复1.5次",
|
|
"持续30秒",
|
|
"第二阶段主驾靠背调整到90%",
|
|
"第二阶段空调温度调整到99度",
|
|
"第二阶段打开后备箱",
|
|
"第二阶段空调温度调整到26度并打开后备箱",
|
|
),
|
|
)
|
|
def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change):
|
|
built = _build(service)
|
|
original = deepcopy(built["schedule"])
|
|
revised = _edit(service, built, change)
|
|
assert revised["status"] in {"needs_confirmation", "validation_failed"}
|
|
assert not revised.get("mode_profile_draft")
|
|
assert revised["hardware_execution_allowed"] is False
|
|
source = service._load_mode_profile_draft_reference(
|
|
{
|
|
key: built["mode_profile_draft"][key]
|
|
for key in ("name", "source_request_id", "vehicle_id")
|
|
},
|
|
conversation_id="conv-schedule-edit",
|
|
mode_name="上车预热模式",
|
|
vehicle_id="denza_n7",
|
|
)[1]
|
|
assert source["schedule"] == original
|
|
|
|
|
|
def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service):
|
|
built = _build(service)
|
|
draft = built["mode_profile_draft"]
|
|
saved = service.create_mode_profile_card(
|
|
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
|
|
)
|
|
mode_id = saved["mode_profile"]["id"]
|
|
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
|
path = Path(profile.source)
|
|
before = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
rejected = service.preview_native_can(
|
|
"把上车预热模式的空调温度调整到26度",
|
|
provider="local",
|
|
conversation_id="conv-saved-schedule-edit",
|
|
execution_mode="hardware",
|
|
vehicle_id="denza_n7",
|
|
)
|
|
assert rejected["status"] == "needs_confirmation"
|
|
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
|
|
|
|
revised = service.preview_native_can(
|
|
"把上车预热模式的第二阶段空调温度调整到26度",
|
|
provider="local",
|
|
conversation_id="conv-saved-schedule-edit",
|
|
execution_mode="hardware",
|
|
vehicle_id="denza_n7",
|
|
)
|
|
assert revised["status"] == "succeeded", revised.get("validation")
|
|
assert revised["orchestration_route"] == "mode_profile_update"
|
|
assert revised["hardware_execution_allowed"] is False
|
|
actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict()
|
|
expected = deepcopy(built["schedule"])
|
|
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
|
assert actual == expected
|
|
assert revised["schedule"] == expected
|
|
assert revised["mode_profile"]["behavior"] == "cyclic"
|
|
|
|
|
|
def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service):
|
|
built = _build(service)
|
|
revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟")
|
|
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
|
repeated = _edit(service, revised, "重复两次")
|
|
assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure")
|
|
expected = deepcopy(built["schedule"])
|
|
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
|
expected["phases"][1]["duration_ms"] = 180000
|
|
expected["repeat_count"] = 2
|
|
assert repeated["schedule"] == expected
|
|
assert repeated["mode_profile_draft"]["draft_revision"] == 3
|
|
draft = repeated["mode_profile_draft"]
|
|
saved = service.create_mode_profile_card(
|
|
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
|
|
)
|
|
assert (
|
|
service.mode_profiles.get_profile(
|
|
saved["mode_profile"]["id"], "denza_n7"
|
|
).schedule.to_dict()
|
|
== expected
|
|
)
|
|
|
|
|
|
def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service):
|
|
source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"]
|
|
for phase in source["phases"]:
|
|
for operation in phase["operations"]:
|
|
if operation["capability"] == "climate.set_temperature":
|
|
operation["target_scope"] = "passenger"
|
|
schedule = ModeSchedule.from_dict(source)
|
|
updated = edit_schedule(
|
|
schedule,
|
|
"全部阶段空调温度调整到25度",
|
|
parse_operations=lambda _: [
|
|
{
|
|
"domain": "climate",
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "vehicle",
|
|
"operation": "set",
|
|
"parameters": {"temperature_c": 25},
|
|
}
|
|
],
|
|
target_is_explicit=service._mode_update_target_is_explicit,
|
|
)
|
|
for index in (0, 1):
|
|
assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == {
|
|
"temperature_c": 25
|
|
}
|
|
assert not any(
|
|
op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle"
|
|
for op in updated.phases[index].operations
|
|
)
|
|
|
|
|
|
def test_phase_motor_duration_cannot_become_a_hold_duration(service):
|
|
built = _build(service)
|
|
result = _edit(service, built, "第二阶段副驾靠背向后移动3s")
|
|
assert result["status"] in {"needs_confirmation", "validation_failed"}
|
|
assert not result.get("mode_profile_draft")
|
|
|
|
|
|
def test_stage_selector_does_not_authorize_delayed_action_semantics(service):
|
|
built = _build(service)
|
|
result = _edit(service, built, "第二阶段30秒后空调温度调整到26度")
|
|
assert result["status"] == "needs_confirmation"
|
|
assert not result.get("mode_profile_draft")
|