322 lines
15 KiB
Python
322 lines
15 KiB
Python
"""A mode device list is an additive configuration edit, never live vehicle control."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from copy import deepcopy
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||
from cognitive_agent.application.assistant_plan_guard import MAX_PLAN_REQUESTS
|
||
from cognitive_agent.application.codex_assistant import (
|
||
CodexAssistantService,
|
||
_authorize_vehicle_request,
|
||
)
|
||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||
|
||
|
||
@pytest.fixture
|
||
def addition_service(tmp_path, monkeypatch):
|
||
for variable in (
|
||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||
):
|
||
monkeypatch.setenv(variable, "false")
|
||
service = SeatDemoService(REPOSITORY_ROOT)
|
||
service.artifact_root = tmp_path / "runs"
|
||
user_root = tmp_path / "user-modes"
|
||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||
try:
|
||
with (
|
||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("mode edit reached real CAN")) as reserve,
|
||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("mode edit reached MQTT")) as mqtt,
|
||
patch.object(service.openai, "interpret", side_effect=AssertionError("deterministic mode edit contacted a model")),
|
||
):
|
||
yield service, AssistantCapabilityCatalog(service), user_root
|
||
reserve.assert_not_called()
|
||
mqtt.assert_not_called()
|
||
finally:
|
||
service.close()
|
||
|
||
|
||
def _preview(service, text, *, draft=None, plan=None, conversation="conv-device-additions"):
|
||
return service.preview_native_can(
|
||
text, provider="local", conversation_id=conversation, vehicle_id="denza_n7",
|
||
execution_mode="hardware", mode_profile_draft_context=deepcopy(draft), assistant_plan=plan,
|
||
)
|
||
|
||
|
||
def _actions(result):
|
||
return (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
|
||
|
||
|
||
def _action_map(result):
|
||
return {
|
||
(action["capability"], action["target"].get("instance") or action["target"].get("zone")):
|
||
action["parameters"] for action in _actions(result)
|
||
}
|
||
|
||
|
||
def _profile_map(profile):
|
||
return {(op.capability, op.target_scope): op.parameters for op in profile.operations}
|
||
|
||
|
||
def _seat_actions(mapping):
|
||
return {key: value for key, value in mapping.items() if key[0].startswith("seat.")}
|
||
|
||
|
||
def _reference(result):
|
||
return {key: result["mode_profile_draft"][key] for key in ("name", "source_request_id", "vehicle_id")}
|
||
|
||
|
||
def _hashes(root):
|
||
return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in root.rglob("*.yaml")}
|
||
|
||
|
||
def _seed_old_seat_only_draft(service, *, scope="passenger", name="躺平模式", extra=""):
|
||
# A deliberately explicit, seat-only request represents an older legitimate
|
||
# preview without relying on ignored developer artifacts or a model stub.
|
||
settings = (
|
||
"副驾座椅水平向前调整到20%,副驾靠背调整到45%,副驾座椅坐盆调整到30%,副驾腿托调整到70%"
|
||
if scope == "passenger" else "主驾座椅通风调整到低档,主驾座椅加热调整到低档"
|
||
)
|
||
result = _preview(service, f"构建一个{name},{settings}{extra}")
|
||
_assert_draft(result)
|
||
assert {target for _, target in _seat_actions(_action_map(result))} == {scope}
|
||
return result
|
||
|
||
|
||
def _assert_draft(result):
|
||
assert result["status"] == "succeeded", (result.get("mode_creation_failure"), result.get("validation"))
|
||
assert result["orchestration_route"] == "mode_profile_create_preview"
|
||
assert result["execution_mode"] == "dry_run"
|
||
assert result["hardware_execution_allowed"] is False
|
||
assert result.get("mode_profile_draft")
|
||
assert len(_actions(result)) == result["mode_profile_draft"]["operation_count"]
|
||
assert len(_actions(result)) <= MAX_PLAN_REQUESTS
|
||
assert len(_action_map(result)) == len(_actions(result)), "append duplicated an existing action key"
|
||
|
||
|
||
def _assistant(service, catalog, message, *, draft=None):
|
||
client = MagicMock()
|
||
client.available = False
|
||
assistant = CodexAssistantService(client)
|
||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||
fast = catalog.fast_answer(message, snapshot)
|
||
raw = []
|
||
|
||
def execute(instruction, progress, assistant_plan=None):
|
||
result = _preview(service, instruction, draft=draft, plan=assistant_plan)
|
||
raw.append(result)
|
||
return result
|
||
|
||
answer = assistant.stream(
|
||
message, conversation_id="conv-device-additions", principal="device-additions-test", history=[],
|
||
emit=lambda _event: True, execute_control=execute,
|
||
authoritative_answer=fast.answer if fast else None,
|
||
capability_snapshot=snapshot, capability_context=catalog.compact_context(snapshot),
|
||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||
control_terms=catalog.control_terms(snapshot), mode_profile_draft_context=draft,
|
||
)
|
||
assert len(raw) == 1, answer
|
||
client.start_structured_turn.assert_not_called()
|
||
client.start_turn.assert_not_called()
|
||
return answer, raw[0]
|
||
|
||
|
||
@pytest.mark.parametrize("message", ["构建一个躺平模式", "帮我构建一个躺平模式吧", "构建一个主驾躺平模式"])
|
||
def test_flat_mode_fast_template_includes_requested_cabin_domains(addition_service, message):
|
||
service, catalog, user_root = addition_service
|
||
answer, result = _assistant(service, catalog, message)
|
||
assert answer["success"] is True, answer
|
||
assert answer["route"] == "scene_template"
|
||
_assert_draft(result)
|
||
assert {"seat", "climate", "ambient_light", "interior_light", "window"} <= {
|
||
action["domain"] for action in _actions(result)
|
||
}
|
||
assert {target for _, target in _seat_actions(_action_map(result))} == (
|
||
{"driver"} if "主驾" in message else {"passenger"}
|
||
)
|
||
assert not _hashes(user_root)
|
||
|
||
|
||
@pytest.mark.parametrize("message", [
|
||
"在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作",
|
||
"帮我给躺平模式加上空调、氛围灯、阅读灯和车窗",
|
||
"给躺平模式增加空调、氛围灯、阅读灯、车窗的联动",
|
||
"躺平模式中补充空调、氛围灯、阅读灯和车窗动作",
|
||
"调整躺平模式,加入空调、氛围灯、阅读灯、车窗",
|
||
])
|
||
@pytest.mark.parametrize("scope", ["passenger", "driver"])
|
||
def test_device_list_addition_revises_old_draft_without_execution_or_losing_seat_parameters(addition_service, message, scope):
|
||
service, catalog, user_root = addition_service
|
||
original = _seed_old_seat_only_draft(service, scope=scope)
|
||
before = deepcopy(_action_map(original))
|
||
reference = _reference(original)
|
||
authorization = _authorize_vehicle_request(message, mode_profile_draft_context=reference)
|
||
assert authorization.reason == "explicit_mode_profile_draft_update"
|
||
answer, revised = _assistant(service, catalog, message, draft=reference)
|
||
assert answer["success"] is True, answer
|
||
_assert_draft(revised)
|
||
assert _seat_actions(_action_map(revised)) == _seat_actions(before)
|
||
assert {"climate", "ambient_light", "interior_light", "window"} <= {
|
||
action["domain"] for action in _actions(revised)
|
||
}
|
||
assert revised["mode_profile_draft"]["revises_source_request_id"] == original["request_id"]
|
||
assert revised["mode_profile_draft"]["draft_revision"] == 2
|
||
assert _action_map(original) == before
|
||
assert not _hashes(user_root)
|
||
|
||
|
||
def test_repeated_device_list_is_idempotent_and_keeps_existing_custom_values(addition_service):
|
||
service, _, user_root = addition_service
|
||
original = _seed_old_seat_only_draft(service, extra=",空调调整到26度,关闭氛围灯")
|
||
message = "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作"
|
||
first = _preview(service, message, draft=_reference(original))
|
||
_assert_draft(first)
|
||
for key, params in _action_map(original).items():
|
||
assert _action_map(first)[key] == params
|
||
second = _preview(service, message, draft=_reference(first))
|
||
_assert_draft(second)
|
||
assert _action_map(second) == _action_map(first)
|
||
assert second["mode_profile_draft"]["draft_revision"] == 3
|
||
assert not _hashes(user_root)
|
||
|
||
|
||
@pytest.mark.parametrize("scope", ["passenger", "driver"])
|
||
def test_saved_mode_addition_changes_only_selected_profile_and_can_be_replayed(addition_service, scope):
|
||
service, _, user_root = addition_service
|
||
original = _seed_old_seat_only_draft(service, scope=scope)
|
||
draft = original["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"]
|
||
unrelated = service.mode_profiles.save_mode("不会改变的模式", [{
|
||
"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
||
"parameters": {"temperature_c": 23},
|
||
}], vehicle_id="denza_n7")
|
||
unrelated_revision = service.mode_profiles.profile_revision(unrelated)
|
||
before = _profile_map(service.mode_profiles.get_profile(mode_id, "denza_n7"))
|
||
updated = _preview(service, "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作")
|
||
assert updated["status"] == "succeeded", updated.get("validation")
|
||
assert updated["orchestration_route"] == "mode_profile_update"
|
||
assert updated["execution_mode"] == "dry_run"
|
||
assert updated["hardware_execution_allowed"] is False
|
||
after = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||
assert _seat_actions(_profile_map(after)) == _seat_actions(before)
|
||
assert {"climate", "ambient_light", "interior_light", "window"} <= {op.domain for op in after.operations}
|
||
assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile(unrelated.id, "denza_n7")) == unrelated_revision
|
||
assert len(_hashes(user_root)) == 2
|
||
replayed = service.execute_mode_profile_card(mode_id, execution_mode="dry_run", vehicle_id="denza_n7")
|
||
assert replayed["status"] == "succeeded"
|
||
assert _seat_actions(_action_map(replayed)) == _seat_actions(before)
|
||
|
||
|
||
@pytest.mark.parametrize("mutation", ["request_id", "vehicle", "name", "conversation"])
|
||
def test_invalid_or_stale_context_never_becomes_live_control_or_changes_saved_mode(addition_service, mutation):
|
||
service, _, user_root = addition_service
|
||
original = _seed_old_seat_only_draft(service)
|
||
reference = _reference(original)
|
||
conversation = "conv-device-additions"
|
||
if mutation == "request_id":
|
||
reference["source_request_id"] = "req-missing-old-source"
|
||
elif mutation == "vehicle":
|
||
reference["vehicle_id"] = "other_vehicle"
|
||
elif mutation == "name":
|
||
reference["name"] = "其他模式"
|
||
else:
|
||
conversation = "conv-another-conversation"
|
||
try:
|
||
result = _preview(service, "在躺平模式中加入空调和车窗", draft=reference, conversation=conversation)
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
assert result["status"] != "succeeded"
|
||
assert not result.get("mode_profile_draft")
|
||
assert result["execution_mode"] == "dry_run"
|
||
assert result["hardware_execution_allowed"] is False
|
||
assert not _actions(result)
|
||
assert not result.get("command_plans")
|
||
assert not _hashes(user_root)
|
||
|
||
|
||
@pytest.mark.parametrize("message", [
|
||
"在躺平模式中加入空调和后备箱动作",
|
||
"在躺平模式中加入阅读灯和天窗动作",
|
||
"在躺平模式中加入空调并把温度调整到99度",
|
||
])
|
||
def test_unsupported_or_out_of_range_addition_rejects_all_changes_atomically(addition_service, message):
|
||
service, _, user_root = addition_service
|
||
original = _seed_old_seat_only_draft(service)
|
||
try:
|
||
result = _preview(service, message, draft=_reference(original))
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
assert result["status"] != "succeeded"
|
||
assert not result.get("mode_profile_draft")
|
||
assert not _actions(result)
|
||
assert not result.get("command_plans")
|
||
assert result["hardware_execution_allowed"] is False
|
||
assert not _hashes(user_root)
|
||
|
||
|
||
def test_adding_devices_to_full_twelve_action_draft_never_truncates_original_actions(addition_service):
|
||
service, _, user_root = addition_service
|
||
operations = [
|
||
("climate.set_power", "vehicle", {"state": "on"}),
|
||
("climate.set_temperature", "vehicle", {"temperature_c": 24}),
|
||
("climate.set_fan_level", "vehicle", {"level": 2}),
|
||
("climate.set_cycle_mode", "vehicle", {"mode": "internal"}),
|
||
("climate.set_control_mode", "vehicle", {"mode": "auto"}),
|
||
("climate.set_front_defrost", "vehicle", {"state": "off"}),
|
||
("roof.set_shade_position", "vehicle", {"position_pct": 0}),
|
||
("window.set_position", "front_left", {"position_pct": 0}),
|
||
("window.set_position", "front_right", {"position_pct": 0}),
|
||
("ambient_light.set_power", "vehicle", {"state": "off"}),
|
||
("interior_light.set_indoor_power", "vehicle", {"state": "off"}),
|
||
("interior_light.set_reading_power", "all", {"state": "off"}),
|
||
]
|
||
plan = {
|
||
"detected_domains": sorted({cap.split(".", 1)[0] for cap, _, _ in operations}),
|
||
"requests": [{
|
||
"domain": cap.split(".", 1)[0], "capability": cap, "target_scope": target,
|
||
"operation": "set", "parameters": [{"name": name, "value": value} for name, value in params.items()],
|
||
"duration_ms": None, "reason": "固定目标边界回归。",
|
||
} for cap, target, params in operations],
|
||
"ambiguities": [], "confidence": 0.95, "decision_summary": ["验证12项原动作保留。"],
|
||
}
|
||
original = _preview(service, "构建一个满载参数模式", plan=plan)
|
||
_assert_draft(original)
|
||
assert len(_actions(original)) == 12
|
||
source_path = service.artifact_root / original["request_id"] / "result.json"
|
||
source_hash = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
||
try:
|
||
result = _preview(service, "在满载参数模式中加入副驾座椅通风动作", draft=_reference(original))
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
assert result["status"] != "succeeded"
|
||
assert not result.get("mode_profile_draft")
|
||
assert not _actions(result)
|
||
assert hashlib.sha256(source_path.read_bytes()).hexdigest() == source_hash
|
||
assert not _hashes(user_root)
|
||
|
||
|
||
def test_previous_artifact_without_new_metadata_remains_an_editable_server_owned_draft(addition_service):
|
||
service, _, user_root = addition_service
|
||
original = _seed_old_seat_only_draft(service)
|
||
legacy = deepcopy(original)
|
||
legacy["mode_profile_draft"].pop("draft_revision", None)
|
||
legacy["mode_profile_draft"].pop("generation_source", None)
|
||
service._persist(legacy)
|
||
result = _preview(service, "在躺平模式中加入空调、氛围灯、阅读灯、车窗", draft=_reference(legacy))
|
||
_assert_draft(result)
|
||
assert result["mode_profile_draft"]["draft_revision"] == 2
|
||
assert _seat_actions(_action_map(result)) == _seat_actions(_action_map(original))
|
||
assert not _hashes(user_root)
|
||
assert json.loads((service.artifact_root / original["request_id"] / "result.json").read_text(encoding="utf-8"))["request_id"] == original["request_id"]
|