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