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>
276 lines
8.6 KiB
Python
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
|
|
]
|