From 6b9ee0f349744794116d3e408b52e3d1c829639c Mon Sep 17 00:00:00 2001
From: Miayui <774713819@qq.com>
Date: Tue, 22 Sep 2026 13:01:46 +0800
Subject: [PATCH] fix(scenes): restore cinema seat controls and ordered
romantic lighting
---
apps/web-console/README.md | 20 +++
apps/web-console/app.js | 54 +++++--
apps/web-console/index.html | 2 +-
apps/web-console/service-worker.js | 4 +-
.../application/assistant_capabilities.py | 12 +-
.../application/codex_assistant.py | 28 +++-
.../application/n7_seat_preset.py | 69 +++++++++
.../application/romantic_lighting.py | 109 ++++++++++++++
.../application/scene_composer.py | 7 +-
.../application/seat_service.py | 96 +++++++++++--
src/cognitive_agent/server.py | 44 +++++-
tests/e2e/test_seat_web_api.py | 49 ++++++-
.../test_scene_peripheral_mode_flow.py | 110 ++++++++++++--
.../unit/test_assistant_frontend_contract.py | 2 +-
.../test_energy_monitor_frontend_contract.py | 2 +-
.../test_frontend_performance_contract.py | 4 +-
tests/unit/test_frontend_theme_contract.py | 2 +-
tests/unit/test_mode_gallery_grouping.py | 39 +++++
tests/unit/test_mode_scene_ingress.py | 93 ++++++++++--
tests/unit/test_n7_seat_preset.py | 88 ++++++++++++
tests/unit/test_romantic_lighting.py | 134 ++++++++++++++++++
tests/unit/test_scene_composer_fast_path.py | 8 +-
tests/unit/test_scene_composer_schema.py | 13 +-
tests/unit/test_vue_frontend_contract.py | 2 +-
24 files changed, 908 insertions(+), 83 deletions(-)
create mode 100644 src/cognitive_agent/application/n7_seat_preset.py
create mode 100644 src/cognitive_agent/application/romantic_lighting.py
create mode 100644 tests/unit/test_n7_seat_preset.py
create mode 100644 tests/unit/test_romantic_lighting.py
diff --git a/apps/web-console/README.md b/apps/web-console/README.md
index 2086de0..45a4856 100755
--- a/apps/web-console/README.md
+++ b/apps/web-console/README.md
@@ -1,5 +1,25 @@
# Web Console
+## 浪漫相关模式的外设灯光
+
+浪漫、约会、纪念日及浪漫影院等模式使用当前持久授权的红外目录:打开灯笼、蜡烛和灯带,
+所有具有红色指令的外设灯光统一设置为红色。每台设备均先发送开启指令,再发送红色指令;
+开启下发失败时终止后续序列。缺少开启能力的设备不能只发送颜色指令,缺少红色能力时
+不以其他颜色替代。新模式生成、车端卡片导入及已有卡片的执行入口均使用该规则,
+原生座椅、空调等场景动作保持原有定义。
+
+## 车端影院模式的副驾姿态
+
+从 `from_n7/scenes` 同步的 `local_scene.seat_preset` 会在执行时按 N7 的 M0–M3
+标定转换为副驾前后、坐盆、靠背、腿托四项位置控制,沿用现有座椅安全校验和执行器。
+例如 `[246, 112, 405, 342]` 脉冲转换为当前百分比协议的 `[51, 30, 44, 53]`。
+原始脉冲保留在卡片中,已保存的影院模式及继承该预设的本地模式无需重新上传。
+不完整、越界或错误座位的预设会被拒绝;带延时的预设继续交由车端处理。
+视频启动等车端专属动作仍走 `transto_n7/ctl`,不会重复转发已由 PC 处理的四轴姿态。
+
+模式卡片区分完整成功、部分处理、指令已提交待车端确认及模拟校验,消息入队不会显示为
+车辆执行完成。
+
## 座椅固件开发工作台
“开发指令”支持输入“增加动态木马功能”,也可点击位于位置控制、座椅通风、座椅加热之后的
diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index c5d7847..d3fd4dc 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -7049,12 +7049,16 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
? "已停止"
: active && modeState === "succeeded"
? "已完成"
+ : active && modeState === "submitted"
+ ? "待车端确认"
+ : active && modeState === "partial"
+ ? "部分处理"
: active && modeState === "failed"
? "执行失败"
: "点击执行";
}
});
- if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) {
+ if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped", "submitted", "partial"].includes(modeState)) {
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
}
}
@@ -7073,6 +7077,35 @@ function hasExecutableModeCommandFlow(mode) {
|| (plans && typeof plans === "object" && Object.keys(plans).length > 0);
}
+function savedModeExecutionView(mode, result) {
+ const name = mode?.name || "功能模式";
+ if (modeScheduleExecutionView(result)?.state === "processing") {
+ return { state: "started", title: `${name}时间轴已启动`, toast: "时间轴已启动,可输入“停止动态模式”提前结束" };
+ }
+ if (!["succeeded", "partially_succeeded"].includes(result.status)) {
+ return { state: "failed", title: `${name}执行未完成`, toast: `${name}未完成,请查看执行结果` };
+ }
+ const preview = result.execution_mode === "dry_run";
+ const waiting = !preview && executionVerificationStatus(result) === "submitted_unverified";
+ if (result.status === "partially_succeeded") {
+ return {
+ state: "partial",
+ title: `${name}${preview ? "模拟校验部分通过" : waiting ? "部分指令已提交,待车端确认" : "部分完成"}`,
+ toast: preview
+ ? `${name}模拟校验部分通过,仍有未由 PC 执行的动作`
+ : `${name}仅部分处理成功,请查看各项指令和车端转发状态`,
+ };
+ }
+ if (waiting) {
+ return { state: "submitted", title: `${name}指令已提交,待车端确认`, toast: "指令已提交,尚未确认车辆执行完成" };
+ }
+ return {
+ state: "succeeded",
+ title: `${name}${preview ? "模拟校验完成" : "执行完成"}`,
+ toast: `${name}${preview ? "模拟校验完成" : "已完成保存的控制指令流"}`,
+ };
+}
+
async function executeSavedModeProfile(mode, { command = "" } = {}) {
if (!hasExecutableModeCommandFlow(mode)) return false;
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
@@ -7125,14 +7158,9 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
);
if (!payload) throw new Error("模式卡片未返回执行结果");
renderResult(payload);
- const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing";
- const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
- $("#resultTitle").textContent = scheduleRunning
- ? `${mode.name}时间轴已启动`
- : completed
- ? `${mode.name}执行完成`
- : `${mode.name}执行未完成`;
- setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed");
+ const executionView = savedModeExecutionView(mode, payload);
+ $("#resultTitle").textContent = executionView.title;
+ setModeExecutionState(mode.id, executionView.state);
if (assistantModeHandle && assistant?.completeModeExecution) {
assistant.completeModeExecution(assistantModeHandle, mode, payload, {
command: modeCommand,
@@ -7141,11 +7169,7 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
}
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
- showToast(scheduleRunning
- ? "时间轴已启动,可输入“停止动态模式”提前结束"
- : completed
- ? `${mode.name}已直接执行保存的控制指令流`
- : `${mode.name}未完成,请查看执行结果`);
+ showToast(executionView.toast);
} catch (error) {
setModeExecutionState(mode.id, "failed");
setRunStatus("failed", "MODE FAILED");
@@ -8926,6 +8950,8 @@ function renderResult(result) {
? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}`
: peripheralRoute && executionSucceeded
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
+ : savedModeExecution && result.mode_profile
+ ? savedModeExecutionView(result.mode_profile, result).title
: partiallySucceeded
? `${domains.length} 域场景部分完成`
: executionSucceeded
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index 96469f6..b2d1249 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -39,7 +39,7 @@
-
+
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js
index acc4729..d5ee166 100644
--- a/apps/web-console/service-worker.js
+++ b/apps/web-console/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE_VERSION = "cognitive-agent-pad-v335-firmware-rocking-horse";
+const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,7 +6,7 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-24-dialog-size",
"/network.js?v=android-pad-2",
- "/app.js?v=dual-seat-147-video-media",
+ "/app.js?v=dual-seat-148-seat-preset",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py
index c3483fc..7f558ed 100644
--- a/src/cognitive_agent/application/assistant_capabilities.py
+++ b/src/cognitive_agent/application/assistant_capabilities.py
@@ -1760,6 +1760,9 @@ class AssistantCapabilityCatalog:
peripheral_actions.append(
{
"ref": peripheral_capability_ref(raw),
+ "device_key": hashlib.sha256(
+ str(raw.get("device_id") or device_name).encode("utf-8")
+ ).hexdigest()[:16],
"device_label": device_name,
"device_type": device_type,
"object_names": _safe_string_list(raw.get("object_names"), limit=12),
@@ -2443,7 +2446,12 @@ class AssistantCapabilityCatalog:
continue
try:
profile = self._vehicle.mode_profiles.get_profile(mode_id, vehicle_id)
- requests = self._vehicle.mode_profiles.requests_for_profiles([profile])
+ resolve_requests = getattr(self._vehicle, "mode_profile_execution_requests", None)
+ requests = (
+ resolve_requests(profile, vehicle_id)
+ if callable(resolve_requests)
+ else self._vehicle.mode_profiles.requests_for_profiles([profile])
+ )
except Exception: # noqa: BLE001 - a stale user mode is not executable
requests = []
operation_ids = [
@@ -2491,7 +2499,7 @@ class AssistantCapabilityCatalog:
"aliases": _safe_string_list(raw.get("aliases"), limit=16),
"description": _safe_text(raw.get("description"), limit=240),
"domains": _safe_string_list(raw.get("domains"), limit=16),
- "operation_count": int(raw.get("operation_count") or 0),
+ "operation_count": len(operation_ids),
"origin": _safe_text(raw.get("origin"), limit=32),
"operation_ids": operation_ids,
"operations": operations,
diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py
index 85ce95a..a33e59d 100644
--- a/src/cognitive_agent/application/codex_assistant.py
+++ b/src/cognitive_agent/application/codex_assistant.py
@@ -34,6 +34,11 @@ from cognitive_agent.application.mode_creation_seat_policy import (
)
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.mode_schedule_service import public_schedule_status
+from cognitive_agent.application.mode_scene_ingress import MAX_MODE_SCENE_ACTIONS
+from cognitive_agent.application.romantic_lighting import (
+ romantic_light_override_refs,
+ romantic_scene,
+)
from cognitive_agent.application.scene_composer import (
SCENE_COMPOSER_INSTRUCTIONS,
SCENE_PLANNER_VERSION,
@@ -1517,6 +1522,17 @@ class CodexAssistantService:
requests = normalized.get("requests")
if not isinstance(requests, list):
raise SceneCompositionError("scene plan requests must be an array")
+ semantics = scene_context.get("scene_semantics")
+ if isinstance(semantics, Mapping) and romantic_scene(semantics.get("mode_name")):
+ replaced_refs = romantic_light_override_refs(scene_context.get("peripheral_actions", []))
+ requests[:] = [request for request in requests if not (
+ isinstance(request, Mapping) and request.get("capability") == "infrared.emit"
+ and any(isinstance(parameter, Mapping)
+ and parameter.get("name") == "capability_ref"
+ and isinstance(parameter.get("value"), str)
+ and parameter.get("value") in replaced_refs
+ for parameter in request.get("parameters", []))
+ )]
existing_refs = {
str(parameter.get("value") or "").strip()
for request in requests
@@ -4317,13 +4333,17 @@ class CodexAssistantService:
secret=secret,
)
actions: list[dict[str, object]] = []
+ action_limit = (
+ MAX_MODE_SCENE_ACTIONS
+ if isinstance(result.get("mode_profile_draft"), Mapping)
+ or isinstance(result.get("mode_profile"), Mapping)
+ else 96 if result.get("schedule") else MAX_PLAN_REQUESTS
+ )
intent_ir = result.get("intent_ir")
if isinstance(intent_ir, dict):
spec = intent_ir.get("spec")
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
- for item in spec["actions"][
- :96 if result.get("schedule") else MAX_PLAN_REQUESTS
- ]:
+ for item in spec["actions"][:action_limit]:
if not isinstance(item, dict):
continue
safe_action = {
@@ -4351,7 +4371,7 @@ class CodexAssistantService:
infrared_actions: list[dict[str, object]] = []
raw_infrared = result.get("infrared_actions")
if isinstance(raw_infrared, list):
- for item in raw_infrared[:MAX_PLAN_REQUESTS]:
+ for item in raw_infrared[:action_limit]:
if not isinstance(item, dict):
continue
infrared_actions.append(
diff --git a/src/cognitive_agent/application/n7_seat_preset.py b/src/cognitive_agent/application/n7_seat_preset.py
new file mode 100644
index 0000000..4b5441b
--- /dev/null
+++ b/src/cognitive_agent/application/n7_seat_preset.py
@@ -0,0 +1,69 @@
+"""Translate the N7 passenger-seat preset contract into local seat operations."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from cognitive_agent.application.vehicle_profile import VehicleProfile
+
+
+# N7 targets_pulse uses the ECU's M0, M1, M2, M3 ordering.
+N7_SEAT_PRESET_AXES = (
+ "longitudinal", "cushion_tilt", "backrest_recline", "legrest_height",
+)
+N7_SEAT_PRESET_CAPABILITIES = (
+ "seat.set_longitudinal_position", "seat.set_cushion_tilt",
+ "seat.set_backrest_recline", "seat.set_legrest_height",
+)
+
+
+def expand_n7_seat_preset(
+ action: Mapping[str, Any], vehicle: VehicleProfile,
+) -> list[dict[str, Any]] | None:
+ """Expand an immediate preset; retain unrelated/timed actions for N7 routing.
+
+ The existing seat contract accepts whole percentages. Retain the source
+ pulses in the stored card, and round only at this execution boundary.
+ Never round a nonzero pulse target into the ECU's explicit homing command.
+ """
+ if action.get("capability") != "local_scene.seat_preset":
+ return None
+ if (
+ vehicle.id != "denza_n7"
+ or action.get("domain") != "local_vehicle"
+ or action.get("target_scope") not in {"vehicle", "passenger", "front_right"}
+ or action.get("operation") != "set"
+ ):
+ raise ValueError("车端座椅预设仅支持腾势 N7 副驾的绝对位置设置。")
+ parameters = action.get("parameters")
+ if not isinstance(parameters, Mapping) or set(parameters) != {"targets_pulse"}:
+ raise ValueError("车端座椅预设必须且只能包含 targets_pulse 参数。")
+ targets = parameters["targets_pulse"]
+ if not isinstance(targets, list) or len(targets) != 4:
+ raise ValueError("车端座椅预设需要按 M0–M3 顺序提供四个脉冲目标。")
+ operations = []
+ for axis, capability, pulse in zip(
+ N7_SEAT_PRESET_AXES, N7_SEAT_PRESET_CAPABILITIES, targets, strict=True
+ ):
+ motor = vehicle.seat_motors.get(axis)
+ maximum = motor.hall_total_pulses if motor is not None else 0
+ if maximum <= 0:
+ raise ValueError(f"车端座椅预设缺少 {axis} 的有效霍尔标定。")
+ if isinstance(pulse, bool) or not isinstance(pulse, int) or not 0 <= pulse <= maximum:
+ raise ValueError(f"车端座椅预设 {axis} 脉冲目标必须为 0–{maximum} 的整数。")
+ position_pct = round(pulse * 100 / maximum)
+ if 0 < pulse < maximum:
+ position_pct = max(1, min(99, position_pct))
+ operations.append({
+ "domain": "seat",
+ "capability": capability,
+ "target_scope": "passenger",
+ "operation": "set",
+ "parameters": {"position_pct": position_pct},
+ "reason": f"车端副驾预设 {axis}:{pulse}/{maximum} 脉冲转换为 {position_pct}%。",
+ })
+ # A delayed vehicle-owned action must not become an immediate PC movement.
+ if action.get("at_ms", 0) != 0:
+ return None
+ return operations
diff --git a/src/cognitive_agent/application/romantic_lighting.py b/src/cognitive_agent/application/romantic_lighting.py
new file mode 100644
index 0000000..ac56bae
--- /dev/null
+++ b/src/cognitive_agent/application/romantic_lighting.py
@@ -0,0 +1,109 @@
+"""Deterministic romantic lighting from the current authorized IR catalog."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+from typing import Any
+
+ROMANTIC_SCENE = re.compile(r"浪漫|约会|七夕|情人节|纪念日|romantic|romance|date[ _-]*night|anniversary", re.I)
+_LIGHT = re.compile(r"灯|蜡烛|light|lamp|lantern|candle", re.I)
+_REQUIRED_POWER = re.compile(r"灯笼|蜡烛|灯带|灯条|闪烁灯|氛围灯|lantern|candle|(?:light|led)\s*strip|ambient\s*light", re.I)
+_ON = re.compile(r"^(?:打开|开启|启动|点亮|on|power\s*on)$", re.I)
+_OFF = re.compile(r"关闭|关掉|熄灭|\boff\b", re.I)
+_COLOR = re.compile(r"红色|绿色|蓝色|白色|紫色|黄色|橙色|粉色|\b(?:red|green|blue|white|purple|yellow|orange|pink)\b", re.I)
+_RED = re.compile(r"红色|\bred\b", re.I)
+
+
+def romantic_scene(value: object) -> bool:
+ return ROMANTIC_SCENE.search(str(value or "")) is not None
+
+
+def light_action_names(item: Mapping[str, Any]) -> list[str]:
+ parameters = item.get("parameters")
+ names = [item.get("action_label"), item.get("action")]
+ if isinstance(parameters, Mapping):
+ names.append(parameters.get("action"))
+ for key in ("action_names", "aliases"):
+ if isinstance(item.get(key), list):
+ names.extend(item[key])
+ return [str(name).strip() for name in names if name]
+
+
+def light_power_or_color(item: Mapping[str, Any]) -> bool:
+ names = light_action_names(item)
+ text = " ".join(names)
+ return any(_ON.fullmatch(name) for name in names) or bool(_OFF.search(text) or _COLOR.search(text))
+
+
+def romantic_light_actions(catalog: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
+ """Select every eligible light, with power before red and no color fallback."""
+ if not isinstance(catalog, Sequence) or isinstance(catalog, (str, bytes)):
+ return []
+ grouped: dict[str, list[Mapping[str, Any]]] = {}
+ identities: dict[str, str] = {}
+ for item in catalog:
+ if not isinstance(item, Mapping) or item.get("enabled", True) is False:
+ continue
+ if item.get("status", "learned") != "learned":
+ continue
+ symbolic = bool(item.get("ref"))
+ if not symbolic and (
+ not item.get("card_uid") or not item.get("device_id")
+ or isinstance(item.get("slot"), bool) or not isinstance(item.get("slot"), int)
+ or not 1 <= item["slot"] <= 248
+ ):
+ continue
+ name = str(item.get("device_label") or item.get("device_name") or "")
+ aliases = item.get("object_names", [])
+ aliases = aliases if isinstance(aliases, list) else []
+ identity = " ".join([name, *(str(x) for x in aliases if isinstance(x, str))])
+ if not _LIGHT.search(identity):
+ continue
+ key = str(item.get("device_key") or item.get("device_id") or name).casefold()
+ grouped.setdefault(key, []).append(item)
+ identities[key] = identity
+ selected = []
+ for key, actions in grouped.items():
+ red = next((item for item in actions
+ if _RED.search(" ".join(light_action_names(item)))
+ and not _OFF.search(" ".join(light_action_names(item)))), None)
+ if red is None and not _REQUIRED_POWER.search(identities[key]):
+ continue
+ activation = next((item for item in actions
+ if any(_ON.fullmatch(name) for name in light_action_names(item))
+ and not _OFF.search(" ".join(light_action_names(item)))
+ and not _COLOR.search(" ".join(light_action_names(item)))), None)
+ if activation is None:
+ continue
+ selected.append(deepcopy(dict(activation)))
+ if red is not None:
+ selected.append(deepcopy(dict(red)))
+ return selected
+
+
+def romantic_light_override_refs(catalog: Sequence[Mapping[str, Any]]) -> set[str]:
+ """Power/color refs superseded by the ordered romantic policy."""
+ refs = set()
+ for item in catalog:
+ if not isinstance(item, Mapping):
+ continue
+ identity = str(item.get("device_label") or item.get("device_name") or "")
+ if _LIGHT.search(identity) and light_power_or_color(item) and item.get("ref"):
+ refs.add(str(item["ref"]))
+ return refs
+
+
+def replace_romantic_light_actions(
+ existing: list[dict[str, Any]], required: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """Replace old power/color commands for the selected devices, retaining other actions."""
+ def target(item):
+ return str(item.get("target_scope") or (item.get("target") or {}).get("device") or "")
+ devices = {target(item) for item in required}
+ retained = [item for item in existing if not (
+ item.get("capability") == "infrared.emit"
+ and target(item) in devices and light_power_or_color(item)
+ )]
+ return [*deepcopy(retained), *deepcopy(required)]
diff --git a/src/cognitive_agent/application/scene_composer.py b/src/cognitive_agent/application/scene_composer.py
index 35ac470..005d980 100644
--- a/src/cognitive_agent/application/scene_composer.py
+++ b/src/cognitive_agent/application/scene_composer.py
@@ -20,8 +20,9 @@ from cognitive_agent.application.mode_creation_seat_policy import (
normalized_seat_scope,
)
from cognitive_agent.application.mode_schedule import ModeSchedule
+from cognitive_agent.application.romantic_lighting import romantic_light_actions, romantic_scene
-SCENE_PLANNER_VERSION = "n7web-scene-composer/v9"
+SCENE_PLANNER_VERSION = "n7web-scene-composer/v10"
_MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024
_MAX_SCHEDULE_PHASE_REQUESTS = 12
@@ -392,6 +393,10 @@ def scene_required_peripheral_refs(
"""
clean_name = " ".join(str(mode_name or "").strip().split())[:64]
+ if romantic_scene(clean_name):
+ return [str(item["ref"]) for item in romantic_light_actions(
+ context.get("peripheral_actions", [])
+ ) if item.get("ref")]
themed_scene = _REQUIRED_DECORATIVE_LIGHT_SCENE.search(clean_name) is not None
if not themed_scene and not include_default_bundle:
return []
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index a9ea5ef..d837d38 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -60,6 +60,7 @@ from cognitive_agent.application.mode_device_additions import ( # noqa: E402
mode_device_additions,
)
from cognitive_agent.application.mode_profile import ( # noqa: E402
+ ModeOperation,
ModeProfile,
ModeProfileRegistry,
ModeTimeline,
@@ -95,6 +96,12 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttNativeSeatExecutor,
)
+from cognitive_agent.application.n7_seat_preset import expand_n7_seat_preset # noqa: E402
+from cognitive_agent.application.romantic_lighting import ( # noqa: E402
+ romantic_light_actions,
+ romantic_scene,
+ replace_romantic_light_actions,
+)
from cognitive_agent.application.native_can_feedback import ( # noqa: E402
VehicleSafetyPreflightError,
authoritative_physical_ch1_feedback_state,
@@ -975,6 +982,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
selected_devices: set[str] = set()
result: list[dict[str, Any]] = []
+ if romantic_scene(self._n7_scene_identity_text(scene)):
+ return [as_scene_action(item, category="romantic-light", kind="required")
+ for item in romantic_light_actions(raw_catalog)]
color_patterns = self._cinema_preferred_color_patterns(scene)
for category, identity_pattern in (
("flashing-light", N7_FLASHING_LIGHT_PATTERN),
@@ -1121,6 +1131,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
document = deepcopy(dict(scene))
cinema_augmentation = self._augment_n7_cinema_scene(document)
+ if romantic_scene(self._n7_scene_identity_text(document)):
+ document["actions"] = replace_romantic_light_actions(
+ document.get("actions", []), self._n7_cinema_peripheral_actions(document)
+ )
+ document["operation_count"] = len(document["actions"])
+ document["domains"] = list(dict.fromkeys(
+ str(action.get("domain") or "") for action in document["actions"]
+ ))
actions = document.get("actions")
if not isinstance(actions, list) or not actions:
raise ValueError("from_n7/scenes card must contain executable actions")
@@ -1440,10 +1458,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
def _mode_requests_and_n7_transfers(
self,
profile: ModeProfile,
+ vehicle_id: str | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Separate locally understood operations from N7-only capabilities."""
requests = self.mode_profiles.requests_for_profiles([profile])
+ vehicle = (
+ self.vehicle_profiles.get(vehicle_id)
+ if any(op.capability == "local_scene.seat_preset" for op in profile.operations)
+ else None
+ )
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
stored_transfer_actions = (
profile.command_flow.get("n7_transfer_actions")
@@ -1473,6 +1497,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
for action in transfer_actions
}
local_requests = []
+ adapted_selectors = set()
for operation, request in zip(
profile.operations,
requests,
@@ -1490,9 +1515,35 @@ class SeatDemoService(ModeScheduleServiceMixin):
operation.operation,
operation.parameters,
)
+ source_action = next((
+ action for action in transfer_actions
+ if self._n7_transfer_operation_selector(
+ action.get("domain"), action.get("capability"),
+ action.get("target_scope"), action.get("operation"),
+ action.get("parameters"),
+ ) == selector
+ ), operation.to_yaml())
+ expanded = (
+ expand_n7_seat_preset(source_action, vehicle) if vehicle is not None else None
+ )
+ if expanded is not None:
+ local_requests.extend(
+ ModeOperation.from_dict(item).to_request(profile.name)
+ for item in expanded
+ )
+ adapted_selectors.add(selector)
+ continue
if selector in transfer_selectors:
continue
local_requests.append(request)
+ transfer_actions = [
+ action for action in transfer_actions
+ if self._n7_transfer_operation_selector(
+ action.get("domain"), action.get("capability"),
+ action.get("target_scope"), action.get("operation"),
+ action.get("parameters"),
+ ) not in adapted_selectors
+ ]
return local_requests, transfer_actions
source_scene = (
profile.command_flow.get("source_scene")
@@ -1517,6 +1568,22 @@ class SeatDemoService(ModeScheduleServiceMixin):
# The server's durable NFC allowlist owns this local TCP path.
# Authorization failures must not be converted into N7 retries.
continue
+ raw_action = (
+ source_actions[index]
+ if index < len(source_actions)
+ and isinstance(source_actions[index], dict)
+ else self._fallback_scene_action(operation, index=index)
+ )
+ # Use the saved operation as authority; source metadata supplies timing only.
+ expanded = expand_n7_seat_preset(
+ {**operation.to_yaml(), "at_ms": raw_action.get("at_ms", 0)}, vehicle
+ ) if vehicle is not None else None
+ if expanded is not None:
+ local_requests.extend(
+ ModeOperation.from_dict(item).to_request(profile.name)
+ for item in expanded
+ )
+ continue
registered = self.model_plan_adapter.capabilities.get(
operation.capability
)
@@ -1532,20 +1599,21 @@ class SeatDemoService(ModeScheduleServiceMixin):
):
local_requests.append(request)
continue
- raw_action = (
- source_actions[index]
- if index < len(source_actions)
- and isinstance(source_actions[index], dict)
- else None
- )
- action = (
- deepcopy(raw_action)
- if raw_action is not None
- else self._fallback_scene_action(operation, index=index)
- )
- transfer_actions.append(action)
+ transfer_actions.append(deepcopy(raw_action))
return local_requests, transfer_actions
+ def mode_profile_execution_requests(
+ self, profile: ModeProfile, vehicle_id: str | None = None,
+ ) -> list[dict[str, Any]]:
+ """Expose the same expanded capability view used when executing a card."""
+ local, transfers = self._mode_requests_and_n7_transfers(profile, vehicle_id)
+ return [
+ *local,
+ *(ModeOperation.from_dict(item).to_request(profile.name) for item in transfers),
+ *(operation.to_request(profile.name) for operation in profile.operations
+ if operation.domain == "self_extension" and operation.capability == "infrared.emit"),
+ ]
+
@staticmethod
def _has_local_mode_peripheral(profile: ModeProfile) -> bool:
return any(
@@ -3783,7 +3851,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
)
if direct_mode_profile is not None:
mode_requests, n7_transfer_actions = (
- self._mode_requests_and_n7_transfers(direct_mode_profile)
+ self._mode_requests_and_n7_transfers(direct_mode_profile, vehicle_profile.id)
)
if mode_creation_scopes is not None:
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
@@ -5147,7 +5215,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
matched_profile = matched_modes[0]
mode_requests, n7_transfer_actions = (
- self._mode_requests_and_n7_transfers(matched_profile)
+ self._mode_requests_and_n7_transfers(matched_profile, vehicle_profile.id)
)
selected_seat_scope = self._explicit_mode_seat_selection(
matched_profile, utterance,
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index bf27ab2..6a6fad5 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -74,6 +74,12 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
)
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402
+from cognitive_agent.application.romantic_lighting import ( # noqa: E402
+ romantic_light_actions,
+ romantic_scene,
+ replace_romantic_light_actions,
+)
+from cognitive_agent.application.mode_scene_ingress import MAX_MODE_SCENE_ACTIONS # noqa: E402
from cognitive_agent.application.seat_service import ( # noqa: E402
LOCAL_CONTROL_BUDGET_MS,
SeatDemoService,
@@ -1802,8 +1808,27 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"source_span": source_utterance,
}
)
- if len(actions) > MAX_PLAN_REQUESTS:
- raise ExtensionIntegrationError("模式草稿包含过多设备动作。")
+ # These are validated execution actions, not the model's raw requests.
+ # A saved seat preset expands to four axes, and IR colors need power-on
+ # prerequisites. Keep the model's 20-request gate at the input boundary.
+ if len(actions) > MAX_MODE_SCENE_ACTIONS:
+ message = f"模式草稿展开后包含 {len(actions)} 项动作,超过 {MAX_MODE_SCENE_ACTIONS} 项上限。"
+ failed = deepcopy(native_result)
+ self.service._scrub_failed_scene_preview(failed)
+ failed.update({
+ "status": "validation_failed", "mode_profile_draft": None,
+ "mode_creation_failure": {
+ "code": "MODE_DRAFT_ACTION_LIMIT", "reason": message,
+ "next_step": "请减少模式中的设备动作后重新生成。",
+ },
+ "decision_summary": [message],
+ "validation": [*list(native_result.get("validation") or []), {
+ "severity": "error", "code": "MODE_DRAFT_ACTION_LIMIT",
+ "path": "intent_ir.spec.actions", "message": message,
+ }],
+ })
+ self.service.persist_composed_result(failed)
+ raise ExtensionIntegrationError(message)
spec["actions"] = actions
spec["requests"] = requests
detected_domains = [
@@ -4241,10 +4266,21 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if isinstance(extension_config, dict)
else []
)
+ catalog = self.extension.list_peripheral_capabilities()
+ if romantic_scene(getattr(profile, "name", "")):
+ required = [{
+ "id": f"romantic-light-{item['slot']}",
+ "domain": "self_extension", "capability": "infrared.emit",
+ "operation": "emit",
+ "target": {
+ "device": item["device_id"], "name": item.get("device_name", ""),
+ "card_uid": item["card_uid"],
+ },
+ "parameters": {"action": item["action"], "slot": item["slot"]},
+ } for item in romantic_light_actions(catalog)]
+ operations = replace_romantic_light_actions(operations, required)
if not operations:
return []
-
- catalog = self.extension.list_peripheral_capabilities()
entries: list[dict[str, object]] = []
for index, operation in enumerate(operations):
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index 4530d62..a7456af 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -846,6 +846,45 @@ operations:
)
)
+ def test_uploaded_seat_preset_executes_four_local_axes_through_mode_api(self) -> None:
+ scene = {
+ "id": "n7-cinema-seat-test", "name": "影院模式", "label": "影院模式",
+ "aliases": ["影院模式"], "description": "车端副驾预设", "icon": "film",
+ "origin": "headunit", "scope": {"vehicle_ids": ["denza_n7"]},
+ "actions": [{
+ "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]},
+ }],
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ registry = ModeProfileRegistry(Path(directory) / "presets", user_root=Path(directory) / "modes")
+ with patch.object(self.service, "mode_profiles", registry), patch.object(
+ self.service._vehicle_mqtt_transport, "publish_n7_control_transfer",
+ side_effect=AssertionError("dry run must not publish"),
+ ):
+ self.service.import_n7_mode_scene(scene)
+ request = urllib.request.Request(
+ f"{self.base_url}/api/mode-profiles/n7-cinema-seat-test/execute",
+ method="POST", headers={"Content-Type": "application/json"},
+ data=json.dumps({
+ "vehicle_id": "denza_n7", "execution_mode": "dry_run",
+ "control_transport": "wireless_mqtt",
+ "client_type": "web_console_mode_card",
+ }).encode("utf-8"),
+ )
+ with self.opener.open(request, timeout=5) as response:
+ result = json.loads(response.read().decode("utf-8"))
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(result.get("n7_transfer_candidates", []), [])
+ operations = result["function_configs"]["seat"]["spec"]["operations"]
+ self.assertEqual(
+ [(op["capability"], op["parameters"]["position_pct"]) for op in operations],
+ [("seat.set_longitudinal_position", 51), ("seat.set_cushion_tilt", 30),
+ ("seat.set_backrest_recline", 44), ("seat.set_legrest_height", 53)],
+ )
+ self.assertTrue(all(op["target"]["instance"] == "passenger" for op in operations))
+
def test_saved_mode_accepts_prefixed_self_extension_flow_key(self) -> None:
with tempfile.TemporaryDirectory() as directory:
extension = SelfExtensionService(
@@ -997,7 +1036,7 @@ operations:
def runtime_url(file_name: str, source_url: str) -> str:
hashed = runtime_assets.get(file_name)
return f"/{hashed}" if hashed else source_url
- with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-147-video-media", timeout=3) as response:
+ with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-148-seat-preset", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
@@ -1084,7 +1123,7 @@ operations:
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
self.assertNotIn("透明车体 · 实体设备", template)
self.assertIn(
- f'src="{runtime_url("app.js", "/app.js?v=dual-seat-147-video-media")}"',
+ f'src="{runtime_url("app.js", "/app.js?v=dual-seat-148-seat-preset")}"',
html,
)
self.assertIn('import "../styles.css";', vite_entry)
@@ -1433,7 +1472,7 @@ operations:
self.assertIn(f'/{runtime_assets[asset_name]}', service_worker)
else:
self.assertIn(
- 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"',
+ 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"',
service_worker,
)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
@@ -1442,7 +1481,7 @@ operations:
self.assertIn('/assistant-chat.js?v=codex-assistant-39-video-media', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-5-dialog-size', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker)
- self.assertIn('"/app.js?v=dual-seat-147-video-media"', service_worker)
+ self.assertIn('"/app.js?v=dual-seat-148-seat-preset"', service_worker)
self.assertIn('/firmware-workbench-entry.css?v=2', service_worker)
self.assertIn('/firmware-workbench-entry.js?v=3', service_worker)
self.assertIn('/firmware-workbench-theme.js?v=1', service_worker)
@@ -1910,7 +1949,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/app.js?v=dual-seat-147-video-media", timeout=3
+ f"{self.base_url}/app.js?v=dual-seat-148-seat-preset", timeout=3
) as response:
app_script = response.read().decode("utf-8")
diff --git a/tests/integration/test_scene_peripheral_mode_flow.py b/tests/integration/test_scene_peripheral_mode_flow.py
index 32dddd9..ed2a14d 100644
--- a/tests/integration/test_scene_peripheral_mode_flow.py
+++ b/tests/integration/test_scene_peripheral_mode_flow.py
@@ -125,8 +125,10 @@ def _received_cinema_scene() -> dict[str, object]:
}
+@pytest.mark.parametrize("full_scene", [False, True])
def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
tmp_path: Path,
+ full_scene: bool,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "runs"
@@ -138,10 +140,41 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
SimulationGateway(),
catalog_db_path=tmp_path / "infrared.sqlite3",
)
- extension.sync_peripheral_catalog([_learned_light_power_on()])
received_cinema = _received_cinema_scene()
+ lights = [_learned_light_power_on()]
+ if full_scene:
+ extra = [
+ *(('window', 'window.set_position', target, {"position_pct": 0})
+ for target in ('front_right', 'rear_left', 'rear_right')),
+ ('roof', 'roof.set_shade_position', 'vehicle', {"position_pct": 0}),
+ ('seat', 'seat.set_ventilation', 'passenger', {"state": "off"}),
+ ('climate', 'climate.set_power', 'vehicle', {"state": "on"}),
+ ('climate', 'climate.set_fan_level', 'vehicle', {"level": 2}),
+ ('climate', 'climate.set_outlet_mode', 'vehicle', {"mode": "face"}),
+ ('climate', 'climate.set_cycle_mode', 'vehicle', {"mode": "internal"}),
+ ('climate', 'climate.set_control_mode', 'vehicle', {"mode": "manual"}),
+ ('interior_light', 'interior_light.set_reading_power', 'all', {"state": "off"}),
+ ('ambient_light', 'ambient_light.set_power', 'vehicle', {"state": "on"}),
+ ('ambient_light', 'ambient_light.set_color_index', 'vehicle', {"color_index": 36}),
+ ('ambient_light', 'ambient_light.set_brightness_level', 'vehicle', {"brightness_level": 4}),
+ ]
+ received_cinema["actions"].extend({
+ "type": "control", "at_ms": 0, "domain": domain, "capability": capability,
+ "target_scope": target, "operation": "set", "parameters": parameters,
+ } for domain, capability, target, parameters in extra)
+ received_cinema["operation_count"] = len(received_cinema["actions"])
+ lights = [{
+ **_learned_light_power_on(), "id": f"ir-slot:{device}:{slot}",
+ "device_id": device, "device_name": name, "object_names": [name],
+ "action": action, "action_names": [action], "slot": slot,
+ } for device, name, action, slot in [
+ ("lantern", "灯笼", "打开", 2), ("lantern", "灯笼", "红色", 3),
+ ("strip", "灯带", "打开", 22), ("strip", "灯带", "红色", 23),
+ ("candle", "蜡烛", "打开", 41),
+ ]]
+ extension.sync_peripheral_catalog(lights)
service.import_n7_mode_scene(received_cinema)
- stale_profile = service.mode_profiles.save_mode(
+ stale_profile = None if full_scene else service.mode_profiles.save_mode(
"浪漫影院模式",
[
{
@@ -203,7 +236,7 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
side_effect=AssertionError("mode creation must not emit infrared"),
):
response = assistant.stream(
- "创建浪漫影院模式",
+ "创建一个浪漫影院模式",
conversation_id="conv-romantic-cinema-template",
principal="romantic-cinema-template-test",
history=[],
@@ -221,8 +254,11 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
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
+ if stale_profile is not None:
+ assert result["mode_profile_draft"]["existing_mode_id"] == stale_profile.id
+ assert result["mode_profile_draft"]["operation_count"] == (25 if full_scene else 7)
+ safe_result = CodexAssistantService._safe_vehicle_result(result)
+ assert len(safe_result["actions"]) == (25 if full_scene else 7)
native_actions = {
(
@@ -233,21 +269,35 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
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"}
+ **{
+ (action["capability"], action["target_scope"]): action["parameters"]
+ for action in received_cinema["actions"]
+ if action["domain"] not in {"local_vehicle", "android"}
+ },
+ ("seat.set_longitudinal_position", "passenger"): {"position_pct": 51},
+ ("seat.set_cushion_tilt", "passenger"): {"position_pct": 30},
+ ("seat.set_backrest_recline", "passenger"): {"position_pct": 44},
+ ("seat.set_legrest_height", "passenger"): {"position_pct": 53},
}
- assert result["n7_transfer_candidates"] == received_cinema["actions"][2:]
- assert [action["slot"] for action in result["infrared_actions"]] == [1]
+ video = [action for action in received_cinema["actions"] if action["domain"] == "android"]
+ assert result["n7_transfer_candidates"] == video
+ slots = [action["slot"] for action in result["infrared_actions"]]
+ assert sorted(slots) == ([2, 3, 22, 23, 41] if full_scene else [1])
+ if full_scene:
+ assert slots.index(2) < slots.index(3)
+ assert slots.index(22) < slots.index(23)
draft = result["mode_profile_draft"]
- saved = service.save_mode_profile_card(
- stale_profile.id,
+ arguments = (
draft["name"],
draft["instruction"],
draft["source_request_id"],
draft["vehicle_id"],
)
+ saved = (
+ service.save_mode_profile_card(stale_profile.id, *arguments)
+ if stale_profile is not None else service.create_mode_profile_card(*arguments)
+ )
profile = service.get_mode_profile_card(saved["mode_profile"]["id"], "denza_n7")
saved_selectors = {
(operation.capability, operation.target_scope)
@@ -256,13 +306,45 @@ def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
assert saved_selectors == {
(action["capability"], action["target_scope"])
for action in received_cinema["actions"]
- } | {("infrared.emit", "romance-light")}
+ if action["capability"] != "local_scene.seat_preset"
+ } | {
+ ("seat.set_longitudinal_position", "passenger"),
+ ("seat.set_cushion_tilt", "passenger"),
+ ("seat.set_backrest_recline", "passenger"),
+ ("seat.set_legrest_height", "passenger"),
+ } | {("infrared.emit", item["device_id"]) for item in lights}
_local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
- assert transfer_actions == received_cinema["actions"][2:]
+ assert transfer_actions == video
finally:
service.close()
+def test_expanded_mode_overflow_cannot_leave_a_saveable_partial_draft() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ handler.service = SimpleNamespace(
+ _scrub_failed_scene_preview=SeatDemoService._scrub_failed_scene_preview,
+ persist_composed_result=MagicMock(),
+ )
+ native = {
+ "request_id": "req-expanded-overflow", "status": "succeeded",
+ "execution_mode": "dry_run", "mode_profile_draft": {"operation_count": 256},
+ "intent_ir": {"spec": {"actions": [
+ {"domain": "seat", "capability": "seat.set_ventilation"}
+ ] * 256}},
+ }
+ infrared = {"function_configs": {"self_extension": {"spec": {"operations": [{
+ "target": {"device": "lamp"}, "parameters": {"action": "打开", "slot": 1},
+ }]}}}}
+ with patch.object(handler, "_merge_can_and_peripheral_results", return_value=native):
+ with pytest.raises(ExtensionIntegrationError, match="257.*256"):
+ handler._merge_scene_peripheral_preview("创建浪漫模式", native, infrared, [])
+ failed = handler.service.persist_composed_result.call_args.args[0]
+ assert failed["status"] == "validation_failed"
+ assert failed["mode_profile_draft"] is None
+ assert failed["command_plans"] == {}
+ assert failed["intent_ir"]["spec"]["actions"] == []
+
+
def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
tmp_path: Path,
) -> None:
diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py
index cfba60f..d60aa8a 100644
--- a/tests/unit/test_assistant_frontend_contract.py
+++ b/tests/unit/test_assistant_frontend_contract.py
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert (
- 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"'
+ 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"'
in service_worker
)
assert '"/assistant-chat.css?v=codex-assistant-30"' in service_worker
diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py
index e7762d6..a3c6d8c 100644
--- a/tests/unit/test_energy_monitor_frontend_contract.py
+++ b/tests/unit/test_energy_monitor_frontend_contract.py
@@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
"/energy-monitor.js?v=energy-monitor-5-performance"
)
assert (
- 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"'
+ 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"'
in service_worker
)
diff --git a/tests/unit/test_frontend_performance_contract.py b/tests/unit/test_frontend_performance_contract.py
index 0ba3f40..791f41e 100644
--- a/tests/unit/test_frontend_performance_contract.py
+++ b/tests/unit/test_frontend_performance_contract.py
@@ -92,12 +92,12 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
worker = source("service-worker.js")
for asset in (
- "/app.js?v=dual-seat-147-video-media",
+ "/app.js?v=dual-seat-148-seat-preset",
"/console-v2.js?v=local-first-79-cinema-status",
"/self-extension.js?v=peripheral-cards-59",
"/energy-monitor.js?v=energy-monitor-5-performance",
):
assert asset in html
assert f'"{asset}"' in worker
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"' in worker
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py
index 37d72d7..c5c7d62 100644
--- a/tests/unit/test_frontend_theme_contract.py
+++ b/tests/unit/test_frontend_theme_contract.py
@@ -54,7 +54,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
assert source_asset in main
assert f'"{cached_asset}"' in service_worker
assert 'import "./styles/design-system.css"' in main
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
diff --git a/tests/unit/test_mode_gallery_grouping.py b/tests/unit/test_mode_gallery_grouping.py
index 01cb06c..4462adc 100644
--- a/tests/unit/test_mode_gallery_grouping.py
+++ b/tests/unit/test_mode_gallery_grouping.py
@@ -103,6 +103,45 @@ def node_runtime() -> str:
raise AssertionError("Node.js is required to execute the mode gallery grouping contract")
+def test_saved_mode_status_distinguishes_partial_submission_and_completion() -> None:
+ app = (WEB / "app.js").read_text(encoding="utf-8")
+ start = app.index("function savedModeExecutionView(")
+ end = app.index("\nasync function executeSavedModeProfile", start)
+ cases = [
+ {"status": "partially_succeeded", "execution_mode": "hardware"},
+ {"status": "partially_succeeded", "execution_mode": "hardware",
+ "execution_outcome": "submitted_unverified"},
+ {"status": "succeeded", "execution_mode": "hardware",
+ "execution_outcome": "submitted_unverified"},
+ {"status": "succeeded", "execution_mode": "hardware"},
+ {"status": "succeeded", "execution_mode": "dry_run",
+ "execution_outcome": "submitted_unverified"},
+ {"status": "partially_succeeded", "execution_mode": "dry_run"},
+ {"status": "failed", "execution_mode": "hardware"},
+ ]
+ script = (
+ "function modeScheduleExecutionView() { return null; }\n"
+ "function executionVerificationStatus(result) { return result.execution_outcome; }\n"
+ + app[start:end]
+ + "\nconsole.log(JSON.stringify("
+ + json.dumps(cases)
+ + '.map(result => savedModeExecutionView({name: "影院模式"}, result))));'
+ )
+ completed = subprocess.run(
+ [node_runtime(), "-e", script], check=True, capture_output=True,
+ text=True, encoding="utf-8",
+ )
+ result = json.loads(completed.stdout)
+ assert [item["state"] for item in result] == [
+ "partial", "partial", "submitted", "succeeded", "succeeded", "partial", "failed",
+ ]
+ assert [item["title"] for item in result] == [
+ "影院模式部分完成", "影院模式部分指令已提交,待车端确认",
+ "影院模式指令已提交,待车端确认", "影院模式执行完成",
+ "影院模式模拟校验完成", "影院模式模拟校验部分通过", "影院模式执行未完成",
+ ]
+
+
def test_mode_gallery_classifies_profiles_into_exclusive_custom_static_dynamic_groups() -> None:
app = (WEB / "app.js").read_text(encoding="utf-8")
start = app.index("const MODE_GALLERY_GROUPS")
diff --git a/tests/unit/test_mode_scene_ingress.py b/tests/unit/test_mode_scene_ingress.py
index bb3ba34..2698b52 100644
--- a/tests/unit/test_mode_scene_ingress.py
+++ b/tests/unit/test_mode_scene_ingress.py
@@ -12,6 +12,7 @@ from cognitive_agent.application.assistant_capabilities import (
AssistantCapabilityCatalog,
)
from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.n7_seat_preset import N7_SEAT_PRESET_CAPABILITIES
from cognitive_agent.application.mode_scene_ingress import (
ModeSceneIngressConsumer,
ModeSceneIngressError,
@@ -714,6 +715,8 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
) -> None:
service = configured_service(tmp_path)
scene = cinema_scene_card()
+ scene["actions"].append(cinema_video_launch_action())
+ scene["operation_count"] = len(scene["actions"])
service.import_n7_mode_scene(scene)
result = service.execute_mode_profile_card(
@@ -733,7 +736,21 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
for config in result["function_configs"].values()
for operation in config["spec"]["operations"]
]
- assert len(compiled_operations) == 16
+ assert len(compiled_operations) == 20
+ positions = {
+ operation["capability"]: operation["parameters"]["position_pct"]
+ for operation in compiled_operations
+ if operation["capability"] in N7_SEAT_PRESET_CAPABILITIES
+ }
+ assert positions == dict(zip(N7_SEAT_PRESET_CAPABILITIES, [51, 30, 44, 53], strict=True))
+ hardware_plan = service.hardware_compiler.compile(result["function_configs"]["seat"])
+ moves = [
+ command["signals"] for command in hardware_plan["spec"]["commands"]
+ if command["message"] == "T2G_Seat_Move_Command_300"
+ ]
+ assert [(move["Motor_Selector"], move["Opcode"], move["Param_A"]) for move in moves] == [
+ (0, 1, 5100), (1, 1, 3000), (2, 1, 4400), (3, 1, 5300),
+ ]
assert all(
operation.get("parse_source") == "saved_mode_command_flow"
for operation in compiled_operations
@@ -743,15 +760,15 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
for operation in compiled_operations
if operation["capability"] == "window.set_position"
} == {"front_left", "front_right", "rear_left", "rear_right"}
- transferred = scene["actions"][5]
+ transferred = cinema_video_launch_action()
assert result["n7_transfer_candidates"] == [transferred]
assert result["deferred_requests"] == [
{
- "domain": "local_vehicle",
- "capability": "local_scene.seat_preset",
- "target_scope": "vehicle",
- "desired_change": "set",
- "parameters": {"targets_pulse": [246, 112, 405, 342]},
+ "domain": "android",
+ "capability": "android.launch_app",
+ "target_scope": "headunit",
+ "desired_change": "launch",
+ "parameters": transferred["parameters"],
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
"execution_backend": "n7_vehicle_transfer",
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
@@ -760,7 +777,7 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
]
assert len(compiled_operations) + len(result["n7_transfer_candidates"]) == len(
scene["actions"]
- )
+ ) + 3
text_result = service.process(
"进入Cinema mode模式",
@@ -774,6 +791,10 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
assert text_result["mode_profile"] is None
assert [item["id"] for item in text_result["matched_modes"]] == ["cinema"]
assert len(text_result["n7_transfer_candidates"]) == 1
+ assert {
+ operation["capability"]
+ for operation in text_result["function_configs"]["seat"]["spec"]["operations"]
+ } >= set(N7_SEAT_PRESET_CAPABILITIES)
assert service.mode_profiles.match_text(
"氛围灯设置为Cinema mode模式",
vehicle_id="denza_n7",
@@ -786,8 +807,9 @@ def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
)
assert catalog_mode["assistant_callable"] is True
assert catalog_mode["n7_transfer_operation_ids"] == [
- "local_scene.seat_preset"
+ "android.launch_app"
]
+ assert set(catalog_mode["operation_ids"]) >= set(N7_SEAT_PRESET_CAPABILITIES)
@pytest.mark.parametrize(
@@ -826,6 +848,59 @@ def test_registered_but_invalid_action_is_not_forwarded_to_n7(
assert transfer_actions == []
+def test_existing_saved_cinema_cards_adapt_on_execution_without_reimport(tmp_path: Path) -> None:
+ service = configured_service(tmp_path)
+ scene = cinema_scene_card()
+ scene["actions"].append(cinema_video_launch_action())
+ service.import_n7_mode_scene(scene)
+ imported = service.get_mode_profile_card("cinema", "denza_n7")
+ legacy = service.mode_profiles.save_mode(
+ "旧版浪漫影院模式", [op.to_yaml() for op in imported.operations],
+ vehicle_id="denza_n7", instruction="进入旧版浪漫影院模式",
+ command_flow={
+ "plans": {"saved": {"spec": {"commands": [{"message": "saved"}]}}},
+ "n7_transfer_actions": [scene["actions"][5], cinema_video_launch_action()],
+ },
+ )
+ before = {path: path.read_bytes() for path in (tmp_path / "profiles").rglob("*.yaml")}
+ service.mode_profiles = ModeProfileRegistry(
+ tmp_path / "presets", user_root=tmp_path / "profiles"
+ )
+ publisher = MagicMock(side_effect=AssertionError("preview must not publish"))
+ service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
+ try:
+ for mode_id in ("cinema", legacy.id):
+ result = service.execute_mode_profile_card(
+ mode_id, execution_mode="dry_run", control_transport="wireless_mqtt"
+ )
+ assert result["status"] == "partially_succeeded"
+ assert result["n7_transfer_candidates"] == [cinema_video_launch_action()]
+ operations = result["function_configs"]["seat"]["spec"]["operations"]
+ assert [op["parameters"]["position_pct"] for op in operations
+ if op["capability"] in N7_SEAT_PRESET_CAPABILITIES] == [51, 30, 44, 53]
+ assert all(path.read_bytes() == value for path, value in before.items())
+ publisher.assert_not_called()
+ finally:
+ service.close()
+
+
+def test_invalid_preset_blocks_the_card_instead_of_transferring_it(tmp_path: Path) -> None:
+ service = configured_service(tmp_path)
+ scene = cinema_scene_card()
+ scene["actions"][5]["parameters"]["targets_pulse"][0] = 9999
+ service.import_n7_mode_scene(scene)
+ publisher = MagicMock(side_effect=AssertionError("invalid motion must not publish"))
+ service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
+ try:
+ with pytest.raises(ValueError, match="0–480"):
+ service.execute_mode_profile_card(
+ "cinema", execution_mode="hardware", control_transport="wireless_mqtt"
+ )
+ publisher.assert_not_called()
+ finally:
+ service.close()
+
+
def test_frontend_marks_n7_synchronized_cards() -> None:
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
encoding="utf-8"
diff --git a/tests/unit/test_n7_seat_preset.py b/tests/unit/test_n7_seat_preset.py
new file mode 100644
index 0000000..f53d408
--- /dev/null
+++ b/tests/unit/test_n7_seat_preset.py
@@ -0,0 +1,88 @@
+from copy import deepcopy
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+
+from cognitive_agent.application.n7_seat_preset import (
+ N7_SEAT_PRESET_CAPABILITIES,
+ expand_n7_seat_preset,
+)
+from cognitive_agent.application.vehicle_profile import VehicleProfileRegistry
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def preset(targets=None):
+ return {
+ "domain": "local_vehicle", "capability": "local_scene.seat_preset",
+ "target_scope": "vehicle", "operation": "set", "at_ms": 0,
+ "parameters": {"targets_pulse": [246, 112, 405, 342] if targets is None else targets},
+ }
+
+
+@pytest.fixture
+def vehicle():
+ return VehicleProfileRegistry(ROOT / "config" / "vehicles").get("denza_n7")
+
+
+def test_preset_uses_vehicle_calibration_and_keeps_original_pulses(vehicle):
+ action = preset()
+ original = deepcopy(action)
+ operations = expand_n7_seat_preset(action, vehicle)
+ assert [item["capability"] for item in operations] == list(N7_SEAT_PRESET_CAPABILITIES)
+ assert [item["parameters"]["position_pct"] for item in operations] == [51, 30, 44, 53]
+ assert all(item["target_scope"] == "passenger" for item in operations)
+ assert action == original
+ calibrated = replace(vehicle, seat_motors={
+ **vehicle.seat_motors,
+ "longitudinal": replace(vehicle.seat_motors["longitudinal"], hall_total_pulses=500),
+ })
+ assert expand_n7_seat_preset(action, calibrated)[0]["parameters"] == {"position_pct": 49}
+
+
+@pytest.mark.parametrize("targets", [
+ [], [1, 2, 3], [1, 2, 3, 4, 5], "246,112,405,342",
+ [True, 112, 405, 342], [246.5, 112, 405, 342], ["246", 112, 405, 342],
+ [-1, 112, 405, 342], [481, 112, 405, 342], [246, 375, 405, 342],
+ [246, 112, 918, 342], [246, 112, 405, 648],
+])
+def test_invalid_pulses_are_rejected_without_clamping(vehicle, targets):
+ with pytest.raises(ValueError):
+ expand_n7_seat_preset(preset(targets), vehicle)
+
+
+@pytest.mark.parametrize("field,value", [
+ ("target_scope", "driver"), ("domain", "seat"), ("operation", "toggle"),
+ ("parameters", {"targets_pulse": [246, 112, 405, 342], "speed": 100}),
+])
+def test_unrecognized_target_or_contract_cannot_move_a_seat(vehicle, field, value):
+ action = preset()
+ action[field] = value
+ with pytest.raises(ValueError):
+ expand_n7_seat_preset(action, vehicle)
+
+
+def test_zero_and_small_targets_do_not_accidentally_home(vehicle):
+ assert [item["parameters"]["position_pct"] for item in expand_n7_seat_preset(
+ preset([0, 0, 0, 0]), vehicle
+ )] == [0, 0, 0, 0]
+ assert [item["parameters"]["position_pct"] for item in expand_n7_seat_preset(
+ preset([1, 1, 1, 1]), vehicle
+ )] == [1, 1, 1, 1]
+ assert [item["parameters"]["position_pct"] for item in expand_n7_seat_preset(
+ preset([480, 374, 917, 647]), vehicle
+ )] == [100, 100, 100, 100]
+
+
+def test_delayed_actions_keep_the_vehicle_timing(vehicle):
+ action = preset()
+ action["at_ms"] = 3000
+ assert expand_n7_seat_preset(action, vehicle) is None
+
+
+def test_wrong_vehicle_or_missing_calibration_is_rejected(vehicle):
+ with pytest.raises(ValueError):
+ expand_n7_seat_preset(preset(), replace(vehicle, id="other_vehicle"))
+ with pytest.raises(ValueError):
+ expand_n7_seat_preset(preset(), replace(vehicle, seat_motors={}))
diff --git a/tests/unit/test_romantic_lighting.py b/tests/unit/test_romantic_lighting.py
new file mode 100644
index 0000000..e0ccf5b
--- /dev/null
+++ b/tests/unit/test_romantic_lighting.py
@@ -0,0 +1,134 @@
+from copy import deepcopy
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+from cognitive_agent.application.codex_assistant import CodexAssistantService
+from cognitive_agent.application.romantic_lighting import romantic_light_actions
+from cognitive_agent.application.scene_composer import scene_required_peripheral_refs
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
+from cognitive_agent.application.self_extension import GatewayError, SelfExtensionService, SimulationGateway
+from cognitive_agent.server import SeatDemoHandler
+
+
+def light(device, name, action, slot, **extra):
+ return {
+ "id": f"ir-slot:{device}:{slot}", "status": "learned", "enabled": True,
+ "device_id": device, "device_name": name, "device_type": "other",
+ "card_uid": f"{int(device[-1]):08X}", "slot": slot, "action": action,
+ "object_names": [name], "action_names": [action], "risk": "low", **extra,
+ }
+
+
+def catalog():
+ return [
+ light("light1", "灯笼", "关闭", 1), light("light1", "灯笼", "打开", 2),
+ light("light1", "灯笼", "切换为红色", 3), light("light1", "灯笼", "切换为蓝色", 5),
+ light("light2", "灯带", "打开", 22), light("light2", "灯带", "设置为红色", 23),
+ light("light3", "蜡烛", "打开", 41), light("light3", "蜡烛", "关闭", 42),
+ light("light4", "球形灯", "打开", 51), light("light4", "球形灯", "设置为红色", 52),
+ ]
+
+
+def test_romantic_lights_all_use_on_then_red_and_preserve_on_only_candles():
+ entries = catalog()
+ entries += [
+ light("light5", "落地灯", "红色", 61), # No known power-on command.
+ light("light6", "投影灯", "打开", 71),
+ light("light6", "投影灯", "蓝色", 72), # Never substitute blue for red.
+ light("light7", "灯笼", "打开", 81, enabled=False),
+ light("light7", "灯笼", "红色", 82, enabled=False),
+ ]
+ before = deepcopy(entries)
+ assert [item["slot"] for item in romantic_light_actions(entries)] == [2, 3, 22, 23, 41, 51, 52]
+ assert entries == before
+
+
+@pytest.mark.parametrize("name", ["浪漫模式", "浪漫影院模式", "约会模式", "Romantic cinema"])
+def test_creation_replaces_old_colors_and_orders_all_required_lights(name):
+ actions = [{
+ "ref": f"ir:{item['slot']}", "device_key": item["device_id"],
+ "device_label": item["device_name"], "action_label": item["action"],
+ "object_names": item["object_names"], "action_names": item["action_names"],
+ } for item in catalog()]
+ context = {"peripheral_actions": actions}
+ required = scene_required_peripheral_refs(name, context)
+ assert required == ["ir:2", "ir:3", "ir:22", "ir:23", "ir:41", "ir:51", "ir:52"]
+ context["scene_semantics"] = {"mode_name": name, "peripheral_policy": {"required_refs": required}}
+ plan = {"requests": [{
+ "domain": "self_extension", "capability": "infrared.emit",
+ "target_scope": ref, "operation": "emit",
+ "parameters": [{"name": "capability_ref", "value": ref}],
+ } for ref in ["ir:5", "ir:3", "ir:2"]], "detected_domains": ["self_extension"]}
+ result = CodexAssistantService._with_required_scene_peripherals(plan, context)
+ assert [item["parameters"][0]["value"] for item in result["requests"]] == required
+
+
+def test_same_named_lights_remain_separate_devices():
+ actions = [{
+ "ref": f"ir:{device}-{action}", "device_key": device,
+ "device_label": "灯笼", "action_label": label,
+ } for device in ["one", "two"] for action, label in [("on", "打开"), ("red", "红色")]]
+ assert [item["ref"] for item in romantic_light_actions(actions)] == [
+ "ir:one-on", "ir:one-red", "ir:two-on", "ir:two-red",
+ ]
+
+
+def test_romantic_import_adds_ordered_lights_without_requiring_cinema_base(tmp_path: Path):
+ service = SeatDemoService(REPOSITORY_ROOT)
+ service.mode_profiles = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "modes")
+ service.set_mode_scene_peripheral_catalog_provider(catalog)
+ climate = {"type": "control", "at_ms": 0, "domain": "climate",
+ "capability": "climate.set_temperature", "target_scope": "vehicle",
+ "operation": "set", "parameters": {"temperature_c": 25}}
+ scene = {"id": "romantic-date", "name": "浪漫约会模式", "label": "浪漫约会模式",
+ "origin": "headunit", "actions": [climate], "scope": {"vehicle_ids": ["denza_n7"]}}
+ try:
+ for _ in range(2):
+ service.import_n7_mode_scene(scene)
+ profile = service.get_mode_profile_card("romantic-date", "denza_n7")
+ actions = profile.command_flow["source_scene"]["actions"]
+ assert actions[0] == climate
+ assert [item["parameters"]["slot"] for item in actions[1:]] == [2, 3, 22, 23, 41, 51, 52]
+ scene = profile.command_flow["source_scene"]
+ assert len(profile.operations) == 8
+ finally:
+ service.close()
+
+
+@pytest.mark.parametrize("fail_power", [False, True])
+def test_saved_romantic_mode_orders_real_dispatch_and_stops_on_power_failure(tmp_path: Path, fail_power):
+ extension = SelfExtensionService(SimulationGateway(), catalog_db_path=tmp_path / "ir.sqlite3")
+ extension.sync_peripheral_catalog(catalog())
+ handler = object.__new__(SeatDemoHandler)
+ handler.extension = extension
+ profile = SimpleNamespace(name="浪漫影院模式", command_flow={"function_configs": {
+ "self_extension": {"spec": {"domain": "self_extension", "operations": [{
+ "capability": "infrared.emit", "target": {"device": "light1", "card_uid": "00000001"},
+ "parameters": {"action": "切换为蓝色", "slot": 5},
+ }]}}
+ }})
+ entries = handler._saved_mode_infrared_entries(profile)
+ capabilities = [entry["capability"] for entry in entries]
+ assert [item["slot"] for item in capabilities] == [2, 3, 22, 23, 41, 51, 52]
+ sent = []
+
+ def send(_command, parameters):
+ sent.append(parameters["slot"])
+ if fail_power:
+ raise GatewayError("power-on transport failed")
+ return {"transport": "tcp", "gateway_acknowledged": True, "request": "mock emit"}
+
+ with patch.object(extension, "execute_gateway", side_effect=send), patch(
+ "cognitive_agent.application.self_extension.time.sleep"
+ ):
+ result = extension.execute_peripheral_plan_request(
+ "执行浪漫影院模式", {"steps": [
+ {"kind": "peripheral", "capability_id": item["id"]} for item in capabilities
+ ]}, capabilities, conversation_id="romantic-order-test", execution_mode="hardware",
+ )
+ assert sent == ([2] if fail_power else [2, 3, 22, 23, 41, 51, 52])
+ assert result["status"] == ("failed" if fail_power else "succeeded")
diff --git a/tests/unit/test_scene_composer_fast_path.py b/tests/unit/test_scene_composer_fast_path.py
index 46cd329..f8a2b0a 100644
--- a/tests/unit/test_scene_composer_fast_path.py
+++ b/tests/unit/test_scene_composer_fast_path.py
@@ -695,8 +695,10 @@ def test_romantic_template_always_adds_authorized_decorative_lights() -> None:
)
assert _peripheral_refs(observed[0]) == [
"ir:flash-open-000000000001",
- "ir:ambient-open-0000000001",
+ "ir:flash-red-0000000000002",
"ir:strip-open-000000000001",
+ "ir:strip-red-0000000000002",
+ "ir:ambient-open-0000000001",
]
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
@@ -740,8 +742,10 @@ def test_romantic_cinema_template_keeps_native_base_and_adds_lights_without_mode
assert native_requests == template["plan"]["requests"]
assert _peripheral_refs(observed[0]) == [
"ir:flash-open-000000000001",
- "ir:ambient-open-0000000001",
+ "ir:flash-red-0000000000002",
"ir:strip-open-000000000001",
+ "ir:strip-red-0000000000002",
+ "ir:ambient-open-0000000001",
]
diff --git a/tests/unit/test_scene_composer_schema.py b/tests/unit/test_scene_composer_schema.py
index 034c498..857e74a 100644
--- a/tests/unit/test_scene_composer_schema.py
+++ b/tests/unit/test_scene_composer_schema.py
@@ -520,20 +520,23 @@ def test_romantic_and_mid_autumn_require_decorative_light_actions(
context["scene_semantics"] = semantics
schema = build_scene_output_schema(context)
- assert required == [
+ romantic_refs = [
+ "ir:flash-open", "ir:flash-red", "ir:strip-open", "ir:strip-red", "ir:ambient-open",
+ ]
+ assert required == (romantic_refs if "浪漫" in mode_name else [
"ir:flash-open",
"ir:strip-open",
- ]
+ ])
assert scene_required_peripheral_refs(
mode_name,
context,
include_default_bundle=True,
- ) == [
+ ) == (romantic_refs if "浪漫" in mode_name else [
"ir:flash-open",
"ir:ambient-open",
"ir:strip-open",
- ]
- assert schema["properties"]["requests"]["maxItems"] == 18
+ ])
+ assert schema["properties"]["requests"]["maxItems"] == 20 - len(required)
if "中秋" in mode_name:
assert semantics["family"] == "festival_celebration"
diff --git a/tests/unit/test_vue_frontend_contract.py b/tests/unit/test_vue_frontend_contract.py
index 92fd8fe..4261c6e 100644
--- a/tests/unit/test_vue_frontend_contract.py
+++ b/tests/unit/test_vue_frontend_contract.py
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
assert package["scripts"]["build"] == "vite build"
assert 'import vue from "@vitejs/plugin-vue"' in vite
assert 'outDir: outputRoot' in vite
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v336-seat-preset"' in service_worker
assert 'createHash("sha256")' in vite
assert 'runtime-assets.json' in vite
assert 'rewriteRuntimeReferences' in vite