From ad0c1f4ed1016cd1e243a4df6111aa6116ca6e82 Mon Sep 17 00:00:00 2001 From: Miayui <774713819@qq.com> Date: Mon, 21 Sep 2026 12:07:49 +0800 Subject: [PATCH] feat(scenes): add deterministic romantic cinema template --- config/scene_templates/romantic_cinema.yaml | 17 ++ .../application/codex_assistant.py | 47 +++- .../application/scene_templates.py | 5 + .../application/seat_service.py | 138 ++++++++++-- .../test_scene_peripheral_mode_flow.py | 200 +++++++++++++++++- .../integration/test_scene_template_hybrid.py | 23 +- .../unit/test_assistant_capability_catalog.py | 2 +- tests/unit/test_scene_composer_fast_path.py | 44 ++++ 8 files changed, 453 insertions(+), 23 deletions(-) create mode 100644 config/scene_templates/romantic_cinema.yaml diff --git a/config/scene_templates/romantic_cinema.yaml b/config/scene_templates/romantic_cinema.yaml new file mode 100644 index 0000000..1f46744 --- /dev/null +++ b/config/scene_templates/romantic_cinema.yaml @@ -0,0 +1,17 @@ +id: romantic-cinema +name: 浪漫影院模式 +aliases: [浪漫观影模式, 浪漫电影模式, 约会影院模式] +description: 完整保留观影模式的座椅、温控、遮光和车内灯光功能,并在运行时确定性加入当前已授权的外设灯光控制。 +icon: heart +operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 完整保留影院模式的主驾放松按摩;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 完整保留影院模式的副驾低档通风。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 完整保留影院模式的副驾靠背姿态。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 完整保留影院模式的副驾腿部支撑。} + - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 完整保留影院模式的座舱恒温。} + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 完整保留影院模式的舒适中性温度。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 完整保留影院模式的低噪声风量。} + - {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 完整保留影院模式的遮光设置。} + - {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: cinema}, reason: 完整保留影院模式的车内影院氛围。} + - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的室内主照明设置。} + - {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的阅读灯设置。} diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py index 3755fb3..85ce95a 100644 --- a/src/cognitive_agent/application/codex_assistant.py +++ b/src/cognitive_agent/application/codex_assistant.py @@ -48,7 +48,10 @@ from cognitive_agent.application.scene_composer import ( scene_required_peripheral_refs, scene_semantic_guidance, ) -from cognitive_agent.application.scene_templates import normalize_scene_template_name +from cognitive_agent.application.scene_templates import ( + PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS, + normalize_scene_template_name, +) from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule LOGGER = logging.getLogger(__name__) @@ -1034,15 +1037,53 @@ class CodexAssistantService: if mode_parts else None ) - scene_template_plan = ( + candidate_scene_template_plan = ( _catalog_exact_scene_template_plan(catalog, mode_parts[0]) - if mode_parts and not mode_parts[1] and exact_mode_matches == 0 + if mode_parts and not mode_parts[1] else None ) + candidate_scene_template_metadata = ( + candidate_scene_template_plan.get("metadata") + if isinstance(candidate_scene_template_plan, Mapping) + else None + ) + candidate_scene_template_id = ( + str(candidate_scene_template_metadata.get("template_id") or "") + if isinstance(candidate_scene_template_metadata, Mapping) + else "" + ) + scene_template_plan = ( + candidate_scene_template_plan + if exact_mode_matches == 0 + or candidate_scene_template_id + in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS + else None + ) + scene_template_metadata = ( + scene_template_plan.get("metadata") + if isinstance(scene_template_plan, Mapping) + else None + ) + preserve_base_external_lights = bool( + isinstance(scene_template_metadata, Mapping) + and str(scene_template_metadata.get("template_id") or "") + in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS + ) + if preserve_base_external_lights: + # The template already owns every native cinema action. Do not let + # generic romantic-scene defaults replace or append native actions; + # only the deterministic, NFC-authorized light refs may be added. + scene_semantics = scene_context.get("scene_semantics") + if isinstance(scene_semantics, dict): + scene_semantics["required_native_domains"] = [] + scene_semantics["required_native_controls"] = [] + scene_semantics["native_fallback_requests"] = [] + scene_semantics["default_control_bundle_applied"] = False dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message)) scene_peripheral_actions = scene_context.get("peripheral_actions", []) template_augmentation_preview = bool( scene_template_plan is not None + and not preserve_base_external_lights and not dynamic_scene_request and isinstance(scene_peripheral_actions, list) and scene_peripheral_actions diff --git a/src/cognitive_agent/application/scene_templates.py b/src/cognitive_agent/application/scene_templates.py index d5c0e8b..de12d66 100644 --- a/src/cognitive_agent/application/scene_templates.py +++ b/src/cognitive_agent/application/scene_templates.py @@ -33,6 +33,10 @@ _DOCUMENT_FIELDS = frozenset( {"id", "name", "aliases", "description", "icon", "operations", "schedule"} ) +# These templates keep their repository-backed native plan byte-for-byte and +# only add currently authorized external-light references at request time. +PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS = frozenset({"romantic-cinema"}) + def normalize_scene_template_name(value: object) -> str: """Normalize an exact user-facing name without enabling fuzzy matching.""" @@ -289,6 +293,7 @@ class SceneTemplateRegistry: __all__ = [ + "PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS", "SceneTemplate", "SceneTemplateRegistry", "normalize_scene_template_name", diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 30b2586..a9ea5ef 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -116,7 +116,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402 NativeCanExecutor, NativeCanRuntime, ) -from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402 +from cognitive_agent.application.scene_templates import ( # noqa: E402 + PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS, + SceneTemplateRegistry, +) from cognitive_agent.application.schedule_intent import ( # noqa: E402 is_schedule_stop, needs_mode_schedule, @@ -745,14 +748,14 @@ class SeatDemoService(ModeScheduleServiceMixin): merged[existing] = action return merged - def _n7_cinema_base_scene( + def _n7_cinema_base_profile( self, incoming_scene: Mapping[str, Any], - ) -> dict[str, Any] | None: + ) -> ModeProfile | None: if self._is_n7_cinema_base_scene(incoming_scene): return None vehicle_id = self.vehicle_profiles.default().id - candidates: list[tuple[int, dict[str, Any]]] = [] + candidates: list[tuple[int, ModeProfile]] = [] for public_profile in self.mode_profiles.public_modes(vehicle_id): try: profile = self.mode_profiles.get_profile( @@ -774,12 +777,26 @@ class SeatDemoService(ModeScheduleServiceMixin): ): continue priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1 - candidates.append((priority, deepcopy(dict(source_scene)))) + candidates.append((priority, profile)) if not candidates: return None - candidates.sort(key=lambda item: (item[0], str(item[1].get("id") or ""))) + candidates.sort(key=lambda item: (item[0], item[1].id)) return candidates[0][1] + def _n7_cinema_base_scene( + self, + incoming_scene: Mapping[str, Any], + ) -> dict[str, Any] | None: + profile = self._n7_cinema_base_profile(incoming_scene) + if profile is None: + return None + source_scene = ( + profile.command_flow.get("source_scene") + if isinstance(profile.command_flow, Mapping) + else None + ) + return deepcopy(dict(source_scene)) if isinstance(source_scene, Mapping) else None + @staticmethod def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool: parameters = action.get("parameters") @@ -1428,9 +1445,22 @@ class SeatDemoService(ModeScheduleServiceMixin): requests = self.mode_profiles.requests_for_profiles([profile]) if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC: - transfer_actions = self._local_cinema_n7_transfer_actions( - profile.name, - profile.instruction, + stored_transfer_actions = ( + profile.command_flow.get("n7_transfer_actions") + if isinstance(profile.command_flow, Mapping) + else None + ) + transfer_actions = ( + [ + deepcopy(dict(action)) + for action in stored_transfer_actions[:256] + if isinstance(action, Mapping) + ] + if isinstance(stored_transfer_actions, list) + else self._local_cinema_n7_transfer_actions( + profile.name, + profile.instruction, + ) ) transfer_selectors = { self._n7_transfer_operation_selector( @@ -2020,8 +2050,22 @@ class SeatDemoService(ModeScheduleServiceMixin): raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。") if schedule is not None: operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations] + source_template_id = ( + str(source_draft.get("source_template_id") or "").strip() + if isinstance(source_draft, Mapping) + else "" + ) + preview_transfer_candidates = source_result.get("n7_transfer_candidates") n7_transfer_actions = ( - self._local_cinema_n7_transfer_actions( + [ + deepcopy(dict(action)) + for action in preview_transfer_candidates[:256] + if isinstance(action, Mapping) + ] + if schedule is None + and source_template_id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS + and isinstance(preview_transfer_candidates, list) + else self._local_cinema_n7_transfer_actions( clean_name, clean_instruction, ) @@ -6966,13 +7010,34 @@ class SeatDemoService(ModeScheduleServiceMixin): mode_name, vehicle_id=vehicle_profile.id, ) - if scene_template is not None and existing_profile is not None: + preserve_base_external_lights = bool( + scene_template is not None + and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS + ) + if ( + scene_template is not None + and existing_profile is not None + and not preserve_base_external_lights + ): raise ValueError("同名功能模式已创建,请重新提交以载入最新模式。") source_profile = ( existing_profile if not requested_instruction and source_draft is None + and not preserve_base_external_lights else None ) + if ( + source_profile is None + and source_draft is None + and not requested_instruction + and preserve_base_external_lights + ): + source_profile = self._n7_cinema_base_profile( + { + "id": scene_template.id, + "name": mode_name, + } + ) if source_profile is None and source_draft is None and not requested_instruction and scene_template is None: source_name = mode_creation_source_name(mode_name) if source_name != mode_name: @@ -7000,7 +7065,18 @@ class SeatDemoService(ModeScheduleServiceMixin): # of appending a model prompt to the user's utterance. The latter was # being parsed as a second control clause on the assistant's local fast # path, turning a valid nine-action mode into an incomplete draft. - source_utterance = requested_instruction or f"进入{draft_mode_name}" + draft_instruction = requested_instruction or f"进入{draft_mode_name}" + inherited_n7_cinema_profile = bool( + source_profile is not None + and scene_template is not None + and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS + and source_profile.sync_origin == MQTT_MODE_SCENE_INGRESS_TOPIC + ) + source_utterance = ( + f"进入{source_profile.name}" + if inherited_n7_cinema_profile + else draft_instruction + ) if requested_instruction and selected_seats is not None and len(selected_seats) == 1: # Qualify only explicit seat-device clauses, never an HVAC/window # zone or unrelated control. General one-shot controls keep their @@ -7056,7 +7132,19 @@ class SeatDemoService(ModeScheduleServiceMixin): self._persist(result) return result try: - if scene_template is not None and selected_seats is not None: + preserve_unscoped_template_seats = bool( + scene_template is not None + and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS + and resolve_explicit_seat_scopes( + utterance, + assume_seat_context=True, + ) is None + ) + if ( + scene_template is not None + and selected_seats is not None + and not preserve_unscoped_template_seats + ): assistant_plan = project_seed_plan(assistant_plan, selected_seats) source_result = self._process_once( source_utterance, @@ -7070,7 +7158,9 @@ class SeatDemoService(ModeScheduleServiceMixin): direct_mode_profile_id=( source_profile.id if source_profile is not None else None ), - mode_creation_scopes=selected_seats, + mode_creation_scopes=( + None if preserve_unscoped_template_seats else selected_seats + ), assistant_plan=assistant_plan, # ``source_utterance`` is synthesized for a name-only, unknown # scene. It must not fall back to substring matching (for example @@ -7086,7 +7176,11 @@ class SeatDemoService(ModeScheduleServiceMixin): # already passed the ordinary text/target authorization path. # Its old display name cannot veto an explicitly requested new # seat. This override is internal, never accepted from payloads. - scene_scope_utterance="" if source_draft is not None else utterance, + scene_scope_utterance=( + "" + if source_draft is not None or preserve_unscoped_template_seats + else utterance + ), ) except ModeCreationSeatPolicyError as exc: return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) @@ -7219,6 +7313,16 @@ class SeatDemoService(ModeScheduleServiceMixin): typed_source_actions = [ action for action in source_actions if isinstance(action, dict) ] if isinstance(source_actions, list) else [] + if ( + inherited_n7_cinema_profile + and source_result.get("status") == "partially_succeeded" + and typed_source_actions + and source_result.get("n7_transfer_candidates") + ): + # N7-only actions are intentionally deferred during a dry-run. They + # remain part of the received cinema base and are persisted as + # transfer actions, so their presence is not a partial draft. + source_result["status"] = "succeeded" if ( source_result.get("status") == "succeeded" and generation_source @@ -7228,6 +7332,7 @@ class SeatDemoService(ModeScheduleServiceMixin): "scene_template_copy", } and assistant_plan is not None + and not inherited_n7_cinema_profile and ( self._scene_plan_signatures(assistant_plan) != self._scene_action_signatures(typed_source_actions) @@ -7372,9 +7477,10 @@ class SeatDemoService(ModeScheduleServiceMixin): if source_draft is not None else 0 ) + source_result["utterance"] = draft_instruction source_result["mode_profile_draft"] = { "name": draft_mode_name, - "instruction": source_utterance, + "instruction": draft_instruction, "source_request_id": source_request_id, "vehicle_id": vehicle_profile.id, "domains": domains, diff --git a/tests/integration/test_scene_peripheral_mode_flow.py b/tests/integration/test_scene_peripheral_mode_flow.py index 7bc6294..32dddd9 100644 --- a/tests/integration/test_scene_peripheral_mode_flow.py +++ b/tests/integration/test_scene_peripheral_mode_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -65,6 +65,204 @@ def _learned_light_power_on() -> dict[str, object]: } +def _received_cinema_scene() -> dict[str, object]: + actions = [ + { + "type": "window_position", + "at_ms": 0, + "domain": "window", + "capability": "window.set_position", + "target_scope": "front_left", + "operation": "set", + "parameters": {"position_pct": 0}, + }, + { + "type": "climate_temperature", + "at_ms": 0, + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "vehicle", + "operation": "set", + "parameters": {"temperature_c": 25}, + }, + { + "type": "seat_preset", + "at_ms": 0, + "domain": "local_vehicle", + "capability": "local_scene.seat_preset", + "target_scope": "vehicle", + "operation": "set", + "parameters": {"targets_pulse": [246, 112, 405, 342]}, + }, + { + "type": "android_app_launch", + "at_ms": 3000, + "domain": "android", + "capability": "android.launch_app", + "target_scope": "headunit", + "operation": "launch", + "parameters": { + "package_name": "com.byd.videoplay", + "activity_name": "com.byd.videoplay.activity.WelComeActivity", + }, + }, + ] + return { + "id": "cinema", + "label": "影院模式", + "name": "影院模式", + "description": "接收到的车机影院模式。", + "icon": "film", + "aliases": ["观影模式"], + "origin": "headunit", + "deletable": True, + "behavior": "static", + "domains": ["window", "climate", "local_vehicle", "android"], + "operation_count": len(actions), + "scope": {"common": False, "vehicle_ids": ["denza_n7"]}, + "profile_revision": "received-cinema-v1", + "actions": actions, + } + + +def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light( + tmp_path: Path, +) -> None: + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "runs" + service.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path / "modes", + ) + extension = SelfExtensionService( + SimulationGateway(), + catalog_db_path=tmp_path / "infrared.sqlite3", + ) + extension.sync_peripheral_catalog([_learned_light_power_on()]) + received_cinema = _received_cinema_scene() + service.import_n7_mode_scene(received_cinema) + stale_profile = service.mode_profiles.save_mode( + "浪漫影院模式", + [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "vehicle", + "operation": "set", + "parameters": {"temperature_c": 30}, + } + ], + vehicle_id="denza_n7", + instruction="进入浪漫影院模式", + ) + catalog = AssistantCapabilityCatalog( + service, + peripheral_provider=extension.list_peripheral_routing_capabilities, + ) + handler = object.__new__(SeatDemoHandler) + handler.service = service + handler.extension = extension + handler.nfc = SimpleNamespace(snapshot=lambda: {"card": None}) + handler.vehicle_display_sync = VehicleDisplaySync() + client = MagicMock() + client.available = True + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + result: dict[str, object] = {} + + def execute_control( + instruction: str, + progress, + assistant_plan: dict[str, object], + ) -> dict[str, object]: + nonlocal result + arguments = { + "utterance": instruction, + "provider": "local", + "conversation_id": "conv-romantic-cinema-template", + "execution_mode": "hardware", + "vehicle_id": "denza_n7", + "simulation_fault": "normal", + "hardware_access_bypass": True, + "reasoning_effort": "low", + "control_transport": "wired_can", + "assistant_plan": assistant_plan, + } + result = handler._preview_mode_with_scene_peripherals( + "/api/requests/preview", + arguments, + assistant_plan, + on_commands_validated=progress, + ) + return result + + try: + with patch.object( + extension, + "execute_gateway", + side_effect=AssertionError("mode creation must not emit infrared"), + ): + response = assistant.stream( + "创建浪漫影院模式", + conversation_id="conv-romantic-cinema-template", + principal="romantic-cinema-template-test", + history=[], + emit=lambda _event: True, + execute_control=execute_control, + capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + + assert response["success"] is True + assert response["route"] == "scene_template" + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + assert result["status"] == "succeeded" + assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy" + assert result["mode_profile_draft"]["existing_mode_id"] == stale_profile.id + assert result["mode_profile_draft"]["operation_count"] == 3 + + native_actions = { + ( + action["capability"], + action["target"].get("instance") or action["target"].get("zone"), + ): action["parameters"] + for action in result["intent_ir"]["spec"]["actions"] + if action["domain"] != "self_extension" + } + assert native_actions == { + (action["capability"], action["target_scope"]): action["parameters"] + for action in received_cinema["actions"] + if action["domain"] in {"window", "climate"} + } + assert result["n7_transfer_candidates"] == received_cinema["actions"][2:] + assert [action["slot"] for action in result["infrared_actions"]] == [1] + + draft = result["mode_profile_draft"] + saved = service.save_mode_profile_card( + stale_profile.id, + draft["name"], + draft["instruction"], + draft["source_request_id"], + draft["vehicle_id"], + ) + profile = service.get_mode_profile_card(saved["mode_profile"]["id"], "denza_n7") + saved_selectors = { + (operation.capability, operation.target_scope) + for operation in profile.operations + } + assert saved_selectors == { + (action["capability"], action["target_scope"]) + for action in received_cinema["actions"] + } | {("infrared.emit", "romance-light")} + _local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile) + assert transfer_actions == received_cinema["actions"][2:] + finally: + service.close() + + def test_scene_composer_mixed_can_ir_preview_save_and_revalidate( tmp_path: Path, ) -> None: diff --git a/tests/integration/test_scene_template_hybrid.py b/tests/integration/test_scene_template_hybrid.py index 901c049..ee1f7c8 100644 --- a/tests/integration/test_scene_template_hybrid.py +++ b/tests/integration/test_scene_template_hybrid.py @@ -165,7 +165,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( templates = snapshot["scene_templates"] assert isinstance(templates, list) - assert len(templates) == 16 + assert len(templates) == 17 assert all(template["assistant_callable"] is True for template in templates) with patch.object( @@ -211,7 +211,9 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( else: expected_requests = [ request for request in expected_requests - if request["domain"] != "seat" or request["target_scope"] == "passenger" + if template["id"] == "romantic-cinema" + or request["domain"] != "seat" + or request["target_scope"] == "passenger" ] assert draft["operation_count"] == len(expected_requests) expected = { @@ -246,6 +248,23 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( assert not list(user_root.rglob("*.yaml")) +def test_romantic_cinema_template_is_an_exact_copy_of_all_cinema_functions( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], +) -> None: + service, _catalog, _user_root = hybrid_service + cinema = service.scene_templates.get_template("cinema") + romantic_cinema = service.scene_templates.get_template("romantic-cinema") + + assert len(cinema.operations) == len(romantic_cinema.operations) == 11 + assert [ + (operation.domain, operation.capability, operation.target_scope, operation.parameters) + for operation in romantic_cinema.operations + ] == [ + (operation.domain, operation.capability, operation.target_scope, operation.parameters) + for operation in cinema.operations + ] + + def test_viewing_template_seeds_a_complete_unbound_preview_without_codex( hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], ) -> None: diff --git a/tests/unit/test_assistant_capability_catalog.py b/tests/unit/test_assistant_capability_catalog.py index 90e8b12..0c5b125 100644 --- a/tests/unit/test_assistant_capability_catalog.py +++ b/tests/unit/test_assistant_capability_catalog.py @@ -372,7 +372,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries( templates = snapshot["scene_templates"] assert snapshot["source_status"]["scene_templates"] == "ready" - assert len(templates) == 16 + assert len(templates) == 17 assert {item["name"] for item in templates} >= { "观影模式", "露营模式", diff --git a/tests/unit/test_scene_composer_fast_path.py b/tests/unit/test_scene_composer_fast_path.py index bd02cdf..46cd329 100644 --- a/tests/unit/test_scene_composer_fast_path.py +++ b/tests/unit/test_scene_composer_fast_path.py @@ -701,6 +701,50 @@ def test_romantic_template_always_adds_authorized_decorative_lights() -> None: assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0]) +def test_romantic_cinema_template_keeps_native_base_and_adds_lights_without_model() -> None: + client = StructuredSceneClient() + assistant = CodexAssistantService(client) + template = _scene_template( + template_id="romantic-cinema", + name="浪漫影院模式", + aliases=["浪漫观影模式"], + ) + observed: list[dict[str, object]] = [] + + def execute( + _instruction: str, + _progress: Callable[[dict[str, object]], None], + assistant_plan: Mapping[str, object], + ) -> dict[str, object]: + observed.append(deepcopy(dict(assistant_plan))) + return _preview_result("浪漫影院模式") + + result, _events = _invoke( + assistant, + "创建浪漫影院模式", + execute, + conversation_id="conv-romantic-cinema-deterministic", + catalog=_catalog_with_templates(template), + scene_context=_scene_context_with_flashing_light(), + ) + + assert result["success"] is True + assert result["route"] == "scene_template" + assert client.structured_calls == [] + assert client.legacy_calls == [] + native_requests = [ + request + for request in observed[0]["requests"] + if request.get("domain") != "self_extension" + ] + assert native_requests == template["plan"]["requests"] + assert _peripheral_refs(observed[0]) == [ + "ir:flash-open-000000000001", + "ir:ambient-open-0000000001", + "ir:strip-open-000000000001", + ] + + def test_mid_autumn_cloud_composition_adds_required_native_and_external_controls() -> None: client = StructuredSceneClient() assistant = CodexAssistantService(client)