Files
1jehuang--jcode/ios/TestHarness/reward/scorers/accessibility.py
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

149 lines
6.1 KiB
Python

"""Scorer: D. accessibility.
STATIC scorer over the SwiftUI source (ctx.source_files). It rewards the
accessibility affordances a VoiceOver / Dynamic Type user needs and penalizes
icon-only buttons that ship no text equivalent:
- explicit `.accessibilityLabel` / `.accessibilityHint`
- Dynamic Type usage: semantic `.font(.body|.headline|...)` (which scales)
instead of only fixed `Theme.mono(<size>)` points
- `.dynamicTypeSize` clamps / ranges
- reduce-motion awareness via `@Environment(\\.accessibilityReduceMotion)`
- `Label(text, systemImage:)` which gives an icon a spoken text label
- icon-only `Button { ... Image(systemName:) ... }` with NO label is a defect
If there is no source AND no AX tree, the cell is genuinely unmeasurable and we
return make_unavailable. The output is a blended 0..100 with per-signal counts
in evidence so regressions are diffable.
"""
from __future__ import annotations
import re
from reward.context import Context
from reward.types import CategoryScore, make_unavailable
NAME = "accessibility"
CATEGORY = "D"
WEIGHT = 0.08
# Semantic text styles scale with Dynamic Type; fixed Theme.mono(pt) does not.
_DYNAMIC_FONT = re.compile(
r"\.font\(\s*\.(?:largeTitle|title3|title2|title|headline|subheadline"
r"|body|callout|footnote|caption2|caption)\b"
)
_MONO_FONT = re.compile(r"Theme\.mono\s*\(")
_A11Y_LABEL = re.compile(r"\.accessibilityLabel\s*\(")
_A11Y_HINT = re.compile(r"\.accessibilityHint\s*\(")
_A11Y_VALUE = re.compile(r"\.accessibility(?:Value|AddTraits|Element|Identifier)\s*\(")
_DYNAMIC_TYPE_SIZE = re.compile(r"\.dynamicTypeSize\s*\(")
_REDUCE_MOTION = re.compile(r"accessibilityReduceMotion")
_LABEL_VIEW = re.compile(r"\bLabel\s*\(")
_SYS_IMAGE = re.compile(r"Image\s*\(\s*systemName\s*:")
# A Button whose closure body is essentially just an Image(systemName:) with no
# nearby text or accessibility label. We scan each `Button` occurrence's local
# window of source to decide.
_BUTTON = re.compile(r"\bButton\b")
_TEXT_VIEW = re.compile(r"\bText\s*\(|\bLabel\s*\(")
def _count(rx: re.Pattern, text: str) -> int:
return len(rx.findall(text))
def _icononly_buttons_without_label(text: str) -> int:
"""Count buttons whose visible content is an SF Symbol with no text/label.
Heuristic: for each `Button`, inspect a forward window up to the next
`Button` (bounded). If it contains an Image(systemName:) but no Text(),
Label(), or .accessibilityLabel, it is an unlabeled icon-only button.
"""
flagged = 0
idxs = [m.start() for m in _BUTTON.finditer(text)]
for i, start in enumerate(idxs):
end = idxs[i + 1] if i + 1 < len(idxs) else len(text)
window = text[start:min(end, start + 600)]
if not _SYS_IMAGE.search(window):
continue
has_text = bool(_TEXT_VIEW.search(window))
has_label = bool(_A11Y_LABEL.search(window))
if not has_text and not has_label:
flagged += 1
return flagged
def score(ctx: Context) -> CategoryScore:
files = ctx.source_files
if not files and ctx.ax_tree is None:
return make_unavailable(NAME, CATEGORY, WEIGHT, "no source or AX tree")
if not files:
# AX tree present but no source: we only grade source signals here.
return make_unavailable(NAME, CATEGORY, WEIGHT, "no source to grade")
blob = "\n".join(files.values())
a11y_label_count = _count(_A11Y_LABEL, blob)
a11y_hint_count = _count(_A11Y_HINT, blob)
a11y_semantic_count = _count(_A11Y_VALUE, blob)
dynamic_font_count = _count(_DYNAMIC_FONT, blob)
mono_font_count = _count(_MONO_FONT, blob)
dynamic_type_size_count = _count(_DYNAMIC_TYPE_SIZE, blob)
reduce_motion_signals = _count(_REDUCE_MOTION, blob)
label_view_count = _count(_LABEL_VIEW, blob)
icononly = _icononly_buttons_without_label(blob)
# Dynamic Type score: share of font usages that are scalable semantic styles
# rather than fixed monospaced points. Label() also reads as text+icon.
total_fonts = dynamic_font_count + mono_font_count
dynamic_type_signals = dynamic_font_count + dynamic_type_size_count
if total_fonts > 0:
dynamic_ratio = dynamic_font_count / total_fonts
else:
dynamic_ratio = 0.0
dynamic_score = 100.0 * dynamic_ratio
if dynamic_type_size_count > 0:
dynamic_score = min(100.0, dynamic_score + 10.0)
# Label coverage: explicit a11y labels + Label() views that name their icon,
# relative to the symbol images that could otherwise be unspoken. Saturates.
sys_image_count = max(1, _count(_SYS_IMAGE, blob))
labelled = a11y_label_count + label_view_count
label_coverage = min(1.0, labelled / sys_image_count)
label_score = 100.0 * label_coverage
# Hint/semantic presence: small bonus band, present-or-not dominated.
semantic_score = min(100.0, 100.0 * (a11y_hint_count + a11y_semantic_count) / 4.0)
# Reduce-motion: binary-ish, any usage is most of the credit.
reduce_score = min(100.0, 60.0 + 40.0 * reduce_motion_signals) if reduce_motion_signals else 0.0
# Blend. Dynamic Type and labelling matter most for everyday legibility;
# reduce-motion and hints are secondary affordances.
value = (0.34 * dynamic_score
+ 0.34 * label_score
+ 0.16 * semantic_score
+ 0.16 * reduce_score)
# Penalize icon-only buttons with no text equivalent: each is a VoiceOver
# dead-end. Scale by how many such buttons relative to a small budget.
penalty = min(40.0, icononly * 12.0)
value = max(0.0, min(100.0, value - penalty))
return CategoryScore(
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
evidence={
"a11y_label_count": a11y_label_count,
"a11y_hint_count": a11y_hint_count,
"dynamic_type_signals": dynamic_type_signals,
"dynamic_font_count": dynamic_font_count,
"mono_font_count": mono_font_count,
"reduce_motion_signals": reduce_motion_signals,
"label_view_count": label_view_count,
"icononly_buttons_without_label": icononly,
"dynamic_ratio": round(dynamic_ratio, 3),
"label_coverage": round(label_coverage, 3),
},
)