chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
275
tools/ui_inspector/state_diff.py
Normal file
275
tools/ui_inspector/state_diff.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""State Diff + proposed postconditions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateChange:
|
||||
kind: str
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
confidence: float = 0.7
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProposedPostcondition:
|
||||
description: str
|
||||
kind: str
|
||||
status: str = "proposed" # proposed | user_confirmed | runtime_verified
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _row_key(r: dict[str, Any]) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
str(r.get("control_type") or r.get("role") or ""),
|
||||
str(r.get("automation_id") or ""),
|
||||
str(r.get("name") or ""),
|
||||
str(r.get("class_name") or ""),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _row_value(r: dict[str, Any]) -> str:
|
||||
return str(r.get("value") or r.get("text") or "")
|
||||
|
||||
|
||||
def _index_rows(rows: Optional[list[dict[str, Any]]]) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for r in rows or []:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
k = _row_key(r)
|
||||
if k.strip("|"):
|
||||
out[k] = r
|
||||
return out
|
||||
|
||||
|
||||
def diff_states(
|
||||
before: Optional[dict[str, Any]],
|
||||
after: Optional[dict[str, Any]],
|
||||
) -> list[StateChange]:
|
||||
"""
|
||||
Diff two state-like dicts (UIState.to_dict or lightweight summaries).
|
||||
|
||||
Expected keys: url, page_title, window, locatable, stats, selection
|
||||
"""
|
||||
b = before or {}
|
||||
a = after or {}
|
||||
changes: list[StateChange] = []
|
||||
|
||||
b_url = str(b.get("url") or "")
|
||||
a_url = str(a.get("url") or "")
|
||||
if b_url != a_url:
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="url_changed",
|
||||
detail={"from": b_url, "to": a_url},
|
||||
)
|
||||
)
|
||||
|
||||
b_title = str(b.get("page_title") or (b.get("window") or {}).get("title") or "")
|
||||
a_title = str(a.get("page_title") or (a.get("window") or {}).get("title") or "")
|
||||
if b_title != a_title:
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="title_changed",
|
||||
detail={"from": b_title, "to": a_title},
|
||||
)
|
||||
)
|
||||
|
||||
b_cls = str((b.get("window") or {}).get("class_name") or "")
|
||||
a_cls = str((a.get("window") or {}).get("class_name") or "")
|
||||
b_hwnd = (b.get("window") or {}).get("hwnd")
|
||||
a_hwnd = (a.get("window") or {}).get("hwnd")
|
||||
if b_cls and a_cls and b_cls != a_cls:
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="window_changed",
|
||||
detail={"from_class": b_cls, "to_class": a_cls},
|
||||
)
|
||||
)
|
||||
if b_hwnd and not a_hwnd:
|
||||
changes.append(StateChange(kind="window_disappeared", detail={"class_name": b_cls}))
|
||||
if a_hwnd and not b_hwnd:
|
||||
changes.append(StateChange(kind="window_appeared", detail={"class_name": a_cls}))
|
||||
|
||||
# Popup heuristics by class name
|
||||
for label, cls in (("before", b_cls), ("after", a_cls)):
|
||||
del label
|
||||
if "SearchResult" in a_cls and "SearchResult" not in b_cls:
|
||||
changes.append(
|
||||
StateChange(kind="popup_appeared", detail={"class_name": a_cls})
|
||||
)
|
||||
if "SearchResult" in b_cls and "SearchResult" not in a_cls:
|
||||
changes.append(
|
||||
StateChange(kind="popup_disappeared", detail={"class_name": b_cls})
|
||||
)
|
||||
|
||||
b_rows = _index_rows(b.get("locatable") or [])
|
||||
a_rows = _index_rows(a.get("locatable") or [])
|
||||
b_keys = set(b_rows)
|
||||
a_keys = set(a_rows)
|
||||
|
||||
for k in sorted(a_keys - b_keys)[:40]:
|
||||
r = a_rows[k]
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="element_appeared",
|
||||
detail={
|
||||
"key": k,
|
||||
"name": r.get("name"),
|
||||
"value": _row_value(r),
|
||||
"control_type": r.get("control_type") or r.get("role"),
|
||||
"automation_id": r.get("automation_id"),
|
||||
},
|
||||
)
|
||||
)
|
||||
for k in sorted(b_keys - a_keys)[:40]:
|
||||
r = b_rows[k]
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="element_disappeared",
|
||||
detail={
|
||||
"key": k,
|
||||
"name": r.get("name"),
|
||||
"value": _row_value(r),
|
||||
"control_type": r.get("control_type") or r.get("role"),
|
||||
"automation_id": r.get("automation_id"),
|
||||
},
|
||||
)
|
||||
)
|
||||
for k in sorted(b_keys & a_keys)[:80]:
|
||||
bv = _row_value(b_rows[k])
|
||||
av = _row_value(a_rows[k])
|
||||
if bv != av:
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="element_value_changed",
|
||||
detail={
|
||||
"key": k,
|
||||
"name": a_rows[k].get("name"),
|
||||
"from": bv,
|
||||
"to": av,
|
||||
"automation_id": a_rows[k].get("automation_id"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
b_focus = (b.get("selection") or {}).get("focused") or (b.get("selection") or {}).get(
|
||||
"focus"
|
||||
)
|
||||
a_focus = (a.get("selection") or {}).get("focused") or (a.get("selection") or {}).get(
|
||||
"focus"
|
||||
)
|
||||
if b_focus != a_focus and (b_focus or a_focus):
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="focus_changed",
|
||||
detail={"from": b_focus, "to": a_focus},
|
||||
)
|
||||
)
|
||||
|
||||
b_loc = int((b.get("stats") or {}).get("locatable_count") or len(b.get("locatable") or []))
|
||||
a_loc = int((a.get("stats") or {}).get("locatable_count") or len(a.get("locatable") or []))
|
||||
if b_loc != a_loc:
|
||||
changes.append(
|
||||
StateChange(
|
||||
kind="locatable_count_changed",
|
||||
detail={"from": b_loc, "to": a_loc},
|
||||
)
|
||||
)
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def propose_postconditions(changes: list[StateChange]) -> list[ProposedPostcondition]:
|
||||
"""Generate suggested postconditions (always status=proposed)."""
|
||||
props: list[ProposedPostcondition] = []
|
||||
for ch in changes:
|
||||
kind = ch.kind
|
||||
d = ch.detail or {}
|
||||
if kind == "url_changed":
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=f"URL became {d.get('to')!r}",
|
||||
kind="url_equals",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
elif kind == "popup_disappeared":
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=f"Window/popup {d.get('class_name')!r} disappeared",
|
||||
kind="window_absent",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
elif kind == "popup_appeared":
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=f"Window/popup {d.get('class_name')!r} appeared",
|
||||
kind="window_present",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
elif kind == "title_changed":
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=f"Title became {d.get('to')!r}",
|
||||
kind="title_equals",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
elif kind == "element_appeared":
|
||||
name = d.get("name") or d.get("automation_id") or d.get("value")
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=f"Element {name!r} appeared",
|
||||
kind="element_present",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
elif kind == "element_disappeared":
|
||||
name = d.get("name") or d.get("automation_id") or d.get("value")
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=f"Element {name!r} disappeared",
|
||||
kind="element_absent",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
elif kind == "element_value_changed":
|
||||
props.append(
|
||||
ProposedPostcondition(
|
||||
description=(
|
||||
f"Element {d.get('name') or d.get('automation_id')!r} "
|
||||
f"value became {d.get('to')!r}"
|
||||
),
|
||||
kind="element_value_equals",
|
||||
detail=d,
|
||||
)
|
||||
)
|
||||
return props
|
||||
|
||||
|
||||
def changes_to_dicts(changes: list[StateChange]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"kind": c.kind, "detail": c.detail, "confidence": c.confidence} for c in changes
|
||||
]
|
||||
|
||||
|
||||
def postconditions_to_dicts(
|
||||
props: list[ProposedPostcondition],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"description": p.description,
|
||||
"kind": p.kind,
|
||||
"status": p.status,
|
||||
"detail": p.detail,
|
||||
}
|
||||
for p in props
|
||||
]
|
||||
Reference in New Issue
Block a user