Files
skill-template/tools/ui_inspector/state_diff.py
chendelian 633ecff4e2 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>
2026-08-07 11:19:14 +08:00

276 lines
8.6 KiB
Python

"""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
]