use teksilo_canvas::{Rect, Size, SizeProposal};
use teksilo_tokens::InputTokens;
use super::*;
use crate::accessibility::target_audit::{
AllowedViolation, Owner,
PinnedDp::{ClearsFloor, Is},
PinnedGeometry, ReachSources, TargetRule, TargetViolation, audit_fixtures,
};
use crate::build_context::BuildContext;
use crate::styles::{Theme, ThemeId};
use crate::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
use crate::widget_builder::WidgetBuilder;
use crate::widget_id::WidgetId;
use crate::widget_tree::WidgetTree;
#[derive(Debug)]
struct TinyLeaf(Size);
impl Widget for TinyLeaf {
fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
self.0.into()
}
}
#[derive(Debug)]
struct PaddedRow {
size: Size,
pad: f32,
children: Vec<WidgetId>,
}
impl Widget for PaddedRow {
fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
self.children.clone()
}
fn preserves_children_on_rebuild(&self) -> bool {
true
}
fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
self.size.into()
}
fn place_children(
&self,
bounds: Rect,
_p: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
let mut x = bounds.x + self.pad;
for child in children.iter_mut() {
let size = ctx
.child_size(child.id, SizeProposal::unspecified())
.unwrap_or(Size::ZERO);
child.origin = teksilo_canvas::Point::new(x, bounds.y + self.pad);
child.size = size;
x += size.width;
}
}
fn children(&self) -> Vec<WidgetId> {
self.children.clone()
}
}
fn undersized_in_a_tappable_row(tree: &mut WidgetTree) -> WidgetId {
let small = tree.add(TinyLeaf(Size::new(10.0, 10.0)).on_tap(|_, _| {}));
let middling = tree.add(TinyLeaf(Size::new(26.0, 26.0)).on_tap(|_, _| {}));
tree.add(
PaddedRow {
size: Size::new(300.0, 100.0),
pad: 40.0,
children: vec![small, middling],
}
.on_tap(|_, _| {}),
)
}
const UNDERSIZED: &[TargetFixture] = &[TargetFixture::new(
"undersized",
undersized_in_a_tappable_row,
)];
const ALPHA: &str = "alpha.light";
const BETA: &str = "beta.light";
const LENIENT: &str = "lenient.light";
const RELAXED: &str = "relaxed.light";
fn alpha() -> Theme {
crate::presets::intui::light().with_id(ALPHA)
}
fn beta() -> Theme {
crate::presets::intui::light().with_id(BETA)
}
fn lenient() -> Theme {
let mut theme = crate::presets::intui::light().with_id(LENIENT);
theme.input.min_target_conformance = 16.0;
theme
}
fn relaxed() -> Theme {
let mut theme = crate::presets::intui::light().with_id(RELAXED);
theme.input.min_target_conformance = 16.0;
theme
}
fn misnamed() -> Theme {
crate::presets::intui::light().with_id("something.else")
}
fn anonymous() -> Theme {
crate::presets::intui::light().with_id(ANONYMOUS_THEME)
}
fn violation(
path: &str,
theme: &str,
density: TargetDensity,
paints: (f32, f32),
reaches: (f32, f32),
) -> TargetViolation {
TargetViolation {
widget: "TinyLeaf",
node: WidgetId::default(),
part: None,
path: path.to_string(),
density,
theme: ThemeId::new(theme.to_string()),
conformance_floor: InputTokens::for_density(density).min_target_conformance,
size: Size::new(paints.0, paints.1),
expanded: Size::new(reaches.0, reaches.1),
sources: ReachSources::default(),
transformed: false,
rule: TargetRule::MinTargetConformance,
}
}
const SIXTEEN: PinnedGeometry = PinnedGeometry {
densities: ALL_DENSITIES,
themes: &[ALPHA, BETA],
paints: (Is(16.0), Is(16.0)),
reaches: (Is(16.0), Is(16.0)),
};
const GRIP: AllowedViolation = AllowedViolation {
path: "Grip",
measured: &[SIXTEEN],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
};
const TWENTY_CLEARS: PinnedGeometry = PinnedGeometry {
densities: ALL_DENSITIES,
themes: &[ALPHA, LENIENT],
paints: (ClearsFloor, ClearsFloor),
reaches: (Is(20.0), Is(20.0)),
};
const TWENTY_EXACT: PinnedGeometry = PinnedGeometry {
densities: ALL_DENSITIES,
themes: &[ALPHA, LENIENT],
paints: (Is(20.0), Is(20.0)),
reaches: (Is(20.0), Is(20.0)),
};
const CLEARS_ENTRY: AllowedViolation = AllowedViolation {
path: "Grip",
measured: &[TWENTY_CLEARS],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry whose paint axis is judged against the floor",
};
const EXACT_ENTRY: AllowedViolation = AllowedViolation {
path: "Grip",
measured: &[TWENTY_EXACT],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry that names a geometry outright",
};
static TWO_FLOORS: Roster = Roster {
themes: &[
ThemeSubject {
id: ALPHA,
theme: alpha,
},
ThemeSubject {
id: LENIENT,
theme: lenient,
},
],
allow: &[CLEARS_ENTRY],
};
static TWO_FLOORS_EXACT: Roster = Roster {
themes: &[
ThemeSubject {
id: ALPHA,
theme: alpha,
},
ThemeSubject {
id: LENIENT,
theme: lenient,
},
],
allow: &[EXACT_ENTRY],
};
const TWENTY_UNDER_BOTH: PinnedGeometry = PinnedGeometry {
densities: ALL_DENSITIES,
themes: &[LENIENT, RELAXED],
paints: (ClearsFloor, ClearsFloor),
reaches: (Is(20.0), Is(20.0)),
};
const SHARED_FLOOR_ENTRY: AllowedViolation = AllowedViolation {
path: "Grip",
measured: &[TWENTY_UNDER_BOTH],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry over two themes that share a lowered floor",
};
static SHARED_FLOOR: Roster = Roster {
themes: &[
ThemeSubject {
id: LENIENT,
theme: lenient,
},
ThemeSubject {
id: RELAXED,
theme: relaxed,
},
],
allow: &[SHARED_FLOOR_ENTRY],
};
static TWO_THEMES: Roster = Roster {
themes: &[
ThemeSubject {
id: ALPHA,
theme: alpha,
},
ThemeSubject {
id: BETA,
theme: beta,
},
],
allow: &[GRIP],
};
fn grip_census() -> Vec<TargetViolation> {
let mut out = Vec::new();
for theme in [ALPHA, BETA] {
for &density in ALL_DENSITIES {
out.push(violation(
"Row > Grip",
theme,
density,
(16.0, 16.0),
(16.0, 16.0),
));
}
}
out
}
fn roster_with(allow: &'static [AllowedViolation]) -> Roster {
Roster {
themes: TWO_THEMES.themes,
allow,
}
}
#[test]
fn the_census_sweeps_every_theme_at_every_density() {
let census = conformance_census(UNDERSIZED, &TWO_THEMES);
for subject in TWO_THEMES.themes {
for &density in ALL_DENSITIES {
assert!(
census
.iter()
.any(|v| v.theme.as_str() == subject.id && v.density == density),
"the census carries no row under {} at {density:?}: {census:#?}",
subject.id,
);
}
}
}
#[test]
fn the_census_carries_only_conformance_failures() {
let census = conformance_census(UNDERSIZED, &TWO_THEMES);
assert!(!census.is_empty(), "the fixture must produce failures");
assert!(
census
.iter()
.all(|v| v.rule == TargetRule::MinTargetConformance),
"a non-AA rule reached the census: {census:#?}",
);
assert!(
audit_fixtures(UNDERSIZED, TargetDensity::Touch)
.iter()
.any(|v| !v.rule.is_conformance_failure()),
"the fixture must also produce a non-AA row for the filter to drop",
);
}
#[test]
fn an_unexcused_failure_is_reported() {
let census = vec![violation(
"Row > Widget",
ALPHA,
TargetDensity::Compact,
(10.0, 10.0),
(10.0, 10.0),
)];
let findings = conformance_failures(&census, &TWO_THEMES);
assert_eq!(findings.len(), 1, "{findings:#?}");
assert!(
findings[0].contains("Row > Widget") && findings[0].contains(ALPHA),
"a finding names the path and the theme: {findings:#?}",
);
}
#[test]
fn an_excused_failure_is_not_reported() {
assert!(
conformance_failures(&grip_census(), &TWO_THEMES).is_empty(),
"a pinned geometry is what an entry excuses",
);
}
#[test]
fn an_entry_whose_geometry_moved_is_stale() {
let census: Vec<TargetViolation> = grip_census()
.into_iter()
.map(|mut v| {
v.size = Size::new(20.0, 20.0);
v.expanded = Size::new(20.0, 20.0);
v
})
.collect();
let findings = stale_entries(&census, &TWO_THEMES);
assert_eq!(
findings.len(),
ALL_DENSITIES.len() * 2,
"every (theme, density) pair the pin claims is reported: {findings:#?}",
);
assert!(findings[0].contains("Grip"), "{findings:#?}");
}
#[test]
fn a_pin_claiming_a_density_it_never_meets_is_stale() {
let census: Vec<TargetViolation> = grip_census()
.into_iter()
.filter(|v| v.density != TargetDensity::Touch)
.collect();
let findings = stale_entries(&census, &TWO_THEMES);
assert_eq!(findings.len(), 2, "one per theme: {findings:#?}");
assert!(
findings.iter().all(|f| f.contains("Touch")),
"{findings:#?}"
);
}
#[test]
fn a_pin_claiming_a_theme_it_never_meets_is_stale() {
let census: Vec<TargetViolation> = grip_census()
.into_iter()
.filter(|v| v.theme.as_str() != BETA)
.collect();
let findings = stale_entries(&census, &TWO_THEMES);
assert_eq!(findings.len(), ALL_DENSITIES.len(), "{findings:#?}");
assert!(findings.iter().all(|f| f.contains(BETA)), "{findings:#?}");
}
#[test]
fn a_blanket_entry_survives_a_seeded_size_regression() {
static BLANKET: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[PinnedGeometry {
densities: ALL_DENSITIES,
themes: &[ALPHA, BETA],
paints: (Is(2.0), ClearsFloor),
reaches: (Is(2.0), ClearsFloor),
}],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
let census: Vec<TargetViolation> = grip_census()
.into_iter()
.map(|mut v| {
v.size = Size::new(2.0, 30.0);
v.expanded = Size::new(2.0, 30.0);
v
})
.collect();
let roster = roster_with(BLANKET);
assert!(
stale_entries(&census, &roster).is_empty(),
"the entry matches the real census, so staleness cannot catch it — \
which is why this question exists",
);
let findings = non_narrowing(&census, &roster, 0);
for axis in [0, 2] {
assert!(
findings.iter().any(|f| f.contains(&format!("axis {axis}"))),
"a 2 dp seed on axis {axis} is still excused and must be reported: \
{findings:#?}",
);
}
for axis in [1, 3] {
assert!(
!findings.iter().any(|f| f.contains(&format!("axis {axis}"))),
"axis {axis} is pinned `ClearsFloor`, which refuses 2 dp, so the \
seed there must break the match: {findings:#?}",
);
}
}
#[test]
fn a_theme_axis_that_decorates_rather_than_narrows_is_reported() {
let census: Vec<TargetViolation> = grip_census()
.into_iter()
.filter(|v| v.theme.as_str() != BETA)
.collect();
let findings = non_narrowing(&census, &TWO_THEMES, 0);
assert!(
findings.iter().any(|f| f.contains(BETA)),
"the geometry is excused under {BETA}, where nothing produces it: \
{findings:#?}",
);
}
#[test]
fn a_theme_seed_is_judged_against_the_floor_of_the_theme_it_moved_to() {
let mut row = violation(
"Grip",
LENIENT,
TargetDensity::Compact,
(20.0, 20.0),
(20.0, 20.0),
);
row.conformance_floor = 16.0;
let census = vec![row];
let findings = non_narrowing(&census, &TWO_FLOORS, 0);
assert!(
findings.is_empty(),
"moving the row to {ALPHA} raises its floor to 24, so a 20 dp paint no \
longer clears it and the pin stops covering — nothing decorates: {findings:#?}",
);
let decorating = non_narrowing(&census, &TWO_FLOORS_EXACT, 0);
assert!(
decorating.iter().any(|f| f.contains(ALPHA)),
"an exactly-pinned geometry is excused under {ALPHA} too, where the \
census produces nothing — so this roster does report a decorating \
axis, and the empty result above is a verdict: {decorating:#?}",
);
}
#[test]
fn a_theme_seeds_floor_comes_from_the_censuss_own_rows() {
let mut excused = violation(
"Row > Grip",
LENIENT,
TargetDensity::Compact,
(20.0, 20.0),
(20.0, 20.0),
);
excused.conformance_floor = 16.0;
let mut elsewhere = violation(
"Elsewhere",
RELAXED,
TargetDensity::Compact,
(10.0, 10.0),
(10.0, 10.0),
);
elsewhere.conformance_floor = 16.0;
let census = vec![excused, elsewhere];
let findings = non_narrowing(&census, &SHARED_FLOOR, 0);
assert_eq!(
findings.len(),
1,
"the moved seed keeps `relaxed`'s real 16 dp floor, so the pin still \
covers it there and the un-exhibited geometry is reported; a floor \
re-derived through the density door comes back 24, the pin stops \
covering, and nothing is reported at all: {findings:#?}",
);
assert!(findings[0].contains(RELAXED), "{findings:#?}");
}
#[test]
fn a_census_that_collapsed_is_reported_as_proving_less() {
let findings = non_narrowing(&grip_census(), &TWO_THEMES, 1_000);
assert_eq!(findings.len(), 2, "one per theme: {findings:#?}");
assert!(
findings.iter().all(|f| f.contains("the census shrank")),
"{findings:#?}",
);
}
#[test]
fn the_roster_lints() {
static DRIFTED_ID: Roster = Roster {
themes: &[ThemeSubject {
id: ALPHA,
theme: misnamed,
}],
allow: &[GRIP],
};
static ANONYMOUS_ROSTER: Roster = Roster {
themes: &[ThemeSubject {
id: ANONYMOUS_THEME,
theme: anonymous,
}],
allow: &[],
};
static NO_WHY: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[SIXTEEN],
owner: Owner::Named("a fixture"),
exception: None,
why: "",
}];
static NO_OWNER: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[SIXTEEN],
owner: Owner::NobodyBecause(""),
exception: None,
why: "a fixture entry that names nobody and does not say why",
}];
static NO_OWNER_EXCUSED: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[SIXTEEN],
owner: Owner::NobodyBecause("the *Inline* exception is the answer, not a deferral"),
exception: Some("Inline"),
why: "No owner: the *Inline* exception is the answer, not a deferral.",
}];
static UNMENTIONED_EXCEPTION: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[SIXTEEN],
owner: Owner::Named("a fixture"),
exception: Some("Inline"),
why: "a fixture entry whose justification never names the exception it claims",
}];
static INVENTED_EXCEPTION: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[SIXTEEN],
owner: Owner::Named("a fixture"),
exception: Some("Redundant"),
why: "a fixture entry claiming Redundant, a discharge SC 2.5.8 does not grant",
}];
static NO_PIN: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
static THEME_OWNED_PATH: &[AllowedViolation] = &[AllowedViolation {
path: "row: FluentRowFrame > MacOsRowFrame > Grip",
measured: &[SIXTEEN],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
static NO_DENSITY: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[PinnedGeometry {
densities: &[],
..SIXTEEN
}],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
static NO_THEME: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[PinnedGeometry {
themes: &[],
..SIXTEEN
}],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
static ANONYMOUS_PIN: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[PinnedGeometry {
themes: &[ANONYMOUS_THEME],
..SIXTEEN
}],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
static UNMEASURED_THEME: &[AllowedViolation] = &[AllowedViolation {
path: "Grip",
measured: &[PinnedGeometry {
themes: &["gamma.light"],
..SIXTEEN
}],
owner: Owner::Named("a fixture"),
exception: None,
why: "a fixture entry",
}];
let rows: Vec<(&str, Roster, Vec<TargetViolation>, &str)> = vec![
(
"a roster id that has drifted from the theme's own",
DRIFTED_ID,
Vec::new(),
"calls itself",
),
(
"a roster naming the anonymous id",
ANONYMOUS_ROSTER,
Vec::new(),
"measures no particular preset",
),
(
"an entry with no justification",
roster_with(NO_WHY),
grip_census(),
"carries no justification",
),
(
"an entry with no owner and no marker saying why",
roster_with(NO_OWNER),
grip_census(),
"a debt with an address",
),
(
"an entry claiming an exception its justification never mentions",
roster_with(UNMENTIONED_EXCEPTION),
grip_census(),
"exception in a field its justification",
),
(
"an entry claiming an exception SC 2.5.8 does not define",
roster_with(INVENTED_EXCEPTION),
grip_census(),
"not one of SC 2.5.8's exceptions",
),
(
"an entry that pins nothing",
roster_with(NO_PIN),
grip_census(),
"pins no measurement",
),
(
"a path across a type one preset owns",
roster_with(THEME_OWNED_PATH),
Vec::new(),
"FluentRowFrame",
),
(
"a pin claiming no density",
roster_with(NO_DENSITY),
grip_census(),
"claims no density",
),
(
"a pin claiming no theme",
roster_with(NO_THEME),
grip_census(),
"claims no theme",
),
(
"a pin naming the anonymous id",
roster_with(ANONYMOUS_PIN),
grip_census(),
"a pin on no preset",
),
(
"a pin naming a theme the gate does not measure",
roster_with(UNMEASURED_THEME),
grip_census(),
"which this gate does not measure",
),
(
"a preset that fails and that no entry names",
roster_with(&[]),
grip_census(),
"absent from the list without being clean",
),
];
assert_eq!(rows.len(), 13, "one row per lint");
for (what, roster, census, expected) in &rows {
let findings = roster_defects(census, roster);
assert!(
findings.iter().any(|f| f.contains(expected)),
"{what}: no finding contains `{expected}`: {findings:#?}",
);
}
let both = roster_defects(&[], &roster_with(THEME_OWNED_PATH));
assert!(
both.iter().any(|f| f.contains("`FluentRowFrame`"))
&& both.iter().any(|f| f.contains("`MacOsRowFrame`")),
"the lint must name the type behind the fixture name as well as the \
plain one; it reported {both:#?}",
);
let clean = roster_defects(&grip_census(), &TWO_THEMES);
assert!(clean.is_empty(), "{clean:#?}");
let excused = roster_defects(&grip_census(), &roster_with(NO_OWNER_EXCUSED));
assert!(excused.is_empty(), "{excused:#?}");
}
#[test]
fn an_entry_does_not_excuse_a_widget_whose_name_merely_starts_with_its_own() {
static LINK: &[AllowedViolation] = &[AllowedViolation {
path: "> Link",
measured: &[SIXTEEN],
owner: Owner::NobodyBecause("a fixture"),
exception: None,
why: "a fixture entry",
}];
let sized = |path: &str| {
violation(
path,
ALPHA,
TargetDensity::Compact,
(16.0, 16.0),
(16.0, 16.0),
)
};
assert!(
LINK[0].matches(&sized("row: HStack > Link")),
"the entry must still excuse the control it was written for",
);
assert!(
!LINK[0].matches(&sized("row: HStack > LinkButton")),
"`LinkButton` is not `Link`, and it inherits none of Link's exception",
);
assert!(
!LINK[0].matches(&sized("row: HStack > InlineLink")),
"nor does a name that merely ends with it",
);
}
#[test]
fn an_entry_naming_a_fixture_names_the_whole_fixture() {
static FIXTURE: &[AllowedViolation] = &[AllowedViolation {
path: "zoomed_out:",
measured: &[SIXTEEN],
owner: Owner::NobodyBecause("a fixture"),
exception: None,
why: "a fixture entry",
}];
let sized = |path: &str| {
violation(
path,
ALPHA,
TargetDensity::Compact,
(16.0, 16.0),
(16.0, 16.0),
)
};
assert!(
FIXTURE[0].matches(&sized("zoomed_out: SceneView > Button")),
"the fixture it names",
);
assert!(
!FIXTURE[0].matches(&sized("heavyweight/zoomed_out: SceneView > Button")),
"a different fixture whose name ends with the one the entry names",
);
}
#[test]
fn an_entry_naming_a_run_of_segments_requires_them_adjacent_and_in_order() {
static RUN: &[AllowedViolation] = &[AllowedViolation {
path: "HStack > Grip",
measured: &[SIXTEEN],
owner: Owner::NobodyBecause("a fixture"),
exception: None,
why: "a fixture entry",
}];
let sized = |path: &str| {
violation(
path,
ALPHA,
TargetDensity::Compact,
(16.0, 16.0),
(16.0, 16.0),
)
};
assert!(RUN[0].matches(&sized("row: Padding > HStack > Grip")));
assert!(
!RUN[0].matches(&sized("row: HStack > Padding > Grip")),
"something between them is a different tree",
);
assert!(
!RUN[0].matches(&sized("row: Grip > HStack")),
"and the order is part of the claim",
);
}