@@ -9787,6 +9787,12 @@ async def test_smartclient_pipeline_keeps_exact_source_and_publishes_passive_evi
97879787 from custom_components.eybond_local.support import smartclient_learning
97889788
97899789 coordinator = self._RunnerCoordinator(ready=False)
9790+ # Real collector-only RuntimeSnapshot has a typed empty frame, not a
9791+ # missing telemetry attribute. Recognized cloud fields must not require
9792+ # a local driver (issue #23).
9793+ coordinator.effective_profile_name = ""
9794+ coordinator.effective_register_schema_name = ""
9795+ coordinator.data.telemetry = TypedTelemetryFrame(driver_key="", points=())
97909796 options = self._runner_options_flow(coordinator)
97919797 options._shadow_learning_state.update(
97929798 {"wizard_method": LEARNING_METHOD_READ_ONLY_EVIDENCE, "wizard_source": "smartclient"}
@@ -9804,10 +9810,61 @@ async def test_smartclient_pipeline_keeps_exact_source_and_publishes_passive_evi
98049810 self.assertTrue(coordinator.published)
98059811 self.assertEqual(options._shadow_learning_state["discovery"]["status"], "ok")
98069812 self.assertEqual(options._shadow_learning_state["cloud_metadata"]["source"], "smartclient")
9813+ self.assertNotIn("local_coverage", options._shadow_learning_state["cloud_metadata"])
9814+ self.assertGreater(
9815+ options._shadow_learning_state["cloud_metadata"]["semantic_report"]["read_candidate_count"], 0
9816+ )
98079817 self.assertNotIn("demo@example.com", str(coordinator.published))
98089818 self.assertNotIn("wizard_credentials", str(coordinator.published))
98099819 self.assertEqual(options._shadow_learning_state.get("overlay", {}), {})
98109820
9821+ def test_cloud_coverage_drops_stale_driver_report_without_local_driver(self) -> None:
9822+ from custom_components.eybond_local.flows.options.shadow_run import _metadata_with_local_coverage
9823+
9824+ evidence = {"source": "smartclient", "local_coverage": {"driver_key": "smg"}}
9825+ for telemetry in (None, TypedTelemetryFrame(driver_key="", points=())):
9826+ with self.subTest(telemetry=telemetry):
9827+ enriched = _metadata_with_local_coverage(evidence, telemetry)
9828+ self.assertNotIn("local_coverage", enriched)
9829+ self.assertEqual(enriched["source"], "smartclient")
9830+ self.assertIn("local_coverage", evidence)
9831+
9832+ async def test_cloud_learning_failure_exports_phase_without_exception_secrets(self) -> None:
9833+ for partial_result in (False, True):
9834+ with self.subTest(partial_result=partial_result):
9835+ coordinator = self._RunnerCoordinator(ready=False)
9836+ options = self._runner_options_flow(coordinator)
9837+ options._shadow_learning_state.update({
9838+ "wizard_method": LEARNING_METHOD_READ_ONLY_EVIDENCE,
9839+ "wizard_source": "smartclient",
9840+ "progress": {"stage": "building", "fraction": 0.82},
9841+ })
9842+ if partial_result:
9843+ options._shadow_learning_state["orchestration"] = {
9844+ "source": "smartclient", "metadata_only": True,
9845+ "metadata_evidence": {"telemetry_fields": [{"title": "PV Voltage"}]},
9846+ }
9847+ with (
9848+ patch.object(
9849+ options, "_async_execute_control_discovery",
9850+ new=AsyncMock(side_effect=ValueError("https://private.invalid/?password=cloud-secret")),
9851+ ),
9852+ self.assertLogs("custom_components.eybond_local.flows.options.shadow_run", level="ERROR") as logs,
9853+ ):
9854+ await options._async_run_control_discovery()
9855+ artifact = coordinator.published[-1]["orchestration"]
9856+ self.assertEqual(artifact["failure"], {
9857+ "reason": "control_discovery_failure_generic",
9858+ "learning_source": "smartclient", "stage": "building",
9859+ "exception_category": "ValueError",
9860+ })
9861+ if partial_result:
9862+ self.assertIn("metadata_evidence", artifact)
9863+ self.assertNotIn("private.invalid", str(coordinator.published) + str(logs.output))
9864+ self.assertNotIn("cloud-secret", str(coordinator.published) + str(logs.output))
9865+ self.assertEqual(coordinator.started, [])
9866+ self.assertEqual(coordinator.stopped, [])
9867+
98119868 async def test_dessmonitor_runner_records_typed_local_semantic_coverage(self) -> None:
98129869 coordinator = self._RunnerCoordinator(ready=False)
98139870 coordinator.data.telemetry = TypedTelemetryFrame(
0 commit comments