Skip to content

Commit b327464

Browse files
Add numeric UI robot coverage gate (#680)
Co-authored-by: GuilaumeDemets <g.demets02@gmail.com>
1 parent 699dbad commit b327464

2 files changed

Lines changed: 224 additions & 1 deletion

File tree

test/test_ui_robot_coverage_contract.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ def test_ui_robot_coverage_contract_passes_for_current_matrix() -> None:
3333
assert payload["schema"] == module.SCHEMA
3434
assert payload["success"] is True
3535
assert payload["issues"] == []
36+
assert payload["summary"]["status"] == "pass"
37+
assert payload["summary"]["coverage_percent"] == 100.0
38+
assert payload["summary"]["covered_items"] == payload["summary"]["required_items"]
39+
assert payload["summary"]["metrics"]["core_pages"] == {"covered": 6, "percent": 100.0, "total": 6}
3640
for page in module.REQUIRED_CORE_PAGES:
3741
assert payload["coverage"]["core_pages"][page]
3842
for action in module.REQUIRED_HIGH_RISK_ACTIONS:
@@ -166,6 +170,7 @@ def test_ui_robot_coverage_contract_json_cli(capsys) -> None:
166170
payload = json.loads(capsys.readouterr().out)
167171
assert payload["success"] is True
168172
assert payload["schema"] == module.SCHEMA
173+
assert payload["summary"]["coverage_percent"] == 100.0
169174
assert payload["coverage"]["hf_robot_scenarios"]
170175

171176

@@ -246,6 +251,7 @@ def scenario(
246251
module.REQUIRED_ORCHESTRATE_POOL_SCENARIO,
247252
pages="ORCHESTRATE",
248253
required_text=",".join(module.REQUIRED_ORCHESTRATE_POOL_TEXT),
254+
browser_error_check=True,
249255
)
250256
execution_pandas_pool = scenario(
251257
module.REQUIRED_EXECUTION_PANDAS_POOL_SCENARIO,
@@ -371,6 +377,7 @@ def fake_load_module(_name, path):
371377

372378
assert payload["success"] is True
373379
assert payload["issues"] == []
380+
assert payload["summary"]["coverage_percent"] == 100.0
374381
assert payload["coverage"]["public_demo_contract"]["ui_apps_covered_by"] == "ui-robot-matrix explicit --apps"
375382
assert payload["coverage"]["ui_robot_matrix_profile_apps"] == sorted(module.REQUIRED_DEMO_UI_APPS)
376383

@@ -455,7 +462,9 @@ def fake_load_module(_name, path):
455462
payload = module.evaluate_contract()
456463

457464
assert payload["success"] is False
465+
assert payload["summary"]["coverage_percent"] < 100.0
458466
details = [issue["detail"] for issue in payload["issues"]]
467+
assert any(issue["kind"] == "coverage_percent" for issue in payload["issues"])
459468
assert (
460469
"first-proof HF profile is missing public demo apps: "
461470
"flight_telemetry_project, pytorch_playground_project, weather_forecast_project"
@@ -548,6 +557,7 @@ def fake_load_module(_name, path):
548557
payload = module.evaluate_contract()
549558

550559
assert payload["success"] is False
560+
assert payload["summary"]["coverage_percent"] < 100.0
551561
assert {"kind": "built_in_apps", "detail": "no public built-in apps were discovered"} in payload["issues"]
552562

553563

@@ -713,8 +723,12 @@ def fake_load_module(_name, path):
713723

714724
details = [issue["detail"] for issue in payload["issues"]]
715725
assert exit_code == 1
716-
assert "verdict: FAIL" in capsys.readouterr().out
726+
rendered_output = capsys.readouterr().out
727+
assert "verdict: FAIL" in rendered_output
728+
assert "coverage:" in rendered_output
729+
assert payload["summary"]["coverage_percent"] < 100.0
717730
assert "- core_page:" in rendered
731+
assert "- coverage_percent:" in rendered
718732
assert any("apps declare configured apps-pages" in detail for detail in details)
719733
assert "default robot matrix has no scenarios for --apps all" in details
720734
assert "PROJECT_EDITOR is not covered by any default robot scenario" in details

tools/ui_robot_coverage_contract.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,34 @@ class CoverageIssue:
178178
detail: str
179179

180180

181+
def _percentage(covered: int, total: int) -> float:
182+
if total <= 0:
183+
return 100.0
184+
return round((covered / total) * 100.0, 2)
185+
186+
187+
def _metric(covered: int, total: int) -> dict[str, int | float]:
188+
covered = max(0, min(int(covered), int(total)))
189+
total = int(total)
190+
return {"covered": covered, "total": total, "percent": _percentage(covered, total)}
191+
192+
193+
def _present_count(required: Sequence[str], actual: Sequence[str] | set[str]) -> int:
194+
return len(set(required) & set(actual))
195+
196+
197+
def _coverage_summary(metrics: dict[str, dict[str, int | float]]) -> dict[str, Any]:
198+
covered = sum(int(metric["covered"]) for metric in metrics.values())
199+
total = sum(int(metric["total"]) for metric in metrics.values())
200+
return {
201+
"covered_items": covered,
202+
"required_items": total,
203+
"coverage_percent": _percentage(covered, total),
204+
"target_percent": 100.0,
205+
"metrics": metrics,
206+
}
207+
208+
181209
def _load_module(name: str, path: Path) -> Any:
182210
spec = importlib.util.spec_from_file_location(name, path)
183211
if spec is None or spec.loader is None:
@@ -298,6 +326,43 @@ def apps_page_coverage_issues(public_views: Sequence[str]) -> list[CoverageIssue
298326
return issues
299327

300328

329+
def _apps_page_metric(public_views: Sequence[str]) -> dict[str, int | float]:
330+
covered = 0
331+
for view in set(public_views):
332+
if view in BROWSER_ASSERTED_APPS_PAGES:
333+
covered += 1
334+
continue
335+
disposition = APPS_PAGE_RENDER_ONLY_DISPOSITIONS.get(view)
336+
if disposition is None:
337+
continue
338+
reason, focused_test = disposition
339+
focused_path = Path(focused_test)
340+
if not focused_path.is_absolute():
341+
focused_path = REPO_ROOT / focused_path
342+
if reason.strip() and focused_path.is_file():
343+
covered += 1
344+
return _metric(covered, len(set(public_views)))
345+
346+
347+
def _hf_robot_metric(hf_robot_scenarios: dict[str, dict[str, list[str]]]) -> dict[str, int | float]:
348+
covered = 0
349+
total = 0
350+
for scenario_name, requirements in REQUIRED_HF_ROBOT_SCENARIOS.items():
351+
actual = hf_robot_scenarios.get(scenario_name, {})
352+
total += 1
353+
if actual:
354+
covered += 1
355+
for key in ("pages", "apps_pages", "flags"):
356+
required_values = requirements.get(key, ())
357+
total += len(required_values)
358+
covered += _present_count(required_values, actual.get(key, []))
359+
required_actions = tuple(str(value).strip().lower() for value in requirements.get("actions", ()))
360+
actual_actions = {str(value).strip().lower() for value in actual.get("actions", [])}
361+
total += len(required_actions)
362+
covered += _present_count(required_actions, actual_actions)
363+
return _metric(covered, total)
364+
365+
301366
def evaluate_contract() -> dict[str, Any]:
302367
widget_robot = _load_module("agilab_widget_robot_contract", WIDGET_ROBOT_PATH)
303368
matrix = _load_module("agilab_widget_robot_matrix_contract", MATRIX_PATH)
@@ -795,9 +860,147 @@ def evaluate_contract() -> dict[str, Any]:
795860
)
796861
)
797862

863+
editor_route_covered = 0
864+
editor_route_total = 0
865+
for route in REQUIRED_EDITOR_ROUTES:
866+
route_contract = editor_route_contract.get(route, {})
867+
route_scenarios = route_contract.get("scenarios", [])
868+
required_text = route_contract.get("required_text", [])
869+
forbidden_text = route_contract.get("forbidden_text", [])
870+
editor_route_total += 1
871+
if route_scenarios:
872+
editor_route_covered += 1
873+
required_text_values = REQUIRED_EDITOR_ROUTE_TEXT.get(route, ())
874+
forbidden_text_values = REQUIRED_EDITOR_ROUTE_FORBIDDEN_TEXT.get(route, ())
875+
editor_route_total += len(required_text_values) + len(forbidden_text_values)
876+
editor_route_covered += _present_count(required_text_values, required_text)
877+
editor_route_covered += _present_count(forbidden_text_values, forbidden_text)
878+
879+
pytorch_required_text = pytorch_analysis.get("required_text", [])
880+
pytorch_required_links = pytorch_analysis.get("required_links", [])
881+
pytorch_required_actions = pytorch_analysis.get("required_actions", [])
882+
pytorch_forbidden_sidebar_text = pytorch_analysis.get("forbidden_sidebar_text", [])
883+
pytorch_flags = pytorch_analysis.get("flags", [])
884+
pytorch_metric_total = (
885+
3
886+
+ len(REQUIRED_PYTORCH_ANALYSIS_TEXT)
887+
+ len(REQUIRED_PYTORCH_ANALYSIS_FORBIDDEN_SIDEBAR_TEXT)
888+
+ len(REQUIRED_PYTORCH_ANALYSIS_LINKS)
889+
+ len(REQUIRED_PYTORCH_ANALYSIS_ACTIONS)
890+
+ 1
891+
)
892+
pytorch_metric_covered = (
893+
int(bool(pytorch_analysis))
894+
+ int("ANALYSIS" in pytorch_analysis.get("pages", []))
895+
+ int(REQUIRED_PYTORCH_ANALYSIS_APP in pytorch_analysis.get("apps", []))
896+
+ _present_count(REQUIRED_PYTORCH_ANALYSIS_TEXT, pytorch_required_text)
897+
+ _present_count(REQUIRED_PYTORCH_ANALYSIS_FORBIDDEN_SIDEBAR_TEXT, pytorch_forbidden_sidebar_text)
898+
+ _present_count(REQUIRED_PYTORCH_ANALYSIS_LINKS, pytorch_required_links)
899+
+ _present_count(REQUIRED_PYTORCH_ANALYSIS_ACTIONS, pytorch_required_actions)
900+
+ int("browser_error_check" in pytorch_flags)
901+
)
902+
903+
orchestrate_pool_metric_total = 2 + len(REQUIRED_ORCHESTRATE_POOL_TEXT) + 1
904+
orchestrate_pool_metric_covered = (
905+
int(bool(orchestrate_pool))
906+
+ int("ORCHESTRATE" in orchestrate_pool.get("pages", []))
907+
+ _present_count(REQUIRED_ORCHESTRATE_POOL_TEXT, orchestrate_pool.get("required_text", []))
908+
+ int("browser_error_check" in orchestrate_pool.get("flags", []))
909+
)
910+
911+
execution_pandas_pool_metric_total = 3 + len(REQUIRED_EXECUTION_PANDAS_POOL_TEXT) + 1
912+
execution_pandas_pool_metric_covered = (
913+
int(bool(execution_pandas_pool))
914+
+ int(REQUIRED_EXECUTION_PANDAS_POOL_APP in execution_pandas_pool.get("apps", []))
915+
+ int("ORCHESTRATE" in execution_pandas_pool.get("pages", []))
916+
+ _present_count(REQUIRED_EXECUTION_PANDAS_POOL_TEXT, execution_pandas_pool.get("required_text", []))
917+
+ int("browser_error_check" in execution_pandas_pool.get("flags", []))
918+
)
919+
920+
configured_apps_pages_total = 1 if configured_apps_pages else 0
921+
metrics = {
922+
"built_in_app_inventory": _metric(int(bool(built_in_apps)), 1),
923+
"built_in_app_matrix": _metric(int(bool(built_in_apps and default_scenarios)), 1),
924+
"core_pages": _metric(
925+
sum(1 for page in REQUIRED_CORE_PAGES if page_to_scenarios.get(page)),
926+
len(REQUIRED_CORE_PAGES),
927+
),
928+
"editor_routes": _metric(editor_route_covered, editor_route_total),
929+
"configured_apps_pages": _metric(int(bool(configured_scenarios)), configured_apps_pages_total),
930+
"high_risk_actions": _metric(
931+
sum(1 for action in REQUIRED_HIGH_RISK_ACTIONS if action_to_scenarios.get(action)),
932+
len(REQUIRED_HIGH_RISK_ACTIONS),
933+
),
934+
"hf_first_proof_apps": _metric(
935+
_present_count(REQUIRED_HF_FIRST_PROOF_APPS, hf_first_proof_apps)
936+
+ len(set(FORBIDDEN_HF_FIRST_PROOF_APPS) - set(hf_first_proof_apps)),
937+
len(REQUIRED_HF_FIRST_PROOF_APPS) + len(FORBIDDEN_HF_FIRST_PROOF_APPS),
938+
),
939+
"hf_first_proof_pages": _metric(
940+
_present_count(REQUIRED_HF_FIRST_PROOF_PAGES, hf_first_proof_pages),
941+
len(REQUIRED_HF_FIRST_PROOF_PAGES),
942+
),
943+
"apps_pages": _apps_page_metric(public_apps_pages),
944+
"hf_robot_scenarios": _hf_robot_metric(hf_robot_scenarios),
945+
"hf_visual_smoke_profile": _metric(
946+
_present_count(REQUIRED_HF_VISUAL_SMOKE_ROBOT_SCENARIOS, hf_visual_smoke_profile_scenarios)
947+
+ _present_count(hf_first_proof_apps, hf_visual_smoke_profile_apps),
948+
len(REQUIRED_HF_VISUAL_SMOKE_ROBOT_SCENARIOS) + len(hf_first_proof_apps),
949+
),
950+
"hf_install_profile": _metric(
951+
int("hf-first-proof-install" in hf_install_profile_scenarios)
952+
+ _present_count(hf_first_proof_apps, hf_install_profile_apps),
953+
1 + len(hf_first_proof_apps),
954+
),
955+
"ui_robot_matrix_profile": _metric(
956+
_present_count(REQUIRED_EDITOR_ROUTE_PROFILE_SCENARIOS, ui_robot_matrix_profile_scenarios)
957+
+ int(REQUIRED_PYTORCH_ANALYSIS_SCENARIO in ui_robot_matrix_profile_scenarios)
958+
+ int(REQUIRED_RELEASE_EVIDENCE_SCENARIO in ui_robot_matrix_profile_scenarios)
959+
+ int(REQUIRED_EXECUTION_PANDAS_POOL_SCENARIO in ui_robot_matrix_profile_scenarios),
960+
len(REQUIRED_EDITOR_ROUTE_PROFILE_SCENARIOS) + 3,
961+
),
962+
"pytorch_analysis_robot": _metric(pytorch_metric_covered, pytorch_metric_total),
963+
"orchestrate_pool_robot": _metric(orchestrate_pool_metric_covered, orchestrate_pool_metric_total),
964+
"execution_pandas_pool_robot": _metric(
965+
execution_pandas_pool_metric_covered,
966+
execution_pandas_pool_metric_total,
967+
),
968+
"public_demo_docs": _metric(
969+
len(REQUIRED_DEMO_DOC_SNIPPETS) - len(missing_demo_doc_snippets),
970+
len(REQUIRED_DEMO_DOC_SNIPPETS),
971+
),
972+
"public_demo_ui_apps": _metric(
973+
len(REQUIRED_DEMO_UI_APPS) - len(missing_demo_ui_apps),
974+
len(REQUIRED_DEMO_UI_APPS),
975+
),
976+
"public_demo_apps_pages": _metric(
977+
len(REQUIRED_DEMO_UI_PAGES) - len(missing_demo_pages),
978+
len(REQUIRED_DEMO_UI_PAGES),
979+
),
980+
"public_demo_proofs": _metric(
981+
len(REQUIRED_DEMO_PROOF_SCENARIOS) - len(missing_proof_scenarios),
982+
len(REQUIRED_DEMO_PROOF_SCENARIOS),
983+
),
984+
}
985+
summary = _coverage_summary(metrics)
986+
if summary["coverage_percent"] < summary["target_percent"]:
987+
issues.append(
988+
CoverageIssue(
989+
"coverage_percent",
990+
(
991+
"UI robot deterministic coverage is "
992+
f"{summary['coverage_percent']:.2f}% "
993+
f"({summary['covered_items']}/{summary['required_items']}); expected 100.00%."
994+
),
995+
)
996+
)
997+
summary["issue_count"] = len(issues)
998+
summary["status"] = "pass" if not issues else "fail"
999+
7981000
return {
7991001
"schema": SCHEMA,
8001002
"success": not issues,
1003+
"summary": summary,
8011004
"issues": [asdict(issue) for issue in issues],
8021005
"coverage": {
8031006
"built_in_apps": built_in_apps,
@@ -838,9 +1041,15 @@ def evaluate_contract() -> dict[str, Any]:
8381041

8391042

8401043
def render_human(payload: dict[str, Any]) -> str:
1044+
summary = payload.get("summary", {})
8411045
lines = [
8421046
"AGILAB UI robot coverage contract",
8431047
f"verdict: {'PASS' if payload.get('success') else 'FAIL'}",
1048+
(
1049+
"coverage: "
1050+
f"{float(summary.get('coverage_percent', 0.0)):.2f}% "
1051+
f"({int(summary.get('covered_items', 0))}/{int(summary.get('required_items', 0))})"
1052+
),
8441053
]
8451054
for issue in payload.get("issues", []):
8461055
lines.append(f"- {issue.get('kind')}: {issue.get('detail')}")

0 commit comments

Comments
 (0)