Skip to main content

computer_use/
scroll.rs

1//! Scroll verification and the AX scroll-bar fallback. Pure: no events are
2//! posted, no Accessibility call is made, and no clock is read from here.
3//!
4//! A scroll-wheel `CGEvent` posted with `CGEventPostToPid` is not delivered to
5//! the views of a non-frontmost app, so a posted event is not proof of a
6//! scroll. The event tier reads the `AXScrollArea` scroll bar before and after
7//! the post, and this module turns that measurement into a decision: the wheel
8//! event worked, the AX scroll-bar fallback should run, the bar is already at
9//! its limit, or nothing could be verified at all.
10//!
11//! The per-line step size is computed from the ratio between the content
12//! height (the document child of the `AXScrollArea`) and the visible height
13//! (the scroll area itself, minus the orthogonal scroll bar). One wheel
14//! notch is roughly three text lines, so a notch is [`LINE_POINTS`] points;
15//! the math in [`content_step`] divides that by the content-overflow
16//! (`content - visible`) and clamps to `0..=1` so a wheel line never jumps
17//! the whole bar.
18//!
19//! Every function here is pure so the same tests run on macOS, Windows, and
20//! Linux. The macOS backend owns the Accessibility calls
21//! (`src/backend/macos/scroll_ax.rs`); Windows and Linux scroll through their
22//! own foreground or portal paths and do not use this module.
23
24/// Two scroll-bar values closer than this are the same value.
25///
26/// `AXValue` is a `double`, so a rounding wobble must not read as a scroll.
27pub const EPSILON: f64 = 1e-6;
28
29/// Warning text when the wheel event did nothing and `AXValue` was written.
30pub const AX_FALLBACK_WARNING: &str =
31    "wheel event not delivered to background app; used AX scroll bar";
32
33/// Warning text when the scroll area has no measured `AXSize` for either the
34/// scroll area itself or its document child, so the per-line step falls back
35/// to the legacy five-percent approximation.
36pub const APPROXIMATED_WARNING: &str = "scroll step approximated";
37
38/// Warning text when the document's content fits inside the visible viewport,
39/// so a scroll is not possible and the per-line step is `0.0`.
40pub const CONTENT_FITS_WARNING: &str = "content fits; nothing to scroll";
41
42/// Value step per wheel line when the content sizing data could not be read.
43pub const DEFAULT_STEP: f64 = 0.05;
44
45/// One wheel-line in points.
46///
47/// A typical macOS wheel notch is roughly three text lines, each about 13
48/// points tall, so 40 points is the line budget per notch.
49pub const LINE_POINTS: f64 = 40.0;
50
51/// Smallest step per wheel line. Below this a write would not visibly move.
52pub const MIN_STEP: f64 = 0.005;
53
54/// Largest step per wheel line. One wheel line never jumps the whole bar.
55pub const MAX_STEP: f64 = 1.0;
56
57/// How far a read-back value may sit from the requested value before the
58/// outcome notes that the app landed somewhere else. An app that snaps to its
59/// own page is still a scroll; it just did not land where we asked.
60pub const ROUNDING_TOLERANCE: f64 = 0.005;
61
62/// One axis of a scroll bar.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub enum Axis {
65    /// `AXVerticalScrollBar`. Value 0 is the top.
66    Vertical,
67    /// `AXHorizontalScrollBar`. Value 0 is the left.
68    Horizontal,
69}
70
71impl Axis {
72    /// Stable JSON name used in warnings.
73    pub fn as_str(self) -> &'static str {
74        match self {
75            Self::Vertical => "vertical",
76            Self::Horizontal => "horizontal",
77        }
78    }
79
80    /// The edge `steps` is scrolling towards. Positive steps go down or right.
81    pub fn limit(self, steps: i32) -> &'static str {
82        match (self, steps >= 0) {
83            (Self::Vertical, true) => "bottom",
84            (Self::Vertical, false) => "top",
85            (Self::Horizontal, true) => "right",
86            (Self::Horizontal, false) => "left",
87        }
88    }
89
90    /// The `AXScrollArea` attribute holding this axis's scroll bar.
91    pub fn bar_attribute(self) -> &'static str {
92        match self {
93            Self::Vertical => "AXVerticalScrollBar",
94            Self::Horizontal => "AXHorizontalScrollBar",
95        }
96    }
97
98    /// The delta field for this axis. `dy` is vertical, `dx` is horizontal.
99    pub fn delta(self, dx: i32, dy: i32) -> i32 {
100        match self {
101            Self::Vertical => dy,
102            Self::Horizontal => dx,
103        }
104    }
105
106    /// Every axis, in the order a scroll reports them: vertical first.
107    pub fn both() -> [Self; 2] {
108        [Self::Vertical, Self::Horizontal]
109    }
110}
111
112/// Which mechanism produced the scroll.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Via {
115    /// The posted wheel event moved the scroll bar.
116    Event,
117    /// `AXValue` was written on the scroll bar.
118    AxScrollbar,
119}
120
121impl Via {
122    /// Stable JSON name.
123    pub fn as_str(self) -> &'static str {
124        match self {
125            Self::Event => "event",
126            Self::AxScrollbar => "ax_scrollbar",
127        }
128    }
129}
130
131/// What comparing the bar before and after the wheel event means.
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum Decision {
134    /// The wheel event moved the bar. `before` and `after` are its values.
135    Event { before: f64, after: f64 },
136    /// The bar did not move and is not at its limit: write `AXValue`.
137    AxScrollbar { before: f64, next: f64 },
138    /// The bar did not move because it is already at its limit.
139    AtLimit { value: f64 },
140    /// No scroll bar could be read, so nothing was verified.
141    Unverifiable,
142}
143
144/// True when the two readings differ by more than [`EPSILON`].
145pub fn moved(before: f64, after: f64) -> bool {
146    (after - before).abs() > EPSILON
147}
148
149/// True when a bar at `value` cannot scroll `steps` further.
150///
151/// A zero step is never at a limit: it asks for no movement.
152pub fn at_limit(value: f64, steps: i32) -> bool {
153    match steps.cmp(&0) {
154        std::cmp::Ordering::Greater => value >= 1.0 - EPSILON,
155        std::cmp::Ordering::Less => value <= EPSILON,
156        std::cmp::Ordering::Equal => false,
157    }
158}
159
160/// The per-line fraction (and the warnings from sizing it) the AX scroll-bar
161/// fallback uses.
162///
163/// `value` is in `0..=1` and means "this much of the bar a wheel line moves."
164/// `warnings` carries [`APPROXIMATED_WARNING`] when the size data was missing
165/// and [`CONTENT_FITS_WARNING`] when the document already fits inside the
166/// visible viewport. A clean measurement has no warnings.
167#[derive(Debug, Clone, PartialEq)]
168pub struct Step {
169    /// Per-line fraction of the bar (0..=1).
170    pub value: f64,
171    /// Non-fatal sizing notes. Empty on a clean measurement.
172    pub warnings: Vec<String>,
173}
174
175impl Step {
176    /// A measured step with no warnings.
177    pub const fn measured(value: f64) -> Self {
178        Self {
179            value,
180            warnings: Vec::new(),
181        }
182    }
183
184    /// The fallback step the legacy `step_per_line(None)` produced: five
185    /// percent, no warnings. Used by callers that have no sizing data and
186    /// want the same step the old approximation delivered.
187    pub const fn approx() -> Self {
188        Self::measured(DEFAULT_STEP)
189    }
190}
191
192/// The per-line scroll step computed from the document content's size along
193/// the scroll axis and the visible viewport's size along the same axis.
194///
195/// `content` is the size of the scroll area's document child — the first
196/// `AXChildren` element that is not an `AXScrollBar` (typically
197/// `AXTextArea`, `AXTable`, `AXOutline`, or `AXGroup`). `visible` is the
198/// scroll area's own `AXSize` along the axis, minus the orthogonal scroll
199/// bar's height (or width) when both bars are present.
200///
201/// One wheel notch is [`LINE_POINTS`] points (≈ three text lines), so one
202/// wheel line moves `LINE_POINTS / (content - visible)` of the bar, clamped
203/// to `0..=1`. The unit tests pin every branch:
204///
205/// - `Some(2400), Some(400)` → `0.02` so ten wheel lines move `0.2` of the bar.
206/// - `content <= visible` → `0.0` with [`CONTENT_FITS_WARNING`] (nothing to
207///   scroll).
208/// - missing or non-finite sizes → [`DEFAULT_STEP`] with
209///   [`APPROXIMATED_WARNING`] (we did not measure, so the step is a guess).
210pub fn content_step(content: Option<f64>, visible: Option<f64>) -> Step {
211    match (content, visible) {
212        (Some(c), Some(v)) if c.is_finite() && v.is_finite() && v > 0.0 && c > v => {
213            Step::measured((LINE_POINTS / (c - v)).clamp(0.0, 1.0))
214        }
215        (Some(c), Some(v)) if c.is_finite() && v.is_finite() && v > 0.0 => Step {
216            value: 0.0,
217            warnings: vec![CONTENT_FITS_WARNING.to_string()],
218        },
219        _ => Step {
220            value: DEFAULT_STEP,
221            warnings: vec![APPROXIMATED_WARNING.to_string()],
222        },
223    }
224}
225
226/// Signed value delta for `steps` wheel lines using `step`. Positive is down or
227/// right.
228pub fn ax_delta(steps: i32, step: &Step) -> f64 {
229    f64::from(steps) * step.value
230}
231
232/// The clamped value to write for `steps` wheel lines from `before`.
233pub fn next_value(before: f64, steps: i32, step: &Step) -> f64 {
234    (before + ax_delta(steps, step)).clamp(0.0, 1.0)
235}
236
237/// Decide what the measurement means.
238///
239/// `before` and `after` are the scroll-bar values around the wheel event, or
240/// `None` when the bar could not be read. `step` is the sized per-line
241/// fraction; the caller passes the same `Step` from the read pass, so the
242/// `AxScrollbar` decision uses the same math that produced the warning. The
243/// order matters: a bar that moved wins over a bar that sits at its limit,
244/// because a bar sitting at its limit cannot have moved.
245pub fn decide(before: Option<f64>, after: Option<f64>, steps: i32, step: &Step) -> Decision {
246    let (Some(before), Some(after)) = (before, after) else {
247        return Decision::Unverifiable;
248    };
249    if !before.is_finite() || !after.is_finite() {
250        return Decision::Unverifiable;
251    }
252    if moved(before, after) {
253        return Decision::Event { before, after };
254    }
255    if at_limit(before, steps) {
256        return Decision::AtLimit { value: before };
257    }
258    Decision::AxScrollbar {
259        before,
260        next: next_value(before, steps, step),
261    }
262}
263
264/// What a scroll actually did, in the shape the response carries.
265#[derive(Debug, Clone, PartialEq)]
266pub struct Outcome {
267    /// Which mechanism produced the scroll.
268    pub via: Via,
269    /// True only when a scroll-bar value changed.
270    pub scrolled: bool,
271    /// Bar value before the scroll, when one could be read.
272    pub before: Option<f64>,
273    /// Bar value after the scroll, when one could be read.
274    pub after: Option<f64>,
275    /// Per-line fraction (0..=1) used for the `AXValue` write, or the value
276    /// we observed before honouring the event tier. `None` when the result
277    /// came back unreadable. Surfaced in the response for transparency.
278    pub step: Option<f64>,
279    /// Non-fatal notes. [`Outcome::warnings`] is empty on a clean event scroll.
280    pub warnings: Vec<String>,
281}
282
283impl Outcome {
284    /// The wheel event moved the bar.
285    pub fn event(before: f64, after: f64, step: f64) -> Self {
286        Self {
287            via: Via::Event,
288            scrolled: moved(before, after),
289            before: Some(before),
290            after: Some(after),
291            step: Some(step),
292            warnings: Vec::new(),
293        }
294    }
295
296    /// `AXValue` was written. `read_back` is the value re-read after the write.
297    pub fn ax_scrollbar(
298        before: f64,
299        read_back: Option<f64>,
300        step: f64,
301        warnings: Vec<String>,
302    ) -> Self {
303        let scrolled = read_back.is_some_and(|value| moved(before, value));
304        Self {
305            via: Via::AxScrollbar,
306            scrolled,
307            before: Some(before),
308            after: read_back,
309            step: Some(step),
310            warnings,
311        }
312    }
313
314    /// The bar is already at its limit, so nothing moved and nothing is wrong.
315    pub fn at_limit(axis: Axis, steps: i32, value: f64, step: Option<f64>) -> Self {
316        Self {
317            via: Via::Event,
318            scrolled: false,
319            before: Some(value),
320            after: Some(value),
321            step,
322            warnings: vec![format!(
323                "{} scroll bar is already at the {} limit",
324                axis.as_str(),
325                axis.limit(steps)
326            )],
327        }
328    }
329
330    /// The caller asked for no movement, as `scroll --dx 0 --dy 0` does.
331    ///
332    /// No axis is addressed, so there is no bar to report a value for. This is
333    /// `scrolled: false` with no warning: nothing was attempted and nothing
334    /// failed.
335    pub fn none() -> Self {
336        Self {
337            via: Via::Event,
338            scrolled: false,
339            before: None,
340            after: None,
341            step: None,
342            warnings: Vec::new(),
343        }
344    }
345}
346
347/// Why an AX scroll-bar fallback could not finish.
348///
349/// The backend turns the reason into `execution_failed`; nothing is reported
350/// as a scroll that did not happen.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum AxFailure {
353    /// The scroll bar refused the `AXValue` write.
354    Refused,
355    /// The value was written but reading it back did not confirm a change.
356    Unconfirmed,
357}
358
359impl AxFailure {
360    /// Reason text for the error message.
361    pub fn reason(self, axis: Axis) -> String {
362        match self {
363            Self::Refused => format!("AX refused the {} scroll bar value write", axis.as_str()),
364            Self::Unconfirmed => format!(
365                "the {} scroll bar value did not change after AX wrote it",
366                axis.as_str()
367            ),
368        }
369    }
370}
371
372/// Why the AX scroll-bar path is running.
373///
374/// This decides the warning. Naming the cause keeps the response honest: a
375/// caller who asked for `--tier ax` never posted a wheel event, so the
376/// "wheel event not delivered" note would be false for them.
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum AxCause {
379    /// A wheel event was posted first and did not move the bar.
380    WheelEventNotDelivered,
381    /// The caller named `--tier ax`, so the scroll bar was the plan all along.
382    ExplicitAxTier,
383}
384
385impl AxCause {
386    /// The warning this cause earns. An explicitly requested AX scroll needs none.
387    pub fn warning(self) -> Option<&'static str> {
388        match self {
389            Self::WheelEventNotDelivered => Some(AX_FALLBACK_WARNING),
390            Self::ExplicitAxTier => None,
391        }
392    }
393}
394
395/// The outcome of writing `AXValue`, or the reason it failed.
396///
397/// `requested` is the value [`Decision::AxScrollbar`] asked for and
398/// `read_back` is what the bar reports afterwards. A bar that reads back a
399/// different value than requested still counts as scrolled: the app may round
400/// to its own page, and that note is added as a second warning. The `step`'s
401/// own warnings are merged in, so a measurement that approximated or hit a
402/// `content fits` document surfaces in the response alongside the AX-fallback
403/// notes.
404pub fn ax_result(
405    axis: Axis,
406    before: f64,
407    requested: f64,
408    read_back: Option<f64>,
409    cause: AxCause,
410    step: &Step,
411) -> Result<Outcome, AxFailure> {
412    let scrolled = read_back.is_some_and(|value| moved(before, value));
413    if !scrolled {
414        return Err(AxFailure::Unconfirmed);
415    }
416    let mut warnings = step.warnings.clone();
417    if let Some(text) = cause.warning() {
418        warnings.push(text.to_string());
419    }
420    if let Some(value) = read_back {
421        if (value - requested).abs() > ROUNDING_TOLERANCE {
422            warnings.push(format!(
423                "{} scroll bar landed on {value:.3} instead of the requested {requested:.3}",
424                axis.as_str()
425            ));
426        }
427    }
428    Ok(Outcome::ax_scrollbar(
429        before, read_back, step.value, warnings,
430    ))
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn wheel_event_that_moved_the_bar_wins_over_everything() {
439        let step = Step::measured(0.2);
440        assert_eq!(
441            decide(Some(0.0), Some(0.2), 3, &step),
442            Decision::Event {
443                before: 0.0,
444                after: 0.2
445            }
446        );
447        // Upward: a negative delta lowers the value.
448        assert_eq!(
449            decide(Some(0.9), Some(0.7), -3, &step),
450            Decision::Event {
451                before: 0.9,
452                after: 0.7
453            }
454        );
455    }
456
457    #[test]
458    fn an_unmoved_bar_outside_its_limit_asks_for_the_ax_write() {
459        let step = Step::measured(0.1);
460        let Decision::AxScrollbar { before, next } = decide(Some(0.0), Some(0.0), 3, &step) else {
461            panic!("expected the AX scroll bar path");
462        };
463        assert_eq!(before, 0.0);
464        // 0.1 per line × 3 lines = 0.3
465        assert!((next - 0.3).abs() < 1e-9, "next was {next}");
466    }
467
468    #[test]
469    fn an_unmoved_bar_at_its_limit_is_not_a_fallback() {
470        let step = Step::measured(0.2);
471        assert_eq!(
472            decide(Some(1.0), Some(1.0), 3, &step),
473            Decision::AtLimit { value: 1.0 }
474        );
475        assert_eq!(
476            decide(Some(0.0), Some(0.0), -3, &step),
477            Decision::AtLimit { value: 0.0 }
478        );
479        // A bar at the bottom is not at its limit when scrolling up.
480        assert!(matches!(
481            decide(Some(1.0), Some(1.0), -3, &step),
482            Decision::AxScrollbar { .. }
483        ));
484        // A bar at the top is not at its limit when scrolling down.
485        assert!(matches!(
486            decide(Some(0.0), Some(0.0), 3, &step),
487            Decision::AxScrollbar { .. }
488        ));
489    }
490
491    #[test]
492    fn a_missing_or_unreadable_bar_is_unverifiable() {
493        let step = Step::approx();
494        assert_eq!(decide(None, Some(0.2), 3, &step), Decision::Unverifiable);
495        assert_eq!(decide(Some(0.0), None, 3, &step), Decision::Unverifiable);
496        assert_eq!(decide(None, None, 3, &step), Decision::Unverifiable);
497        assert_eq!(
498            decide(Some(f64::NAN), Some(0.2), 3, &step),
499            Decision::Unverifiable
500        );
501        assert_eq!(
502            decide(Some(f64::INFINITY), Some(0.2), 3, &step),
503            Decision::Unverifiable
504        );
505    }
506
507    #[test]
508    fn a_rounding_wobble_is_not_a_scroll() {
509        assert!(!moved(0.2, 0.2 + EPSILON / 2.0));
510        assert!(moved(0.2, 0.2 + EPSILON * 10.0));
511    }
512
513    #[test]
514    fn content_step_uses_line_points_over_the_content_overflow() {
515        // The README example: content 2400, visible 400 → 40 / (2400-400) = 0.02.
516        let step = content_step(Some(2400.0), Some(400.0));
517        assert!((step.value - 0.02).abs() < 1e-9, "step was {}", step.value);
518        assert!(step.warnings.is_empty(), "{:?}", step.warnings);
519
520        // A scroll area where the document fills 16% of the viewport.
521        let step = content_step(Some(1160.0), Some(1000.0));
522        // LINE_POINTS / 160 = 0.25
523        assert!((step.value - 0.25).abs() < 1e-9, "step was {}", step.value);
524    }
525
526    #[test]
527    fn content_step_clamps_into_zero_to_one() {
528        // A document that is so close to the viewport that LINE_POINTS would
529        // exceed 1.0 — clamp so one wheel line never jumps the whole bar.
530        let step = content_step(Some(1040.0), Some(1000.0));
531        assert!((step.value - 1.0).abs() < 1e-9, "step was {}", step.value);
532        assert!(step.warnings.is_empty());
533        assert!(step.value >= 0.0 && step.value <= 1.0);
534
535        // A tiny overflow is still positive: 40 / 40 = 1.0, the upper clamp.
536        let step = content_step(Some(10_001.0), Some(10_000.0));
537        assert!(step.value > 0.0, "step was {}", step.value);
538        assert!(step.value <= 1.0);
539    }
540
541    #[test]
542    fn content_step_says_content_fits_when_there_is_nothing_to_scroll() {
543        // Strict equality: document == viewport.
544        let step = content_step(Some(800.0), Some(800.0));
545        assert_eq!(step.value, 0.0);
546        assert_eq!(step.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
547
548        // Document smaller than viewport (oversized scroll area).
549        let step = content_step(Some(400.0), Some(800.0));
550        assert_eq!(step.value, 0.0);
551        assert_eq!(step.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
552    }
553
554    #[test]
555    fn content_step_falls_back_to_a_five_percent_step_when_sizing_is_missing() {
556        // No content and no visible → approximated, DEFAULT_STEP, warning.
557        let step = content_step(None, None);
558        assert_eq!(step.value, DEFAULT_STEP);
559        assert_eq!(step.warnings, vec![APPROXIMATED_WARNING.to_string()]);
560
561        // Half-sized → still missing the other side.
562        assert_eq!(
563            content_step(Some(1200.0), None).warnings,
564            vec![APPROXIMATED_WARNING.to_string()]
565        );
566        assert_eq!(
567            content_step(None, Some(800.0)).warnings,
568            vec![APPROXIMATED_WARNING.to_string()]
569        );
570
571        // Non-finite or non-positive sizes follow the same fallback path.
572        assert_eq!(
573            content_step(Some(f64::NAN), Some(800.0)).value,
574            DEFAULT_STEP
575        );
576        assert_eq!(
577            content_step(Some(1200.0), Some(0.0)).warnings,
578            vec![APPROXIMATED_WARNING.to_string()]
579        );
580        assert_eq!(
581            content_step(Some(1200.0), Some(-10.0)).warnings,
582            vec![APPROXIMATED_WARNING.to_string()]
583        );
584    }
585
586    #[test]
587    fn content_step_drives_next_value_for_ten_wheel_lines() {
588        let step = content_step(Some(2400.0), Some(400.0));
589        // 10 lines × 0.02 = 0.20
590        assert!((next_value(0.0, 10, &step) - 0.20).abs() < 1e-9);
591        assert!((next_value(0.5, -10, &step) - 0.30).abs() < 1e-9);
592        // Past the end clamps.
593        assert_eq!(next_value(0.95, 10, &step), 1.0);
594        assert_eq!(next_value(0.05, -10, &step), 0.0);
595    }
596
597    #[test]
598    fn step_approx_keeps_the_legacy_default_with_no_warnings() {
599        let step = Step::approx();
600        assert_eq!(step.value, DEFAULT_STEP);
601        assert!(step.warnings.is_empty());
602    }
603
604    #[test]
605    fn ax_delta_multiplies_steps_by_the_step_value() {
606        let step = content_step(Some(2400.0), Some(400.0));
607        assert!((ax_delta(10, &step) - 0.20).abs() < 1e-9);
608        assert!((ax_delta(-10, &step) + 0.20).abs() < 1e-9);
609        assert_eq!(ax_delta(0, &step), 0.0);
610
611        // The fallback step gives the legacy five-percent value.
612        let step = Step::approx();
613        assert!((ax_delta(3, &step) - 0.15).abs() < 1e-9);
614    }
615
616    #[test]
617    fn the_written_value_is_clamped_into_zero_to_one() {
618        let step = Step::approx();
619        assert_eq!(next_value(0.0, -5, &step), 0.0);
620        assert_eq!(next_value(1.0, 5, &step), 1.0);
621        assert!((next_value(0.5, 2, &step) - 0.6).abs() < 1e-9);
622        assert!((next_value(0.5, -2, &step) - 0.4).abs() < 1e-9);
623    }
624
625    #[test]
626    fn at_limit_only_answers_for_a_nonzero_step_in_that_direction() {
627        assert!(at_limit(1.0, 1));
628        assert!(at_limit(1.0 - EPSILON / 2.0, 1));
629        assert!(!at_limit(1.0, -1));
630        assert!(at_limit(0.0, -1));
631        // Within one epsilon of the edge counts as at the limit.
632        assert!(at_limit(EPSILON / 2.0, -1));
633        assert!(!at_limit(0.999_9, 1));
634        assert!(!at_limit(0.000_1, -1));
635        // A zero step asks for no movement, so it is never "at the limit".
636        assert!(!at_limit(1.0, 0));
637        assert!(!at_limit(0.0, 0));
638    }
639
640    #[test]
641    fn an_event_outcome_reports_a_move_only_when_the_value_changed() {
642        let moved_outcome = Outcome::event(0.0, 0.2, 0.05);
643        assert_eq!(moved_outcome.via, Via::Event);
644        assert!(moved_outcome.scrolled);
645        assert_eq!(moved_outcome.before, Some(0.0));
646        assert_eq!(moved_outcome.after, Some(0.2));
647        assert_eq!(moved_outcome.step, Some(0.05));
648        assert!(moved_outcome.warnings.is_empty());
649        assert!(!Outcome::event(0.5, 0.5, 0.05).scrolled);
650        assert_eq!(Outcome::event(0.5, 0.5, 0.05).step, Some(0.05));
651    }
652
653    #[test]
654    fn an_ax_outcome_carries_the_fallback_warning_and_step() {
655        let outcome =
656            Outcome::ax_scrollbar(0.0, Some(0.3), 0.1, vec![AX_FALLBACK_WARNING.to_string()]);
657        assert_eq!(outcome.via, Via::AxScrollbar);
658        assert!(outcome.scrolled);
659        assert_eq!(outcome.after, Some(0.3));
660        assert_eq!(outcome.step, Some(0.1));
661        assert_eq!(outcome.warnings, vec![AX_FALLBACK_WARNING.to_string()]);
662        assert_eq!(Via::AxScrollbar.as_str(), "ax_scrollbar");
663        assert_eq!(Via::Event.as_str(), "event");
664    }
665
666    #[test]
667    fn at_limit_says_so_without_claiming_a_scroll() {
668        let outcome = Outcome::at_limit(Axis::Vertical, 3, 1.0, Some(0.05));
669        assert!(!outcome.scrolled);
670        assert_eq!(outcome.before, Some(1.0));
671        assert_eq!(outcome.after, Some(1.0));
672        assert_eq!(outcome.step, Some(0.05));
673        assert_eq!(outcome.warnings.len(), 1);
674        assert!(
675            outcome.warnings[0].contains("bottom"),
676            "{:?}",
677            outcome.warnings
678        );
679        assert!(
680            Outcome::at_limit(Axis::Horizontal, -2, 0.0, Some(0.05)).warnings[0].contains("left")
681        );
682        assert_eq!(Axis::Vertical.bar_attribute(), "AXVerticalScrollBar");
683        assert_eq!(Axis::Horizontal.bar_attribute(), "AXHorizontalScrollBar");
684    }
685
686    #[test]
687    fn an_unconfirmed_ax_write_is_a_failure_not_a_scroll() {
688        let step = Step::approx();
689        assert_eq!(
690            ax_result(
691                Axis::Vertical,
692                0.0,
693                0.3,
694                Some(0.0),
695                AxCause::WheelEventNotDelivered,
696                &step,
697            ),
698            Err(AxFailure::Unconfirmed)
699        );
700        assert_eq!(
701            ax_result(
702                Axis::Vertical,
703                0.0,
704                0.3,
705                None,
706                AxCause::WheelEventNotDelivered,
707                &step,
708            ),
709            Err(AxFailure::Unconfirmed)
710        );
711        assert_eq!(
712            AxFailure::Refused.reason(Axis::Horizontal),
713            "AX refused the horizontal scroll bar value write"
714        );
715        assert!(AxFailure::Unconfirmed
716            .reason(Axis::Vertical)
717            .contains("did not change"));
718    }
719
720    #[test]
721    fn an_app_rounding_to_its_own_page_still_counts_as_scrolled() {
722        let step = Step::measured(0.1);
723        let outcome = ax_result(
724            Axis::Vertical,
725            0.0,
726            0.3,
727            Some(0.25),
728            AxCause::WheelEventNotDelivered,
729            &step,
730        )
731        .expect("scrolled");
732        assert!(outcome.scrolled);
733        assert_eq!(outcome.via, Via::AxScrollbar);
734        assert_eq!(outcome.after, Some(0.25));
735        assert_eq!(outcome.step, Some(0.1));
736        assert_eq!(outcome.warnings[0], AX_FALLBACK_WARNING);
737        assert!(
738            outcome.warnings[1].contains("0.250") && outcome.warnings[1].contains("0.300"),
739            "{:?}",
740            outcome.warnings
741        );
742    }
743
744    #[test]
745    fn ax_result_merges_step_warnings_alongside_the_fallback_note() {
746        // A measured step that was approximated: the response surfaces the
747        // step's note first, then the AX fallback note. read_back matches the
748        // request so no rounding warning is added.
749        let step = content_step(None, None);
750        let outcome = ax_result(
751            Axis::Vertical,
752            0.0,
753            0.15,
754            Some(0.15),
755            AxCause::WheelEventNotDelivered,
756            &step,
757        )
758        .expect("scrolled");
759        assert_eq!(outcome.step, Some(DEFAULT_STEP));
760        assert_eq!(
761            outcome.warnings,
762            vec![
763                APPROXIMATED_WARNING.to_string(),
764                AX_FALLBACK_WARNING.to_string()
765            ]
766        );
767
768        // A content-fits step on the explicit AX tier with the bar already at
769        // zero: ax_result reports Unconfirmed for a write that did not move
770        // the bar, regardless of how clean the step was.
771        let step = content_step(Some(800.0), Some(800.0));
772        let outcome = ax_result(
773            Axis::Vertical,
774            0.0,
775            0.0,
776            Some(0.0),
777            AxCause::ExplicitAxTier,
778            &step,
779        );
780        assert_eq!(outcome, Err(AxFailure::Unconfirmed));
781
782        // A content-fits step that moves the bar carries the step's warning
783        // but not the AX fallback note (explicit `--tier ax`).
784        let step = content_step(Some(800.0), Some(800.0));
785        let outcome = ax_result(
786            Axis::Vertical,
787            0.0,
788            0.0,
789            Some(0.001),
790            AxCause::ExplicitAxTier,
791            &step,
792        )
793        .expect("scrolled");
794        assert_eq!(outcome.step, Some(0.0));
795        assert_eq!(outcome.warnings, vec![CONTENT_FITS_WARNING.to_string()]);
796    }
797
798    #[test]
799    fn axes_pick_their_own_delta_and_direction() {
800        assert_eq!(Axis::Vertical.delta(3, 7), 7);
801        assert_eq!(Axis::Horizontal.delta(3, 7), 3);
802        assert_eq!(Axis::Vertical.limit(1), "bottom");
803        assert_eq!(Axis::Vertical.limit(-1), "top");
804        assert_eq!(Axis::Horizontal.limit(0), "right");
805        assert_eq!(Axis::both(), [Axis::Vertical, Axis::Horizontal]);
806        assert_eq!(Axis::Vertical.as_str(), "vertical");
807    }
808
809    #[test]
810    fn an_explicit_ax_scroll_never_claims_a_wheel_event_was_dropped() {
811        // The caller asked for the scroll bar, so no wheel event was posted and
812        // the "not delivered" note would be false.
813        let step = Step::measured(0.1);
814        let outcome = ax_result(
815            Axis::Vertical,
816            0.0,
817            0.3,
818            Some(0.3),
819            AxCause::ExplicitAxTier,
820            &step,
821        )
822        .expect("scrolled");
823        assert!(outcome.scrolled);
824        assert_eq!(outcome.via, Via::AxScrollbar);
825        assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
826        assert_eq!(AxCause::ExplicitAxTier.warning(), None);
827        assert_eq!(
828            AxCause::WheelEventNotDelivered.warning(),
829            Some(AX_FALLBACK_WARNING)
830        );
831        // A refusal is still a refusal whoever asked for the scroll bar.
832        assert_eq!(
833            ax_result(
834                Axis::Vertical,
835                0.0,
836                0.3,
837                Some(0.0),
838                AxCause::ExplicitAxTier,
839                &step,
840            ),
841            Err(AxFailure::Unconfirmed)
842        );
843    }
844
845    #[test]
846    fn a_zero_delta_scroll_is_not_a_scroll_and_not_a_failure() {
847        let outcome = Outcome::none();
848        assert!(!outcome.scrolled);
849        assert_eq!(outcome.before, None);
850        assert_eq!(outcome.after, None);
851        assert_eq!(outcome.step, None);
852        assert!(outcome.warnings.is_empty());
853        // No axis is addressed, so no bar exists to report and no limit applies.
854        assert!(!at_limit(0.0, 0));
855        assert!(!at_limit(1.0, 0));
856        let step = Step::measured(0.5);
857        assert_eq!(ax_delta(0, &step), 0.0);
858        assert_eq!(next_value(0.4, 0, &step), 0.4);
859    }
860
861    #[test]
862    fn the_measured_fallback_value_matches_the_decision() {
863        let step = Step::measured(0.3);
864        let Decision::AxScrollbar { before, next } = decide(Some(0.4), Some(0.4), 2, &step) else {
865            panic!("expected the AX scroll bar path");
866        };
867        assert_eq!(next, next_value(before, 2, &step));
868        let outcome = ax_result(
869            Axis::Vertical,
870            before,
871            next,
872            Some(next),
873            AxCause::WheelEventNotDelivered,
874            &step,
875        )
876        .expect("scrolled");
877        assert!(outcome.scrolled);
878        assert_eq!(outcome.after, Some(next));
879        assert_eq!(outcome.step, Some(0.3));
880    }
881}