fix(scenes): restore cinema seat controls and ordered romantic lighting
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={}))
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user