from __future__ import annotations import json import threading import time from pathlib import Path from cognitive_agent.application.mode_profile import ModeProfileRegistry from cognitive_agent.application.mode_scene_catalog import ( ModeSceneCatalogPublisher, ModeSceneMqttBurstPublisher, build_mode_scene_catalog, ) from cognitive_agent.application.mode_schedule import ModeSchedule from cognitive_agent.application.seat_service import REPOSITORY_ROOT def _write_office_preset(path: Path) -> None: path.parent.mkdir(parents=True) path.write_text( """ id: office name: 办公模式 description: N7 办公场景。 icon: briefcase scope: vehicle_ids: [denza_n7] operations: - domain: climate capability: climate.set_temperature target_scope: passenger operation: set parameters: temperature_c: 24 """.strip() + "\n", encoding="utf-8", ) def test_publisher_tracks_create_override_and_delete(tmp_path: Path) -> None: preset_root = tmp_path / "presets" user_root = tmp_path / "user" _write_office_preset( preset_root / "vehicles" / "denza_n7" / "preset" / "office.yaml" ) registry = ModeProfileRegistry(preset_root, user_root=user_root) catalog_path = tmp_path / "scenses" / "demo-scenes.json" publisher = ModeSceneCatalogPublisher( catalog_path, "denza_n7", server_id="test-n7", ) registry.add_change_listener(publisher.publish) initial = json.loads(catalog_path.read_text(encoding="utf-8")) assert initial["service"] == "n7-scenes" assert initial["schema_version"] == 1 assert initial["server_id"] == "test-n7" assert initial["scene_count"] == 1 assert initial["scenes"][0]["origin"] == "preset" assert initial["scenes"][0]["actions"] == [ { "type": "climate_temperature", "at_ms": 0, "domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "operation": "set", "parameters": {"temperature_c": 24}, "zone": "passenger", "celsius": 24, } ] created = registry.save_mode( "阅读模式", [ { "domain": "ambient_light", "capability": "ambient_light.set_illumination", "target_scope": "vehicle", "parameters": {"color_hex": "#E8F3FF", "brightness_pct": 40}, } ], vehicle_id="denza_n7", ) after_create = json.loads(catalog_path.read_text(encoding="utf-8")) created_scene = next( scene for scene in after_create["scenes"] if scene["id"] == created.id ) assert after_create["scene_count"] == 2 assert created_scene["origin"] == "user" assert created_scene["deletable"] is True assert created_scene["actions"][0]["parameters"]["brightness_pct"] == 40 fan_mode = registry.save_mode( "微风模式", [ { "domain": "climate", "capability": "climate.set_fan_level", "target_scope": "vehicle", "parameters": {"level": 2}, } ], vehicle_id="denza_n7", ) after_fan_create = json.loads(catalog_path.read_text(encoding="utf-8")) fan_scene = next( scene for scene in after_fan_create["scenes"] if scene["id"] == fan_mode.id ) assert fan_scene["actions"][0]["type"] == "climate_fan" assert fan_scene["actions"][0]["level"] == 2 registry.save_mode( "专注办公模式", [ { "domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "parameters": {"temperature_c": 26}, } ], mode_id="office", vehicle_id="denza_n7", ) after_override = json.loads(catalog_path.read_text(encoding="utf-8")) office = next(scene for scene in after_override["scenes"] if scene["id"] == "office") assert office["label"] == "专注办公模式" assert office["origin"] == "customized" assert office["preset_override"] is True assert office["actions"][0]["celsius"] == 26 registry.delete_user_mode(created.id, "denza_n7") registry.delete_user_mode(fan_mode.id, "denza_n7") registry.delete_user_mode("office", "denza_n7") after_delete = json.loads(catalog_path.read_text(encoding="utf-8")) assert [scene["id"] for scene in after_delete["scenes"]] == ["office"] assert after_delete["scenes"][0]["label"] == "办公模式" assert after_delete["scenes"][0]["origin"] == "preset" def test_repository_presets_are_exported_without_internal_command_flows() -> None: registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes") catalog = build_mode_scene_catalog( registry, "denza_n7", generated_at="2026-09-09T00:00:00Z", ) assert catalog["scene_count"] >= 13 assert len({scene["id"] for scene in catalog["scenes"]}) == catalog["scene_count"] by_id = {scene["id"]: scene for scene in catalog["scenes"]} assert by_id["office"]["operation_count"] == len(registry.get_profile("office", "denza_n7").operations) assert by_id["rocking_horse"]["behavior"] == "cyclic" assert by_id["rocking_horse"]["timeline"]["repeat"] == "until_stopped_or_timeout" encoded = json.dumps(catalog, ensure_ascii=False) assert "command_flow" not in encoded assert "source_request_id" not in encoded def test_saved_editor_flow_projects_all_native_and_extension_operations( tmp_path: Path, ) -> None: registry = ModeProfileRegistry( tmp_path / "presets", user_root=tmp_path / "profiles", ) operations = [ { "domain": "climate", "capability": "climate.set_temperature", "target_scope": "driver", "parameters": {"temperature_c": 24}, }, { "domain": "interior_light", "capability": "interior_light.set_reading_power", "target_scope": "all", "parameters": {"state": "on"}, }, { "domain": "roof", "capability": "roof.set_shade_motion", "target_scope": "vehicle", "parameters": {"motion": "open"}, }, { "domain": "window", "capability": "window.set_position", "target_scope": "front_right", "parameters": {"position_pct": 50}, }, ] def configured_operation( operation_id: str, capability: str, zone: str, parameters: dict[str, object], *, device: str, ) -> dict[str, object]: return { "id": operation_id, "capability": capability, "parameters": parameters, "target": {"device": device, "zone": zone}, } function_configs = { "native_climate": { "spec": { "domain": "climate", "operations": [ configured_operation( "driver_set_temperature_1", "climate.set_temperature", "driver", {"temperature_c": 24}, device="hvac", ) ], } }, "native_interior_light": { "spec": { "domain": "interior_light", "operations": [ configured_operation( "all_set_reading_power_1", "interior_light.set_reading_power", "all", {"state": "on"}, device="reading_light", ) ], } }, "native_roof": { "spec": { "domain": "roof", "operations": [ configured_operation( "vehicle_set_shade_motion_1", "roof.set_shade_motion", "vehicle", {"motion": "open"}, device="roof_shade", ) ], } }, "native_window": { "spec": { "domain": "window", "operations": [ configured_operation( "front_right_set_position_1", "window.set_position", "front_right", {"position_pct": 50}, device="window", ) ], } }, "self_extension": { "spec": { "domain": "self_extension", "operations": [ { "id": "ir-slot:toy-light-001:2-1", "capability": "infrared.emit", "parameters": {"action": "打开", "slot": 2}, "target": { "card_uid": "2CC8B103", "device": "toy-light-001", "name": "闪烁灯", }, }, { "id": "ir-slot:toy-light-001:6-2", "capability": "infrared.emit", "parameters": {"action": "设置为白色", "slot": 6}, "target": { "card_uid": "2CC8B103", "device": "toy-light-001", "name": "闪烁灯", }, }, ], } }, } registry.save_mode( "阅读模式", operations, mode_id="mode-reading", vehicle_id="denza_n7", command_flow={"function_configs": function_configs}, ) catalog = build_mode_scene_catalog( registry, "denza_n7", generated_at="2026-09-15T00:00:00Z", ) reading = next( scene for scene in catalog["scenes"] if scene["id"] == "mode-reading" ) assert reading["operation_count"] == 6 assert len(reading["actions"]) == 6 assert [action["capability"] for action in reading["actions"]] == [ "climate.set_temperature", "interior_light.set_reading_power", "roof.set_shade_motion", "window.set_position", "infrared.emit", "infrared.emit", ] assert reading["domains"] == [ "climate", "interior_light", "roof", "self_extension", "window", ] first_infrared, second_infrared = reading["actions"][-2:] assert first_infrared["parameters"] == {"action": "打开", "slot": 2} assert second_infrared["parameters"] == {"action": "设置为白色", "slot": 6} assert first_infrared["target"] == { "card_uid": "2CC8B103", "device": "toy-light-001", "name": "闪烁灯", } assert second_infrared["target"] == first_infrared["target"] assert first_infrared["operation"] == "emit" def test_mode_scene_mqtt_burst_publishes_only_one_exact_card_three_times() -> None: publications: list[tuple[float, dict[str, object]]] = [] complete = threading.Event() scene: dict[str, object] = { "id": "mode-reading", "label": "阅读模式", "name": "阅读模式", "background_time": "noon", "description": "由自然语言生成的自定义模式。", "icon": "bookmark-plus", "aliases": ["阅读模式", "阅读"], "origin": "user", "deletable": True, "behavior": "static", "domains": ["climate"], "operation_count": 1, "scope": {"common": False, "vehicle_ids": ["denza_n7"]}, "profile_revision": "1234567890abcdef", "actions": [ { "type": "climate_temperature", "at_ms": 0, "domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "operation": "set", "parameters": {"temperature_c": 24}, "zone": "passenger", "celsius": 24, } ], } def publish(payload: dict[str, object]) -> dict[str, object]: publications.append((time.monotonic(), payload)) if len(publications) == 3: complete.set() return {"delivery_status": "published", "message_id": len(publications)} publisher = ModeSceneMqttBurstPublisher( publish, topic="from_pc/scenes", burst_window_s=0.12, ) queued_at = time.monotonic() try: queued = publisher.queue(scene) assert complete.wait(timeout=1.0) assert queued == { "state": "queued", "topic": "from_pc/scenes", "scene_id": "mode-reading", "burst_count": 3, "burst_window_ms": 120, } assert len(publications) == 3 assert publications[-1][0] - queued_at < 0.2 assert all(payload == scene for _, payload in publications) assert all("scenes" not in payload for _, payload in publications) assert publisher.snapshot()["state"] == "burst_complete" finally: publisher.close() def _schedule() -> dict: return ModeSchedule.from_dict({ "schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 2, "phases": [ {"id": "warm", "name": "暖舱", "duration_ms": 1000, "operations": [ {"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle", "operation": "set", "parameters": {"temperature_c": 28}}, ]}, {"id": "settle", "name": "舒适", "duration_ms": 2000, "operations": [ {"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle", "operation": "set", "parameters": {"temperature_c": 24}}, {"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger", "operation": "set", "parameters": {"position_pct": 45}}, ]}, ], }).to_dict() def test_schedule_catalog_preserves_phases_but_never_exports_flat_executable_actions(tmp_path: Path) -> None: registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user") schedule = _schedule() profile = registry.save_mode( "分阶段舒适模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7", schedule=ModeSchedule.from_dict(schedule), command_flow={"source_request_id": "req-private", "raw_can": "private-transport-bytes"}, ) catalog = build_mode_scene_catalog(registry, "denza_n7") scene = next(item for item in catalog["scenes"] if item["id"] == profile.id) assert scene["schedule"] == schedule assert scene["behavior"] == "cyclic" assert scene["operation_count"] == 3 assert scene["domains"] == ["climate", "seat"] assert scene["actions"] == [], "legacy clients must not execute all timeline stages at once" assert scene["execution"] == { "kind": "server_schedule", "mode_id": profile.id, "client_actions_supported": False, "requires_authentication": True, } assert "服务端时间轴" in scene["execution_notice"] assert scene["preview_actions_scope"] == "single_cycle" preview = scene["preview_actions"] assert [action["phase_id"] for action in preview] == ["warm", "settle", "settle"] assert [action["at_ms"] for action in preview] == [0, 1000, 1000] assert [action["phase_duration_ms"] for action in preview] == [1000, 2000, 2000] assert [action["celsius"] for action in preview if action["type"] == "climate_temperature"] == [28, 24] assert preview[-1]["target_scope"] == "passenger" assert preview[-1]["parameters"] == {"position_pct": 45} assert ModeSchedule.from_dict(scene["schedule"]).total_duration_ms == 6000 encoded = json.dumps(catalog) assert "source_request_id" not in encoded assert "private-transport-bytes" not in encoded assert "command_flow" not in encoded def test_schedule_publisher_updates_after_save_reload_duration_change_and_delete(tmp_path: Path) -> None: registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user") publisher = ModeSceneCatalogPublisher(tmp_path / "catalog.json", "denza_n7") registry.add_change_listener(publisher.publish) assert publisher.snapshot()["scene_count"] == 0 schedule = _schedule() created = registry.save_mode( "动态释压模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7", schedule=ModeSchedule.from_dict(schedule), ) first = publisher.snapshot() assert first["scenes"][0]["schedule"] == schedule registry.reload() assert publisher.snapshot() == first schedule["phases"][1]["duration_ms"] = 5000 registry.save_mode( created.name, schedule["phases"][0]["operations"], vehicle_id="denza_n7", mode_id=created.id, schedule=ModeSchedule.from_dict(schedule), ) changed = publisher.snapshot() assert changed["catalog_revision"] != first["catalog_revision"] assert changed["scenes"][0]["profile_revision"] != first["scenes"][0]["profile_revision"] assert changed["scenes"][0]["schedule"]["phases"][1]["duration_ms"] == 5000 assert changed["scenes"][0]["actions"] == [] assert json.loads(publisher.path.read_text(encoding="utf-8")) == changed registry.delete_user_mode(created.id, "denza_n7") assert publisher.snapshot()["scene_count"] == 0 assert publisher.snapshot()["scenes"] == []