feat: integrate learned peripherals into mode creation

Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows.

Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
This commit is contained in:
Miayui
2026-09-20 16:37:19 +08:00
parent 904f5f8a6c
commit bfff1b515c
25 changed files with 3073 additions and 102 deletions
@@ -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-v324-firmware-notification"'
'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"'
in service_worker
)
assert '"/assistant-chat.css?v=codex-assistant-29"' 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-v324-firmware-notification"'
'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"'
in service_worker
)
@@ -174,6 +174,57 @@ def test_no_matching_ability_publishes_nothing() -> None:
service.close()
def test_binding_a_still_present_card_retries_after_catalog_revision_change() -> None:
nfc = {"card": {"action": "present", "uid": "E113B203"}}
catalog: list[dict[str, Any]] = []
catalog_revision = {"value": "catalog-a"}
publications: list[dict[str, Any]] = []
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: catalog,
catalog_revision_provider=lambda: catalog_revision["value"],
publisher=lambda payload: publications.append(dict(payload))
or {"delivery_status": "queued", "message_id": len(publications)},
burst_window_s=0.06,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(
lambda: service.snapshot()["state"] == "no_matching_extension"
)
assert publications == []
catalog.append(capability("new-device-on"))
catalog_revision["value"] = "catalog-b"
assert wait_until(lambda: len(publications) == 3)
status = service.snapshot()
assert status["state"] == "burst_complete"
assert status["topic"] == "extension/toy"
assert status["active_catalog_revision"] == "catalog-b"
assert status["burst_published_count"] == 3
catalog[:] = [
capability(
"replacement-device-on",
device_id="toy-light-003",
device_name="新灯带",
slot=21,
)
]
catalog_revision["value"] = "catalog-c"
assert wait_until(lambda: len(publications) == 6)
assert publications[-1]["actions"][0]["target_scope"] == "toy-light-003"
# An unrelated revision with identical abilities must not duplicate a burst.
catalog_revision["value"] = "catalog-d"
time.sleep(0.08)
assert len(publications) == 6
finally:
service.close()
def test_failed_burst_attempts_only_three_frames_until_redetected() -> None:
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
attempts: list[float] = []
@@ -216,6 +267,7 @@ def test_server_wires_publisher_without_invoking_infrared_execution() -> None:
assert "extension_toys = NfcExtensionToyPublisher(" in server
assert "catalog_provider=extension.list_peripheral_capabilities" in server
assert "catalog_revision_provider=extension.peripheral_catalog_revision" in server
assert "publisher=service.publish_extension_toy_message" in server
assert "SeatDemoHandler.extension_toys.start()" in server
assert "SeatDemoHandler.extension_toys.close()" in server
@@ -94,10 +94,10 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
for asset in (
"/app.js?v=dual-seat-145-n7-dispatch",
"/console-v2.js?v=local-first-77-performance",
"/self-extension.js?v=pet-settings-53-performance",
"/self-extension.js?v=nfc-mqtt-56",
"/energy-monitor.js?v=energy-monitor-5-performance",
):
assert asset in html
assert f'"{asset}"' in worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"' in worker
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
+1 -1
View File
@@ -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-v324-firmware-notification"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
+11 -1
View File
@@ -21,6 +21,11 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert '<h2>NFC 设备授权</h2>' in script
assert 'NFC 设备授权' in script
assert 'api("nfc/status")' in script
assert 'function applyExtensionToySnapshot(value' in script
assert 'value.extension_toy' in script
assert 'topic: "extension/toy"' in script
assert '发送失败:${extensionToySnapshot.last_error' in script
assert 'burst_published_count' in script
assert 'function bindCardToDevice(device, cardUid)' in script
assert 'function infraredDeviceGroups()' in script
assert 'function configuredCapabilities(deviceId = null)' in script
@@ -28,6 +33,10 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert 'data-nfc-capability-enabled' in script
assert 'data-nfc-select-all' in script
assert '全部命令已默认选中' in script
assert 'class="v2-nfc-existing v2-nfc-rebind"' in script
assert '将当前 NFC 改绑到新设备 ID' in script
assert '改绑设备 ID' in script
assert '旧设备 ID 的 NFC 授权会解除' in script
assert 'function createToyFromNfc()' not in script
assert 'data-nfc-learn-capability' not in script
assert 'data-nfc-relearn-capability' not in script
@@ -38,6 +47,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert 'value.legacy_profiles' in script
assert 'value.legacy_bridge_addresses' in script
assert 'device.cardUid = normalizedUid' in script
assert 'item.cardUid = null' in script
assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
assert 'function infraredSignalFromResponse(value)' in script
@@ -61,7 +71,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=pet-settings-53-performance' in html
assert 'self-extension.js?v=nfc-mqtt-56' in html
assert 'import "../self-extension.css";' in vite_entry
@@ -91,15 +91,23 @@ def test_scene_context_contains_only_callable_reusable_atomic_capabilities(
assert all(item.get("target_scopes") for item in capabilities)
assert all(isinstance(item.get("parameters"), list) for item in capabilities)
# The scene planner needs contracts, not mutable runtime surfaces or saved
# modes. Omitting them also prevents catalog text from becoming prompt
# injection material and keeps the cold-path prompt small.
# The scene planner gets a bounded inventory, not mutable runtime surfaces,
# saved modes, NFC identifiers, slots, or transport payloads.
assert "modes" not in context
assert "learned_peripherals" not in context
assert "service_surfaces" not in context
assert "device_groups" not in context
assert "execution_context" not in context
assert "忽略此前规则" not in json.dumps(context, ensure_ascii=False)
assert context["peripheral_actions"] == []
assert context["device_inventory"][-1]["availability"] == "unavailable"
door_inventory = next(
item for item in context["device_inventory"] if item.get("id") == "door"
)
assert door_inventory["availability"] == "unavailable"
assert door_inventory["capability_ids"] == ["door.set_rear_lock"]
encoded_context = json.dumps(context, ensure_ascii=False)
assert "card_uid" not in encoded_context
assert '"slot"' not in encoded_context
forbidden_capability_fields = {
"bindings",
@@ -154,7 +162,6 @@ def test_scene_context_exposes_only_absolute_parameters_and_native_scene_bounds(
(
lambda snapshot: snapshot.__setitem__("catalog_revision", "runtime-only-change"),
lambda snapshot: snapshot.__setitem__("modes", []),
lambda snapshot: snapshot.__setitem__("learned_peripherals", []),
lambda snapshot: snapshot.__setitem__(
"execution_context", {"current_physical_availability": "preflight_required"}
),
@@ -175,6 +182,69 @@ def test_composition_revision_ignores_dynamic_non_planning_state(
assert _revision(_scene_context(capability_catalog, changed)) == expected
def test_scene_context_exposes_only_authorized_safe_peripheral_refs() -> None:
catalog = AssistantCapabilityCatalog(
SeatDemoService(),
peripheral_provider=lambda: [
{
"id": "ir-slot:romance-light:7",
"status": "learned",
"enabled": True,
"device_id": "romance-light",
"device_name": "氛围灯带",
"object_names": ["氛围灯带", "灯带"],
"device_type": "light",
"card_uid": "2CC8B103",
"action": "设置为红色",
"action_names": ["设置为红色", "红色"],
"risk": "low",
"slot": 7,
},
{
"id": "ir-slot:unbound-light:8",
"status": "learned",
"enabled": False,
"device_id": "unbound-light",
"device_name": "未授权灯带",
"object_names": ["未授权灯带"],
"action": "打开",
"action_names": ["打开"],
"slot": 8,
},
],
)
context = catalog.scene_composition_context(catalog.snapshot())
assert len(context["peripheral_actions"]) == 1
action = context["peripheral_actions"][0]
assert action["ref"].startswith("ir:")
assert action["device_label"] == "氛围灯带"
assert action["action_label"] == "设置为红色"
inventory = [
item
for item in context["device_inventory"]
if item["kind"] == "learned_infrared"
]
assert [item["availability"] for item in inventory] == [
"available",
"unavailable",
]
encoded = json.dumps(context, ensure_ascii=False)
assert "2CC8B103" not in encoded
assert '"slot":7' not in encoded
def test_composition_revision_changes_with_peripheral_planning_contract(
capability_catalog: AssistantCapabilityCatalog,
) -> None:
snapshot = capability_catalog.snapshot()
expected = _revision(_scene_context(capability_catalog, snapshot))
changed = copy.deepcopy(snapshot)
changed["learned_peripherals"] = []
assert _revision(_scene_context(capability_catalog, changed)) != expected
def test_composition_revision_is_order_independent(
capability_catalog: AssistantCapabilityCatalog,
) -> None:
+258
View File
@@ -15,6 +15,7 @@ from cognitive_agent.application.codex_assistant import (
CodexAssistantService,
_mode_profile_create_parts,
)
from cognitive_agent.application.scene_composer import scene_semantic_guidance
CONTROL_TERMS = ("空调", "座椅", "车窗", "遮阳帘", "氛围灯", "阅读灯", "出风口")
@@ -488,6 +489,213 @@ def test_exact_scene_template_hit_skips_codex_and_previews_a_fresh_plan() -> Non
assert events[0]["engine"] == "n7web-scene-template"
def test_exact_scene_template_keeps_native_base_and_adds_only_selected_ir_ref() -> None:
client = StructuredSceneClient()
capability_ref = "ir:0123456789abcdef01234567"
client.structured_answers.put(
json.dumps(
{
"requests": [{"capability_ref": capability_ref}],
"ambiguities": [],
"confidence": 0.93,
},
ensure_ascii=False,
)
)
assistant = CodexAssistantService(client)
template = _scene_template()
context = _scene_context()
context["peripheral_actions"] = [
{
"ref": capability_ref,
"device_label": "氛围灯带",
"action_label": "设置为红色",
"semantic_tags": ["浪漫", "红色", "灯光"],
}
]
context["device_inventory"] = [
{
"kind": "learned_infrared",
"id": "romance-light",
"label": "氛围灯带",
"action": "设置为红色",
"availability": "available",
}
]
observed: list[dict[str, object]] = []
def execute(
_instruction: str,
_progress: Callable[[dict[str, object]], None],
assistant_plan: Mapping[str, object],
) -> dict[str, object]:
observed.append(deepcopy(dict(assistant_plan)))
return _preview_result("观影模式")
result, _events = _invoke(
assistant,
"构建一个观影模式",
execute,
conversation_id="conv-scene-template-ir-augmentation",
catalog=_catalog_with_templates(template),
scene_context=context,
)
assert result["success"] is True
assert result["route"] == "scene_template"
assert len(client.structured_calls) == 1
assert not client.legacy_calls
assert observed[0]["requests"][:2] == template["plan"]["requests"]
assert observed[0]["requests"][2] == {
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": capability_ref,
"operation": "emit",
"duration_ms": None,
"parameters": [{"name": "capability_ref", "value": capability_ref}],
"reason": (
"Scene Composer selected a server-issued learned-device reference; "
"runtime binding resolution is still required."
),
}
assert "learned-device augmentation" in client.thread_instructions[0]
def _scene_context_with_flashing_light() -> dict[str, object]:
context = _scene_context()
context["peripheral_actions"] = [
{
"ref": "ir:flash-open-000000000001",
"device_label": "闪烁灯",
"object_names": ["闪烁灯", "闪光灯"],
"action_label": "打开",
"action_names": ["打开", "开启", "启动"],
"semantic_tags": ["闪烁灯", "灯光"],
},
{
"ref": "ir:flash-red-0000000000002",
"device_label": "闪烁灯",
"object_names": ["闪烁灯", "闪光灯"],
"action_label": "切换为红色",
"action_names": ["切换为红色", "设置为红色"],
"semantic_tags": ["闪烁灯", "红色", "灯光"],
},
{
"ref": "ir:flash-close-00000000003",
"device_label": "闪烁灯",
"object_names": ["闪烁灯", "闪光灯"],
"action_label": "关闭",
"action_names": ["关闭", "熄灭"],
"semantic_tags": ["闪烁灯", "灯光"],
},
]
context["device_inventory"] = [
{
"kind": "learned_infrared",
"id": "toy-light-001",
"label": "闪烁灯",
"availability": "available",
}
]
return context
def _peripheral_refs(plan: Mapping[str, object]) -> list[str]:
return [
str(parameter.get("value") or "")
for request in plan.get("requests", [])
if isinstance(request, Mapping)
and request.get("domain") == "self_extension"
for parameter in request.get("parameters", [])
if isinstance(parameter, Mapping)
and parameter.get("name") == "capability_ref"
]
def test_romantic_template_always_adds_authorized_flashing_light() -> None:
client = StructuredSceneClient()
client.structured_answers.put(
json.dumps(
{"requests": [], "ambiguities": [], "confidence": 0.82},
ensure_ascii=False,
)
)
assistant = CodexAssistantService(client)
template = _scene_template(
template_id="romantic",
name="浪漫模式",
aliases=["约会模式"],
)
observed: list[dict[str, object]] = []
def execute(
_instruction: str,
_progress: Callable[[dict[str, object]], None],
assistant_plan: Mapping[str, object],
) -> dict[str, object]:
observed.append(deepcopy(dict(assistant_plan)))
return _preview_result("浪漫模式")
result, _events = _invoke(
assistant,
"创建浪漫模式",
execute,
conversation_id="conv-romantic-required-flashing-light",
catalog=_catalog_with_templates(template),
scene_context=_scene_context_with_flashing_light(),
)
assert result["success"] is True
assert observed[0]["requests"][:2] == template["plan"]["requests"]
assert _peripheral_refs(observed[0]) == [
"ir:flash-open-000000000001",
"ir:flash-red-0000000000002",
]
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
def test_mid_autumn_cloud_composition_receives_all_abilities_and_adds_flashing_light() -> None:
client = StructuredSceneClient()
assistant = CodexAssistantService(client)
observed: list[dict[str, object]] = []
scene_context = _scene_context_with_flashing_light()
def execute(
_instruction: str,
_progress: Callable[[dict[str, object]], None],
assistant_plan: Mapping[str, object],
) -> dict[str, object]:
observed.append(deepcopy(dict(assistant_plan)))
return _preview_result("中秋欢快模式")
result, _events = _invoke(
assistant,
"创建中秋欢快模式",
execute,
conversation_id="conv-mid-autumn-cloud-peripherals",
catalog=_catalog_with_templates(_scene_template()),
scene_context=scene_context,
)
assert result["success"] is True
assert result["route"] == "scene_composer"
call_context = client.structured_calls[0]["application_context"]
assert len(call_context["capabilities"]) == len(scene_context["capabilities"])
assert len(call_context["peripheral_actions"]) == 3
assert call_context["scene_semantics"]["family"] == "festival_celebration"
assert call_context["scene_semantics"]["peripheral_policy"][
"required_refs"
] == [
"ir:flash-open-000000000001",
"ir:flash-red-0000000000002",
]
assert "as many relevant" in client.thread_instructions[0]
assert _peripheral_refs(observed[0]) == [
"ir:flash-open-000000000001",
"ir:flash-red-0000000000002",
]
def test_scene_template_miss_falls_through_to_structured_scene_composer() -> None:
client = StructuredSceneClient()
assistant = CodexAssistantService(client)
@@ -517,6 +725,44 @@ def test_scene_template_miss_falls_through_to_structured_scene_composer() -> Non
assert events[0]["engine"] == "codex-app-server-scene-composer"
def test_learning_mode_uses_grounded_free_composition_without_device_names() -> None:
client = StructuredSceneClient()
assistant = CodexAssistantService(client)
observed: list[dict[str, object]] = []
def execute(
instruction: str,
_progress: Callable[[dict[str, object]], None],
assistant_plan: Mapping[str, object],
) -> dict[str, object]:
assert instruction == "创建学习模式"
observed.append(deepcopy(dict(assistant_plan)))
return _preview_result("学习模式")
result, _events = _invoke(
assistant,
"创建学习模式",
execute,
conversation_id="conv-learning-mode-free-composition",
catalog=_catalog_with_templates(_scene_template()),
scene_context=_scene_context(),
)
assert result["success"] is True
assert result["route"] == "scene_composer"
assert len(client.structured_calls) == 1
context = client.structured_calls[0]["application_context"]
semantics = context["scene_semantics"]
assert semantics["family"] == "focused_learning"
assert semantics["grounded"] is True
assert "climate.set_temperature" in semantics["preferred_capability_ids"]
output_schema = client.structured_calls[0]["output_schema"]
assert output_schema["properties"]["requests"]["minItems"] == 1
assert output_schema["properties"]["ambiguities"]["maxItems"] == 0
assert "学习/自习/专注" in client.thread_instructions[0]
assert observed and observed[0]["requests"]
def test_registered_mode_match_takes_precedence_over_same_named_template() -> None:
client = StructuredSceneClient()
assistant = CodexAssistantService(client)
@@ -856,6 +1102,18 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
"unresolved_clauses": ["构建一个星夜模式"],
"cloud_input_utterance": "构建一个星夜模式",
}
expected_semantics = scene_semantic_guidance("星夜模式")
expected_semantics["peripheral_policy"] = {
"available_action_count": 0,
"required_refs": [],
"selection_goal": (
"Include as many semantically relevant scene-safe peripheral devices "
"as are useful; normally choose activation plus at most one stable "
"state action per device."
),
"required_reason": "No peripheral action is mandatory for this name.",
}
expected_context["scene_semantics"] = expected_semantics
assert call["application_context"] == expected_context
assert call["output_schema"]["type"] == "object"
assert call["output_schema"]["additionalProperties"] is False
+149
View File
@@ -11,6 +11,8 @@ from cognitive_agent.application.assistant_capabilities import AssistantCapabili
from cognitive_agent.application.scene_composer import (
build_scene_output_schema,
normalize_scene_output,
scene_required_peripheral_refs,
scene_semantic_guidance,
)
from cognitive_agent.application.seat_service import SeatDemoService
@@ -352,3 +354,150 @@ def test_production_scene_schema_remains_below_app_server_limit() -> None:
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
assert len(encoded) < 64 * 1024
def test_schema_and_normalizer_accept_only_opaque_peripheral_refs() -> None:
context = _context()
context["peripheral_actions"] = [
{
"ref": "ir:0123456789abcdef01234567",
"device_label": "氛围灯带",
"action_label": "设置为红色",
}
]
schema = build_scene_output_schema(context)
raw = {
"requests": [{"capability_ref": "ir:0123456789abcdef01234567"}],
"ambiguities": [],
"confidence": 0.91,
}
assert Draft202012Validator(schema).is_valid(raw)
assert not Draft202012Validator(schema).is_valid(
{
**raw,
"requests": [
{
"capability_ref": "ir:invented",
"slot": 7,
"card_uid": "2CC8B103",
}
],
}
)
normalized = normalize_scene_output(raw, context)
assert normalized["requests"] == [
{
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "ir:0123456789abcdef01234567",
"operation": "emit",
"duration_ms": None,
"parameters": [
{
"name": "capability_ref",
"value": "ir:0123456789abcdef01234567",
}
],
"reason": (
"Scene Composer selected a server-issued learned-device reference; "
"runtime binding resolution is still required."
),
}
]
def test_schema_remains_bounded_with_full_peripheral_catalog() -> None:
context = _context()
context["peripheral_actions"] = [
{"ref": f"ir:{index:024x}"}
for index in range(248)
]
schema = build_scene_output_schema(context)
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode()
assert len(encoded) < 64 * 1024
peripheral_branch = schema["properties"]["requests"]["items"]["anyOf"][-1]
assert len(
peripheral_branch["properties"]["capability_ref"]["enum"]
) == 248
def test_learning_mode_is_grounded_as_free_semantic_composition() -> None:
context = _context()
guidance = scene_semantic_guidance("学习模式")
context["scene_semantics"] = guidance
schema = build_scene_output_schema(context)
validator = Draft202012Validator(schema)
valid = _output(
_request(
"climate.set_temperature",
"vehicle",
"temperature_c",
24,
)
)
assert guidance["grounded"] is True
assert guidance["family"] == "focused_learning"
assert scene_semantic_guidance("灯光主题模式")["family"] == (
"lighting_ambience"
)
assert "interior_light.set_reading_power" in guidance[
"preferred_capability_ids"
]
assert schema["properties"]["requests"]["minItems"] == 1
assert schema["properties"]["ambiguities"]["maxItems"] == 0
assert validator.is_valid(valid)
assert not validator.is_valid(
{"requests": [], "ambiguities": [], "confidence": 0.4}
)
assert not validator.is_valid(
{**valid, "ambiguities": ["不知道学习指什么"]}
)
with pytest.raises(ValueError, match="at least one request"):
normalize_scene_output(
{"requests": [], "ambiguities": [], "confidence": 0.4},
context,
)
@pytest.mark.parametrize("mode_name", ("浪漫模式", "中秋欢快模式"))
def test_romantic_and_mid_autumn_require_flashing_light_actions(
mode_name: str,
) -> None:
context = _context()
context["peripheral_actions"] = [
{
"ref": "ir:flash-open",
"device_label": "闪烁灯",
"object_names": ["闪烁灯", "闪光灯"],
"action_label": "打开",
"action_names": ["打开", "开启"],
},
{
"ref": "ir:flash-red",
"device_label": "闪烁灯",
"object_names": ["闪烁灯", "闪光灯"],
"action_label": "切换为红色",
"action_names": ["切换为红色", "设置为红色"],
},
{
"ref": "ir:flash-close",
"device_label": "闪烁灯",
"object_names": ["闪烁灯", "闪光灯"],
"action_label": "关闭",
"action_names": ["关闭", "熄灭"],
},
]
required = scene_required_peripheral_refs(mode_name, context)
semantics = scene_semantic_guidance(mode_name)
semantics["peripheral_policy"] = {"required_refs": required}
context["scene_semantics"] = semantics
schema = build_scene_output_schema(context)
assert required == ["ir:flash-open", "ir:flash-red"]
assert schema["properties"]["requests"]["maxItems"] == 10
if "中秋" in mode_name:
assert semantics["family"] == "festival_celebration"
+80
View File
@@ -106,6 +106,19 @@ class SelfExtensionTests(unittest.TestCase):
)
self.assertEqual(synced["count"], 1)
self.assertRegex(synced["catalog_revision"], r"^[0-9a-f]{16}$")
self.assertEqual(
synced["catalog_revision"], service.peripheral_catalog_revision()
)
with self.assertRaisesRegex(GatewayError, "其他会话更新"):
service.sync_peripheral_catalog(
[],
expected_revision="stale-revision",
)
self.assertEqual(
[item["id"] for item in service.list_peripheral_capabilities()],
["toy-001-light-on"],
)
with closing(sqlite3.connect(database_path)) as connection, connection:
connection.execute(
"UPDATE infrared_slots SET device_id = '', "
@@ -696,6 +709,73 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(changed["device_id"], "toy-light-v2")
self.assertEqual(service.list_peripheral_capabilities(), [])
def test_nfc_card_can_be_reassigned_from_existing_to_new_device(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
service.save_infrared_slot(
1,
{
"device_id": "old-light",
"object_names": ["旧闪烁灯"],
"action_names": ["打开"],
"source": "tcp",
},
)
service.save_infrared_slot(
21,
{
"device_id": "new-light",
"object_names": ["新灯带"],
"action_names": ["打开"],
"source": "tcp",
},
)
initial = service.sync_peripheral_catalog(
[
{
"id": "ir-slot:old-light:1",
"status": "learned",
"enabled": True,
"device_id": "old-light",
"device_name": "旧闪烁灯",
"object_names": ["旧闪烁灯"],
"card_uid": "E113B203",
"action": "打开",
"action_names": ["打开"],
"slot": 1,
}
]
)
rebound = service.sync_peripheral_catalog(
[
{
"id": "ir-slot:new-light:21",
"status": "learned",
"enabled": True,
"device_id": "new-light",
"device_name": "新灯带",
"object_names": ["新灯带"],
"card_uid": "E113B203",
"action": "打开",
"action_names": ["打开"],
"slot": 21,
}
],
expected_revision=initial["catalog_revision"],
)
self.assertEqual(rebound["count"], 1)
self.assertEqual(
[
(item["device_id"], item["slot"], item["card_uid"])
for item in service.list_peripheral_capabilities()
],
[("new-light", 21, "E113B203")],
)
def test_new_structured_slot_is_parseable_before_nfc_authorization(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
@@ -0,0 +1,86 @@
from __future__ import annotations
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
SOURCE_PATH = REPOSITORY_ROOT / "apps" / "web-console" / "self-extension.js"
def _function_source(source: str, signature: str, next_signature: str) -> str:
start = source.index(signature)
end = source.index(next_signature, start)
return source[start:end]
def test_nfc_binding_waits_for_durable_catalog_before_success() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
bind = _function_source(
source,
" async function bindCurrentNfcToExisting() {",
"\n async function unbindCurrentNfc()",
)
persistence = bind.index("await bindCardToDevice(device, cardUid);")
success = bind.index("NFC 已关联设备 ID")
assert persistence < success
assert "NFC 设备授权保存失败" in bind
assert "void bindCardToDevice" not in bind
def test_peripheral_catalog_sync_is_serialized_and_tracks_backend_revision() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
sync = _function_source(
source,
" async function syncPeripheralCatalog(",
"\n async function bindCardToDevice",
)
assert "peripheralCatalogSyncQueue" in sync
assert "catalog_revision: peripheralCatalogRevision" in sync
assert "result.catalog_revision" in sync
assert "if (throwOnError) throw error;" in sync
def test_bound_card_can_be_reassigned_to_a_new_device() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
render = _function_source(
source,
" function renderNfcDialog(",
"\n function buildUnifiedNfcDialog",
)
bind = _function_source(
source,
" async function bindCurrentNfcToExisting() {",
"\n async function unbindCurrentNfc()",
)
assert "alternativeDeviceGroups" in render
assert "item.deviceId !== linkedDevice.id" in render
assert "state.devices.find((device) => device.id === item.deviceId)?.cardUid" in render
assert "data-nfc-bind-existing" in render
assert "将当前 NFC 改绑到新设备 ID" in render
assert "const previousDevice = linkedDeviceForCard(cardUid);" in bind
assert "NFC 已从设备 ID" in bind
assert "if (!capabilities.length)" in bind
def test_device_card_surfaces_extension_toy_mqtt_delivery() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
apply_toy = _function_source(
source,
" function applyExtensionToySnapshot(",
"\n function applyNfcSnapshot",
)
apply_nfc = _function_source(
source,
" function applyNfcSnapshot(",
"\n async function refreshNfcStatus",
)
assert 'state === "burst_complete"' in apply_toy
assert 'state === "burst_failed"' in apply_toy
assert "burst_published_count" in apply_toy
assert "extensionToySnapshot.last_error" in apply_toy
assert "applyExtensionToySnapshot(value.extension_toy" in apply_nfc
assert "正在准备 extension/toy 消息" in apply_nfc
+1 -1
View File
@@ -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-v324-firmware-notification"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"' in service_worker
assert "unpkg.com" not in html
assert "cdn.jsdelivr.net" not in html