fix(client): bind revealed current-only price layout
This commit is contained in:
@@ -337,26 +337,22 @@ def _candidate_projection(nodes: list[Any]) -> tuple[tuple[str, ...], ...]:
|
|||||||
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
|
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
|
||||||
"候选面板头部不唯一。",
|
"候选面板头部不唯一。",
|
||||||
)
|
)
|
||||||
|
if len([child for child in header.element if child.tag == "node"]) != 4:
|
||||||
|
raise SkuSelectionError("候选面板头部子节点数量漂移。")
|
||||||
outer = _one(
|
outer = _one(
|
||||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
||||||
"候选维度容器不唯一。",
|
"候选维度容器不唯一。",
|
||||||
)
|
)
|
||||||
price_row = _one(
|
price_content, price_frame = _candidate_price_container(nodes, header)
|
||||||
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")],
|
price_nodes = _candidate_price_nodes(nodes, header, surface, price_frame)
|
||||||
"候选价格行不唯一。",
|
|
||||||
)
|
|
||||||
current = _one(
|
|
||||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")],
|
|
||||||
"候选当前价不唯一。",
|
|
||||||
)
|
|
||||||
original = _one(
|
|
||||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")],
|
|
||||||
"候选原价不唯一。",
|
|
||||||
)
|
|
||||||
if _clickable_before(current, surface):
|
|
||||||
raise SkuSelectionError("候选当前价位于可点击内容祖先下。")
|
|
||||||
summary = _one(
|
summary = _one(
|
||||||
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")],
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_content
|
||||||
|
and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")
|
||||||
|
and len(node.element) == 0
|
||||||
|
],
|
||||||
"候选摘要不唯一。",
|
"候选摘要不唯一。",
|
||||||
)
|
)
|
||||||
color_region = _one(
|
color_region = _one(
|
||||||
@@ -414,7 +410,189 @@ def _candidate_projection(nodes: list[Any]) -> tuple[tuple[str, ...], ...]:
|
|||||||
node.element.get("selected", ""),
|
node.element.get("selected", ""),
|
||||||
node.element.get("clickable", ""),
|
node.element.get("clickable", ""),
|
||||||
)
|
)
|
||||||
for node in (surface, header, outer, price_row, current, original, summary, color_region, color, size_label, size_options, s_action, m_action, dangerous[0])
|
for node in (
|
||||||
|
surface,
|
||||||
|
header,
|
||||||
|
outer,
|
||||||
|
*price_nodes,
|
||||||
|
summary,
|
||||||
|
color_region,
|
||||||
|
color,
|
||||||
|
size_label,
|
||||||
|
size_options,
|
||||||
|
s_action,
|
||||||
|
m_action,
|
||||||
|
dangerous[0],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_price_nodes(
|
||||||
|
nodes: list[Any],
|
||||||
|
header: Any,
|
||||||
|
surface: Any,
|
||||||
|
price_frame: Any,
|
||||||
|
) -> tuple[Any, Any, Any]:
|
||||||
|
matches: list[tuple[Any, Any, Any]] = []
|
||||||
|
for matcher in (_candidate_dual_price_nodes, _candidate_current_only_price_nodes):
|
||||||
|
try:
|
||||||
|
match = matcher(nodes, header, surface, price_frame)
|
||||||
|
except SkuSelectionError:
|
||||||
|
continue
|
||||||
|
matches.append(match)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise SkuSelectionError("候选价格布局不符合唯一完整取证变体。")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_dual_price_nodes(
|
||||||
|
nodes: list[Any],
|
||||||
|
header: Any,
|
||||||
|
surface: Any,
|
||||||
|
price_frame: Any,
|
||||||
|
) -> tuple[Any, Any, Any]:
|
||||||
|
price_row = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_frame
|
||||||
|
and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")
|
||||||
|
],
|
||||||
|
"候选双价格行不唯一。",
|
||||||
|
)
|
||||||
|
if len([child for child in price_row.element if child.tag == "node"]) != 2:
|
||||||
|
raise SkuSelectionError("候选双价格行子节点数量漂移。")
|
||||||
|
current = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_row
|
||||||
|
and _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")
|
||||||
|
and len(node.element) == 0
|
||||||
|
],
|
||||||
|
"候选双价格当前价不唯一。",
|
||||||
|
)
|
||||||
|
original = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_row
|
||||||
|
and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")
|
||||||
|
and len(node.element) == 0
|
||||||
|
],
|
||||||
|
"候选双价格原价不唯一。",
|
||||||
|
)
|
||||||
|
if _clickable_before(current, surface):
|
||||||
|
raise SkuSelectionError("候选双价格当前价位于可点击内容祖先下。")
|
||||||
|
if any(
|
||||||
|
_candidate_current_only_fragment(node)
|
||||||
|
for node in nodes
|
||||||
|
if _descendant(node, header)
|
||||||
|
):
|
||||||
|
raise SkuSelectionError("候选双价格布局混入单价格残片。")
|
||||||
|
return price_row, current, original
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_current_only_price_nodes(
|
||||||
|
nodes: list[Any],
|
||||||
|
header: Any,
|
||||||
|
surface: Any,
|
||||||
|
price_frame: Any,
|
||||||
|
) -> tuple[Any, Any, Any]:
|
||||||
|
price_row = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_frame
|
||||||
|
and _exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")
|
||||||
|
],
|
||||||
|
"候选单价格行父子关系漂移。",
|
||||||
|
)
|
||||||
|
if len([child for child in price_row.element if child.tag == "node"]) != 1:
|
||||||
|
raise SkuSelectionError("候选单价格行子节点数量漂移。")
|
||||||
|
current = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_row
|
||||||
|
and _exact_readonly_text(node, "限1件 ¥12.88 ", "[396,503][675,570]")
|
||||||
|
and len(node.element) == 0
|
||||||
|
],
|
||||||
|
"候选单价格当前价不唯一。",
|
||||||
|
)
|
||||||
|
if any(node.text in {"¥29.88", "券前¥29.88"} for node in nodes):
|
||||||
|
raise SkuSelectionError("候选单价格变体出现原价角色。")
|
||||||
|
if any(
|
||||||
|
_candidate_dual_fragment(node)
|
||||||
|
for node in nodes
|
||||||
|
if _descendant(node, header)
|
||||||
|
):
|
||||||
|
raise SkuSelectionError("候选单价格布局混入双价格残片。")
|
||||||
|
if _clickable_before(current, surface):
|
||||||
|
raise SkuSelectionError("候选单价格当前价位于可点击内容祖先下。")
|
||||||
|
return price_frame, price_row, current
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_price_container(nodes: list[Any], header: Any) -> tuple[Any, Any]:
|
||||||
|
content_frame = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is header
|
||||||
|
and _exact_inert(node, "android.widget.FrameLayout", "[0,474][1080,863]")
|
||||||
|
],
|
||||||
|
"候选价格内容外框不唯一。",
|
||||||
|
)
|
||||||
|
if len([child for child in content_frame.element if child.tag == "node"]) != 1:
|
||||||
|
raise SkuSelectionError("候选价格内容外框子节点数量漂移。")
|
||||||
|
relative = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is content_frame
|
||||||
|
and _exact_inert(node, "android.widget.RelativeLayout", "[0,474][1080,863]")
|
||||||
|
],
|
||||||
|
"候选价格 RelativeLayout 父子关系漂移。",
|
||||||
|
)
|
||||||
|
if len([child for child in relative.element if child.tag == "node"]) != 1:
|
||||||
|
raise SkuSelectionError("候选价格 RelativeLayout 子节点数量漂移。")
|
||||||
|
content = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is relative
|
||||||
|
and _exact_inert(node, "android.view.ViewGroup", "[0,474][1080,863]")
|
||||||
|
],
|
||||||
|
"候选价格内容 ViewGroup 父子关系漂移。",
|
||||||
|
)
|
||||||
|
if len([child for child in content.element if child.tag == "node"]) != 8:
|
||||||
|
raise SkuSelectionError("候选价格内容 ViewGroup 子节点数量漂移。")
|
||||||
|
price_frame = _one(
|
||||||
|
[
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is content
|
||||||
|
and _exact_inert(node, "android.widget.FrameLayout", "[396,498][1053,570]")
|
||||||
|
],
|
||||||
|
"候选价格 FrameLayout 父子关系漂移。",
|
||||||
|
)
|
||||||
|
if len([child for child in price_frame.element if child.tag == "node"]) != 1:
|
||||||
|
raise SkuSelectionError("候选价格 FrameLayout 子节点数量漂移。")
|
||||||
|
return content, price_frame
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_current_only_fragment(node: Any) -> bool:
|
||||||
|
return (
|
||||||
|
_exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")
|
||||||
|
or _exact_readonly_text(node, "限1件 ¥12.88 ", "[396,503][675,570]")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_dual_fragment(node: Any) -> bool:
|
||||||
|
return (
|
||||||
|
_exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")
|
||||||
|
or _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")
|
||||||
|
or _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import cmbuyer_client.pdd.sku_reveal_spike as reveal_module
|
|||||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||||
from cmbuyer_client.pdd.sku_reveal_spike import (
|
from cmbuyer_client.pdd.sku_reveal_spike import (
|
||||||
_annotate_reveal_failure,
|
_annotate_reveal_failure,
|
||||||
|
_candidate_projection,
|
||||||
_RevealEvidenceAdapter,
|
_RevealEvidenceAdapter,
|
||||||
safe_reveal_failure_stage,
|
safe_reveal_failure_stage,
|
||||||
SkuRevealSpikeCapturer,
|
SkuRevealSpikeCapturer,
|
||||||
@@ -40,6 +41,22 @@ _CURRENT_ONLY_COLOR = (_FIXTURES / "sku_panel_color_selected_size_hidden_current
|
|||||||
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
|
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _inert_node(class_name: str, bounds: str) -> ElementTree.Element:
|
||||||
|
return ElementTree.Element(
|
||||||
|
"node",
|
||||||
|
{
|
||||||
|
"package": "com.xunmeng.pinduoduo",
|
||||||
|
"class": class_name,
|
||||||
|
"bounds": bounds,
|
||||||
|
"clickable": "false",
|
||||||
|
"enabled": "true",
|
||||||
|
"visible-to-user": "true",
|
||||||
|
"selected": "false",
|
||||||
|
"scrollable": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _candidate_unselected() -> str:
|
def _candidate_unselected() -> str:
|
||||||
root = ElementTree.fromstring(_M_SELECTED)
|
root = ElementTree.fromstring(_M_SELECTED)
|
||||||
parents = {child: parent for parent in root.iter() for child in parent}
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
@@ -52,6 +69,38 @@ def _candidate_unselected() -> str:
|
|||||||
for child in list(wrapper):
|
for child in list(wrapper):
|
||||||
if child.get("class") == "android.view.View":
|
if child.get("class") == "android.view.View":
|
||||||
wrapper.remove(child)
|
wrapper.remove(child)
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
header = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.LinearLayout"
|
||||||
|
and node.get("bounds") == "[0,366][1080,1000]"
|
||||||
|
)
|
||||||
|
price_row = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.LinearLayout"
|
||||||
|
and node.get("bounds") == "[396,498][895,570]"
|
||||||
|
)
|
||||||
|
summary = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.TextView"
|
||||||
|
and node.get("bounds") == "[396,654][1053,716]"
|
||||||
|
)
|
||||||
|
parents[price_row].remove(price_row)
|
||||||
|
parents[summary].remove(summary)
|
||||||
|
content_frame = _inert_node("android.widget.FrameLayout", "[0,474][1080,863]")
|
||||||
|
relative = _inert_node("android.widget.RelativeLayout", "[0,474][1080,863]")
|
||||||
|
content = _inert_node("android.view.ViewGroup", "[0,474][1080,863]")
|
||||||
|
price_frame = _inert_node("android.widget.FrameLayout", "[396,498][1053,570]")
|
||||||
|
header.append(content_frame)
|
||||||
|
content_frame.append(relative)
|
||||||
|
relative.append(content)
|
||||||
|
content.append(price_frame)
|
||||||
|
price_frame.append(price_row)
|
||||||
|
content.append(summary)
|
||||||
|
for index in range(6):
|
||||||
|
content.append(_inert_node("android.view.View", f"[{index},474][{index + 1},475]"))
|
||||||
|
for index in range(3):
|
||||||
|
header.append(_inert_node("android.view.View", f"[{index},366][{index + 1},367]"))
|
||||||
action_root = next(
|
action_root = next(
|
||||||
node
|
node
|
||||||
for node in root.iter("node")
|
for node in root.iter("node")
|
||||||
@@ -110,6 +159,196 @@ def _candidate_unselected() -> str:
|
|||||||
return ElementTree.tostring(root, encoding="unicode")
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_current_only() -> str:
|
||||||
|
root = ElementTree.fromstring(_candidate_unselected())
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
dual_row = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.LinearLayout"
|
||||||
|
and node.get("bounds") == "[396,498][895,570]"
|
||||||
|
)
|
||||||
|
price_frame = parents[dual_row]
|
||||||
|
price_frame.remove(dual_row)
|
||||||
|
row = _inert_node("android.widget.LinearLayout", "[396,498][693,570]")
|
||||||
|
current = _inert_node("android.widget.TextView", "[396,503][675,570]")
|
||||||
|
current.set("text", "限1件 ¥12.88 ")
|
||||||
|
price_frame.append(row)
|
||||||
|
row.append(current)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_price_drift(variant: str, kind: str) -> str:
|
||||||
|
root = ElementTree.fromstring(
|
||||||
|
_candidate_unselected() if variant == "dual" else _candidate_current_only()
|
||||||
|
)
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
if variant == "dual":
|
||||||
|
row = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.LinearLayout"
|
||||||
|
and node.get("bounds") == "[396,498][895,570]"
|
||||||
|
)
|
||||||
|
current = next(node for node in row if node.get("text") == "快卖完 ¥12.88")
|
||||||
|
original = next(node for node in row if node.get("text") == "¥29.88")
|
||||||
|
if kind == "hybrid":
|
||||||
|
current.set("text", "限1件 ¥12.88 ")
|
||||||
|
current.set("bounds", "[396,503][675,570]")
|
||||||
|
row.remove(original)
|
||||||
|
elif kind == "duplicate":
|
||||||
|
row.append(ElementTree.fromstring(ElementTree.tostring(current, encoding="unicode")))
|
||||||
|
elif kind == "unknown":
|
||||||
|
current.set("text", "未知候选价格")
|
||||||
|
elif kind == "unknown_child":
|
||||||
|
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知子节点"}))
|
||||||
|
elif kind == "parent_drift":
|
||||||
|
row.remove(current)
|
||||||
|
parents[row].append(current)
|
||||||
|
else:
|
||||||
|
raise AssertionError(kind)
|
||||||
|
elif variant == "current_only":
|
||||||
|
frame = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.FrameLayout"
|
||||||
|
and node.get("bounds") == "[396,498][1053,570]"
|
||||||
|
)
|
||||||
|
row = next(node for node in frame if node.get("bounds") == "[396,498][693,570]")
|
||||||
|
current = next(node for node in row if node.get("text") == "限1件 ¥12.88 ")
|
||||||
|
if kind == "hybrid":
|
||||||
|
current.set("text", "快卖完 ¥12.88")
|
||||||
|
current.set("bounds", "[396,503][712,570]")
|
||||||
|
original = ElementTree.Element(
|
||||||
|
"node",
|
||||||
|
dict(current.attrib)
|
||||||
|
| {
|
||||||
|
"text": "¥29.88",
|
||||||
|
"bounds": "[730,503][895,570]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row.append(original)
|
||||||
|
elif kind == "duplicate":
|
||||||
|
parents[frame].append(
|
||||||
|
ElementTree.fromstring(ElementTree.tostring(frame, encoding="unicode"))
|
||||||
|
)
|
||||||
|
elif kind == "unknown":
|
||||||
|
current.set("bounds", "[396,502][675,570]")
|
||||||
|
elif kind == "unknown_child":
|
||||||
|
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知子节点"}))
|
||||||
|
elif kind in {"original", "coupon_original"}:
|
||||||
|
original = _inert_node("android.widget.TextView", "[730,503][895,570]")
|
||||||
|
original.set("text", "¥29.88" if kind == "original" else "券前¥29.88")
|
||||||
|
# 放在候选 header 之外,证明单价格分支检查整棵树,而非只看价格容器。
|
||||||
|
root.append(original)
|
||||||
|
elif kind == "parent_drift":
|
||||||
|
frame.remove(row)
|
||||||
|
parents[frame].append(row)
|
||||||
|
else:
|
||||||
|
raise AssertionError(kind)
|
||||||
|
else:
|
||||||
|
raise AssertionError(variant)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_chain_drift(role: str, kind: str) -> str:
|
||||||
|
root = ElementTree.fromstring(_candidate_current_only())
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
header = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.LinearLayout"
|
||||||
|
and node.get("bounds") == "[0,366][1080,1000]"
|
||||||
|
)
|
||||||
|
surface = parents[header]
|
||||||
|
content_frame = next(
|
||||||
|
node for node in header
|
||||||
|
if node.get("class") == "android.widget.FrameLayout"
|
||||||
|
and node.get("bounds") == "[0,474][1080,863]"
|
||||||
|
)
|
||||||
|
relative = next(iter(content_frame))
|
||||||
|
content = next(iter(relative))
|
||||||
|
price_frame = next(
|
||||||
|
node for node in content
|
||||||
|
if node.get("class") == "android.widget.FrameLayout"
|
||||||
|
and node.get("bounds") == "[396,498][1053,570]"
|
||||||
|
)
|
||||||
|
row = next(iter(price_frame))
|
||||||
|
current = next(iter(row))
|
||||||
|
summary = next(
|
||||||
|
node for node in content
|
||||||
|
if node.get("class") == "android.widget.TextView"
|
||||||
|
and node.get("bounds") == "[396,654][1053,716]"
|
||||||
|
)
|
||||||
|
roles = {
|
||||||
|
"header": header,
|
||||||
|
"content_frame": content_frame,
|
||||||
|
"relative": relative,
|
||||||
|
"content": content,
|
||||||
|
"price_frame": price_frame,
|
||||||
|
"row": row,
|
||||||
|
"current": current,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
target = roles[role]
|
||||||
|
if kind == "attrs":
|
||||||
|
target.set("enabled", "false")
|
||||||
|
elif kind == "children":
|
||||||
|
target.append(_inert_node("android.view.View", "[1,1][2,2]"))
|
||||||
|
elif kind == "parent":
|
||||||
|
new_parents = {
|
||||||
|
"header": next(
|
||||||
|
node for node in surface
|
||||||
|
if node.get("class") == "androidx.recyclerview.widget.RecyclerView"
|
||||||
|
),
|
||||||
|
"content_frame": surface,
|
||||||
|
"relative": header,
|
||||||
|
"content": content_frame,
|
||||||
|
"price_frame": header,
|
||||||
|
"row": content,
|
||||||
|
"current": price_frame,
|
||||||
|
"summary": header,
|
||||||
|
}
|
||||||
|
parents[target].remove(target)
|
||||||
|
new_parents[role].append(target)
|
||||||
|
else:
|
||||||
|
raise AssertionError(kind)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_cross_variant_residue(variant: str, kind: str) -> str:
|
||||||
|
root = ElementTree.fromstring(
|
||||||
|
_candidate_unselected() if variant == "dual" else _candidate_current_only()
|
||||||
|
)
|
||||||
|
content = next(
|
||||||
|
node for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.view.ViewGroup"
|
||||||
|
and node.get("bounds") == "[0,474][1080,863]"
|
||||||
|
)
|
||||||
|
filler = next(node for node in content if node.get("class") == "android.view.View")
|
||||||
|
content.remove(filler)
|
||||||
|
if variant == "dual":
|
||||||
|
row = _inert_node("android.widget.LinearLayout", "[396,498][693,570]")
|
||||||
|
current = _inert_node("android.widget.TextView", "[396,503][675,570]")
|
||||||
|
current.set("text", "限1件 ¥12.88 ")
|
||||||
|
row.append(current)
|
||||||
|
fragments = {"row": row, "current": current}
|
||||||
|
elif variant == "current_only":
|
||||||
|
row = _inert_node("android.widget.LinearLayout", "[396,498][895,570]")
|
||||||
|
current = _inert_node("android.widget.TextView", "[396,503][712,570]")
|
||||||
|
current.set("text", "快卖完 ¥12.88")
|
||||||
|
original = _inert_node("android.widget.TextView", "[730,503][895,570]")
|
||||||
|
original.set("text", "¥29.88")
|
||||||
|
row.extend((current, original))
|
||||||
|
fragments = {"row": row, "current": current, "original": original}
|
||||||
|
else:
|
||||||
|
raise AssertionError(variant)
|
||||||
|
if kind == "complete":
|
||||||
|
frame = _inert_node("android.widget.FrameLayout", "[396,498][1053,570]")
|
||||||
|
frame.append(row)
|
||||||
|
content.append(frame)
|
||||||
|
else:
|
||||||
|
# 只插入一段精确 opposing role,且维持 content 原 child-count,防止由计数检查掩盖残片检查。
|
||||||
|
content.append(ElementTree.fromstring(ElementTree.tostring(fragments[kind], encoding="unicode")))
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
def _candidate_with_color_unselected() -> str:
|
def _candidate_with_color_unselected() -> str:
|
||||||
root = ElementTree.fromstring(_candidate_unselected())
|
root = ElementTree.fromstring(_candidate_unselected())
|
||||||
target = next(
|
target = next(
|
||||||
@@ -290,6 +529,62 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
|||||||
self.assertNotIn("start", manifest)
|
self.assertNotIn("start", manifest)
|
||||||
self.assertNotIn("end", manifest)
|
self.assertNotIn("end", manifest)
|
||||||
|
|
||||||
|
def test_current_only_candidate_publishes_without_changing_other_candidate_rules(self) -> None:
|
||||||
|
device = _FakeDevice()
|
||||||
|
device.after_hierarchy = _candidate_current_only()
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
target = Path(directory) / "evidence"
|
||||||
|
result = self._capturer(device).capture(
|
||||||
|
"192.168.0.173:5555",
|
||||||
|
"937122477375",
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.output_directory, target)
|
||||||
|
self.assertEqual(len(_swipes(device)), 1)
|
||||||
|
self.assertTrue((target / "after" / "hierarchy.xml").is_file())
|
||||||
|
|
||||||
|
def test_candidate_price_variants_reject_hybrid_duplicate_unknown_and_parent_drift(self) -> None:
|
||||||
|
# 两个真实嵌套 variant 各自唯一命中;任意混搭、额外子节点或父子关系漂移均拒绝。
|
||||||
|
_candidate_projection(_parse_nodes(_candidate_unselected()))
|
||||||
|
_candidate_projection(_parse_nodes(_candidate_current_only()))
|
||||||
|
for variant in ("dual", "current_only"):
|
||||||
|
kinds = ["hybrid", "duplicate", "unknown", "unknown_child", "parent_drift"]
|
||||||
|
if variant == "current_only":
|
||||||
|
kinds.extend(("original", "coupon_original"))
|
||||||
|
for kind in kinds:
|
||||||
|
with self.subTest(variant=variant, kind=kind), self.assertRaises(SkuSelectionError):
|
||||||
|
_candidate_projection(
|
||||||
|
_parse_nodes(_candidate_price_drift(variant, kind))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_candidate_real_container_chain_rejects_every_parent_attribute_and_child_drift(self) -> None:
|
||||||
|
roles = (
|
||||||
|
"header",
|
||||||
|
"content_frame",
|
||||||
|
"relative",
|
||||||
|
"content",
|
||||||
|
"price_frame",
|
||||||
|
"row",
|
||||||
|
"current",
|
||||||
|
"summary",
|
||||||
|
)
|
||||||
|
for role in roles:
|
||||||
|
for kind in ("parent", "attrs", "children"):
|
||||||
|
with self.subTest(role=role, kind=kind), self.assertRaises(SkuSelectionError):
|
||||||
|
_candidate_projection(_parse_nodes(_candidate_chain_drift(role, kind)))
|
||||||
|
|
||||||
|
def test_candidate_price_variants_reject_complete_and_partial_opposing_shapes(self) -> None:
|
||||||
|
for variant, kinds in (
|
||||||
|
("dual", ("complete", "row", "current")),
|
||||||
|
("current_only", ("complete", "row", "current", "original")),
|
||||||
|
):
|
||||||
|
for kind in kinds:
|
||||||
|
with self.subTest(variant=variant, kind=kind), self.assertRaises(SkuSelectionError):
|
||||||
|
_candidate_projection(
|
||||||
|
_parse_nodes(_candidate_cross_variant_residue(variant, kind))
|
||||||
|
)
|
||||||
|
|
||||||
def test_current_only_empty_and_color_only_are_accepted_before_reveal(self) -> None:
|
def test_current_only_empty_and_color_only_are_accepted_before_reveal(self) -> None:
|
||||||
device = _FakeDevice()
|
device = _FakeDevice()
|
||||||
device.empty_hierarchy = _CURRENT_ONLY_EMPTY
|
device.empty_hierarchy = _CURRENT_ONLY_EMPTY
|
||||||
@@ -429,6 +724,32 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
|||||||
self.assertEqual(_swipes(device), [])
|
self.assertEqual(_swipes(device), [])
|
||||||
self.assertEqual(safe_reveal_failure_stage(raised.exception), "reveal_precondition")
|
self.assertEqual(safe_reveal_failure_stage(raised.exception), "reveal_precondition")
|
||||||
|
|
||||||
|
def test_after_screenshot_current_only_to_dual_drift_is_not_published(self) -> None:
|
||||||
|
device = _FakeDevice()
|
||||||
|
current_only = _candidate_current_only()
|
||||||
|
device.after_hierarchy = current_only
|
||||||
|
original_call = device.jsonrpc_call
|
||||||
|
|
||||||
|
def drift(method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
value = original_call(method, params, timeout)
|
||||||
|
if method == "takeScreenshot" and device.hierarchy == current_only:
|
||||||
|
device.hierarchy = _candidate_unselected()
|
||||||
|
return value
|
||||||
|
|
||||||
|
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
target = Path(directory) / "evidence"
|
||||||
|
with self.assertRaises(SkuRevealSpikeError) as raised:
|
||||||
|
self._capturer(device).capture(
|
||||||
|
"192.168.0.173:5555",
|
||||||
|
"937122477375",
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
self.assertEqual(safe_reveal_failure_stage(raised.exception), "reveal_after")
|
||||||
|
self.assertEqual(len(_swipes(device)), 1)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||||
|
|
||||||
def test_reveal_failure_stages_bind_attempt_candidate_after_and_publish(self) -> None:
|
def test_reveal_failure_stages_bind_attempt_candidate_after_and_publish(self) -> None:
|
||||||
device = _FakeDevice()
|
device = _FakeDevice()
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -25,7 +25,7 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T07:31:35Z sha256=e64497dd77f43867cbe347239cbf3a4f7d755aee0132753723a821d422dc9d63 -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T07:53:26Z sha256=e823207a1f2cee7e982a098b25af4d4b76ddba688c299a18fe1560f4d3efbbd7 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
||||||
@@ -306,6 +306,10 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
### 2026-08-05T07:31:25Z · ila
|
### 2026-08-05T07:31:25Z · ila
|
||||||
|
|
||||||
2026-08-05 项目所有者完成人工确认:真实 reveal 后目标颜色已选中,未选中任何尺码。结合失败后保持不动采集的截图/XML,S/M 已显示,页面仍是规格面板而非订单确认页;因此唯一固定 reveal 动作已证明不会误选尺码,也未触发提交区。实施方案收紧为:只在独立 spike 的 rolled candidate 中新增与 raw 完全一致的 current-only 价格布局(Frame `[396,498][1053,570]` → 唯一 row `[396,498][693,570]` → 唯一 current `限1件 ¥12.88 ` `[396,503][675,570]`,原价 count=0);旧 dual candidate `快卖完 ¥12.88` + `¥29.88` 原样保留。两种布局必须分别完整唯一匹配,禁止 optional original、跨布局拼装、未知文字/坐标/父链/child count;候选其余颜色、摘要、S/M 未选、提交硬拒绝区判据不变。补 dual/current-only 双向混合、duplicate、unknown、parent drift 失败测试;不得改生产 Flow 的 reveal,不得新增数量、确认页、围栏、提交或支付能力。先提交本计划投影,再由唯一 client 写入者实现、独立审计、根代理重跑真实 raw 与完整客户端门禁。T-103 保持 DOING;代码通过后是否需要再次运行 spike 以形成原子 before/after 发布证据,仍以任务验收全集为准。
|
2026-08-05 项目所有者完成人工确认:真实 reveal 后目标颜色已选中,未选中任何尺码。结合失败后保持不动采集的截图/XML,S/M 已显示,页面仍是规格面板而非订单确认页;因此唯一固定 reveal 动作已证明不会误选尺码,也未触发提交区。实施方案收紧为:只在独立 spike 的 rolled candidate 中新增与 raw 完全一致的 current-only 价格布局(Frame `[396,498][1053,570]` → 唯一 row `[396,498][693,570]` → 唯一 current `限1件 ¥12.88 ` `[396,503][675,570]`,原价 count=0);旧 dual candidate `快卖完 ¥12.88` + `¥29.88` 原样保留。两种布局必须分别完整唯一匹配,禁止 optional original、跨布局拼装、未知文字/坐标/父链/child count;候选其余颜色、摘要、S/M 未选、提交硬拒绝区判据不变。补 dual/current-only 双向混合、duplicate、unknown、parent drift 失败测试;不得改生产 Flow 的 reveal,不得新增数量、确认页、围栏、提交或支付能力。先提交本计划投影,再由唯一 client 写入者实现、独立审计、根代理重跑真实 raw 与完整客户端门禁。T-103 保持 DOING;代码通过后是否需要再次运行 spike 以形成原子 before/after 发布证据,仍以任务验收全集为准。
|
||||||
|
|
||||||
|
### 2026-08-05T07:53:16Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-103 rolled current-only candidate 实现与双轮审计完成。唯一 client 写入者只修改 `sku_reveal_spike.py` 与对应测试:candidate 现在绑定两份真实 raw 共有的 exact header→Frame→Relative→ViewGroup→price Frame 链、各层 child count、summary direct parent;旧 dual row/current/original 与新 current-only row/current 是完整互斥 variant,原价绝非 optional,双向 complete/partial/hybrid/duplicate/unknown/parent drift 和已知 `¥29.88`/`券前¥29.88` 残片全部拒绝。首轮审计发现并退回两个 P1(current-only 任意 descendant、dual+current 共存仍通过);返修后根代理及独立审计均复现为 REJECT。真实 current-only after raw projection PASS(14 roles);历史 dual M raw 仅内存机械恢复未选尺码态后 PASS(14 roles)。截图后 current-only→dual 漂移永久测试证明 `stage=reveal_after`、唯一 swipe=1、target/staging 均不发布。根代理完整门禁:client unittest 274/274 PASS,compileall PASS,agent-context PASS,git diff-check PASS;生产新增行只有 clickability 只读检查,无新 tap/swipe/数量/确认页/授权/围栏/提交或支付能力。T-103 仍保持 DOING;下一步需按验收全集形成可发布的原子 before/after spike 证据并由人终验,代码提交后不得复用旧 output-dir。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user