diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 2242687..e88e820 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -1168,6 +1168,8 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): diagnostics={"selection_failure": "target_not_visible"}, ) + self._validate_size_candidate_dimension(dimension) + available_values = tuple( item for item in dimension.values if item.available ) @@ -1213,6 +1215,39 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): ) raise PddPurchaseSpecResolutionRequired(snapshot) + @staticmethod + def _validate_size_candidate_dimension(dimension: SpecDimension) -> None: + """候选进入快照前满足 #254 的数量、文字和维度边界。""" + + def valid_text(value: str) -> bool: + return ( + 1 <= len(value) <= 191 + and not any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value) + ) + + texts = [item.text for item in dimension.values] + available_count = sum(item.available for item in dimension.values) + invalid_reason = "" + if dimension.key != "size" or not valid_text(dimension.name): + invalid_reason = "dimension_invalid" + elif len(set(texts)) != len(texts): + invalid_reason = "candidate_duplicated" + elif any(not valid_text(text) for text in texts): + invalid_reason = "candidate_text_invalid" + elif available_count > 100: + invalid_reason = "candidate_count_exceeded" + if invalid_reason: + raise PddPurchaseError( + "PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE", + "完整尺码候选不符合规格解析边界,已停止采购", + step="purchase_select_options", + diagnostics={ + "selection_failure": "target_not_visible", + "candidate_validation": invalid_reason, + "candidate_count": available_count, + }, + ) + def _collect_size_candidates(self, device: Any) -> Optional[SpecDimension]: """使用采集模块同一套第二规格遍历,不建立新连接也不保存 XML。""" diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index d618862..5e635d2 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -1537,7 +1537,7 @@ class PddCollectParserTest(unittest.TestCase): sleeper=lambda _seconds: None, ) - result = service._collect_size_dimension(device) + result = service.collect_second_dimension_candidates(device) self.assertIsNotNone(result) self.assertEqual(result.name, "套餐(16)") diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index 5df98a5..e23410c 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -1052,6 +1052,54 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): ) adapter.close() + def test_invalid_candidate_list_never_requests_remote_resolution(self): + for values, expected_reason in ( + ( + (DimensionValue("M", True), DimensionValue("M", False)), + "candidate_duplicated", + ), + ( + tuple( + DimensionValue(f"尺码{index}", True) + for index in range(101) + ), + "candidate_count_exceeded", + ), + ): + with self.subTest(expected_reason=expected_reason): + device = FakeDevice() + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService( + connector=lambda _serial: device + ), + sleeper=lambda _seconds: None, + select_color_fn=lambda *_args, **_kwargs: True, + select_size_fn=lambda *_args, **_kwargs: False, + size_candidate_collector=lambda _device, items=values: ( + SpecDimension("size", "尺码", items) + ), + ) + adapter.open_goods(GOODS_URL) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.select_options( + {"color": "黑色", "size": "不存在"} + ) + + self.assertNotIsInstance( + raised.exception, PddPurchaseSpecResolutionRequired + ) + self.assertEqual( + raised.exception.code, + "PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE", + ) + self.assertEqual( + raised.exception.diagnostics["candidate_validation"], + expected_reason, + ) + adapter.close() + def test_size_without_selected_color_never_requests_remote_resolution(self): device = FakeDevice() scans = [] diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index c62db67..e486a0c 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -432,7 +432,8 @@ PDD URL,包名和页面类型仍以最新控件树确认。 第二规格只读遍历。遍历完成后,Adapter 在内存中保留全部观察项及可用状态,并仅为 可购买项按页面顺序生成 `c1`、`c2` 等候选和 `spec-resolution-v1` 快照哈希。只有完整 遍历才能抛出 `PddPurchaseSpecResolutionRequired`;遍历不完整、页面丢失、没有可购买项, -或完整列表中仍存在目标的精确/繁简等价文字,都返回普通采购错误。该 Adapter 不调用 +候选重复、超过 100 条、文字或维度不合法,或完整列表中仍存在目标的精确/繁简等价文字, +都返回普通采购错误。该 Adapter 不调用 Admin,也不点击任何远端解析结果;网络请求和解析后复核属于后续工单。 部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的