Skip to main content

pixelcoords_core/
points.rs

1//! The point stream `assert --stdin` reads: one point per line, with an
2//! optional per-line expectation.
3//!
4//! Scoring an agent trajectory means asking the same question of hundreds
5//! of clicks, and a stream that silently skips what it cannot read is
6//! worse than one that stops: a run that scored 400 of 500 points and
7//! said nothing looks exactly like a run that scored all 500. So every
8//! malformed line is an error naming what was wrong with it, and the
9//! caller reports which line it was.
10//!
11//! Blank lines and `#` comments are carried through the grammar rather
12//! than skipped by the caller, because a hand-written fixture file is a
13//! normal thing to want and stripping them at the wrong layer means each
14//! caller reinvents it.
15
16use thiserror::Error;
17
18use crate::geometry::Point;
19
20/// One line of the stream.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Line {
23    /// Empty or whitespace only.
24    Blank,
25    /// A `#` comment.
26    Comment,
27    /// A point, and the label it is expected to land in when the line
28    /// carried one. A per-line expectation overrides the run's `--expect`
29    /// for that line only, so one stream can score a heterogeneous
30    /// trajectory.
31    Point {
32        point: Point,
33        expect: Option<String>,
34    },
35}
36
37#[derive(Debug, Error, PartialEq, Eq)]
38pub enum PointError {
39    #[error("{input:?} is not X,Y[,label] — it has no comma")]
40    NoComma { input: String },
41    #[error("{value:?} in {input:?} is not a whole number of pixels")]
42    NotANumber { value: String, input: String },
43    #[error(
44        "{input:?} ends in a comma with no label — drop the comma, or name \
45         the region the point should land in"
46    )]
47    EmptyLabel { input: String },
48}
49
50/// Parse one line: `X,Y`, `X,Y,label`, blank, or `# comment`.
51///
52/// Coordinates are whole physical pixels and may be negative — a monitor
53/// left of the primary has negative global coordinates. A label may
54/// itself contain commas; everything after the second comma is the label,
55/// so `100,200,row 3, column 4` labels the point `"row 3, column 4"`.
56pub fn parse_line(text: &str) -> Result<Line, PointError> {
57    let trimmed = text.trim();
58    if trimmed.is_empty() {
59        return Ok(Line::Blank);
60    }
61    if trimmed.starts_with('#') {
62        return Ok(Line::Comment);
63    }
64
65    let mut parts = trimmed.splitn(3, ',');
66    let (Some(x), Some(y)) = (parts.next(), parts.next()) else {
67        return Err(PointError::NoComma {
68            input: trimmed.to_string(),
69        });
70    };
71    let point = Point::new(coord(x, trimmed)?, coord(y, trimmed)?);
72
73    let expect = match parts.next() {
74        None => None,
75        Some(label) => {
76            let label = label.trim();
77            if label.is_empty() {
78                return Err(PointError::EmptyLabel {
79                    input: trimmed.to_string(),
80                });
81            }
82            Some(label.to_string())
83        }
84    };
85    Ok(Line::Point { point, expect })
86}
87
88fn coord(value: &str, input: &str) -> Result<i32, PointError> {
89    value
90        .trim()
91        .parse::<i32>()
92        .map_err(|_| PointError::NotANumber {
93            value: value.trim().to_string(),
94            input: input.to_string(),
95        })
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn point(text: &str) -> Line {
103        parse_line(text).expect("a valid line")
104    }
105
106    #[test]
107    fn a_bare_pair_is_a_point_with_no_expectation() {
108        assert_eq!(
109            point("812,440"),
110            Line::Point {
111                point: Point::new(812, 440),
112                expect: None,
113            }
114        );
115    }
116
117    #[test]
118    fn a_third_field_is_the_expected_label() {
119        assert_eq!(
120            point("812,440,submit"),
121            Line::Point {
122                point: Point::new(812, 440),
123                expect: Some("submit".into()),
124            }
125        );
126    }
127
128    #[test]
129    fn surrounding_whitespace_is_ignored_everywhere() {
130        assert_eq!(point("  812 , 440 ,  submit  "), point("812,440,submit"));
131    }
132
133    #[test]
134    fn coordinates_may_be_negative() {
135        // A display left of the primary has negative global coordinates;
136        // refusing them would make half a desktop unscoreable.
137        assert_eq!(
138            point("-1920,-40"),
139            Line::Point {
140                point: Point::new(-1920, -40),
141                expect: None,
142            }
143        );
144    }
145
146    #[test]
147    fn a_label_may_contain_commas() {
148        // Only the first two commas are structural, so a label never has
149        // to be quoted or escaped.
150        assert_eq!(
151            point("1,2,row 3, column 4"),
152            Line::Point {
153                point: Point::new(1, 2),
154                expect: Some("row 3, column 4".into()),
155            }
156        );
157    }
158
159    #[test]
160    fn blanks_and_comments_are_part_of_the_grammar() {
161        assert_eq!(parse_line("").unwrap(), Line::Blank);
162        assert_eq!(parse_line("   \t ").unwrap(), Line::Blank);
163        assert_eq!(parse_line("# the login flow").unwrap(), Line::Comment);
164        assert_eq!(parse_line("   # indented").unwrap(), Line::Comment);
165    }
166
167    #[test]
168    fn a_line_with_no_comma_names_itself() {
169        assert_eq!(
170            parse_line("812").unwrap_err(),
171            PointError::NoComma {
172                input: "812".into()
173            }
174        );
175    }
176
177    #[test]
178    fn a_non_numeric_coordinate_names_the_offending_field() {
179        let err = parse_line("12,x").unwrap_err();
180        assert_eq!(
181            err,
182            PointError::NotANumber {
183                value: "x".into(),
184                input: "12,x".into(),
185            }
186        );
187        // The message has to carry both, or a caller reading a 1,000-line
188        // failure cannot tell which half was wrong.
189        let text = err.to_string();
190        assert!(text.contains("\"x\"") && text.contains("\"12,x\""));
191    }
192
193    #[test]
194    fn decimals_are_refused_rather_than_rounded() {
195        // Rounding silently would put the scored point somewhere the
196        // caller did not ask about.
197        assert!(matches!(
198            parse_line("12.5,40").unwrap_err(),
199            PointError::NotANumber { .. }
200        ));
201    }
202
203    #[test]
204    fn a_trailing_comma_is_an_error_not_an_empty_label() {
205        assert_eq!(
206            parse_line("1,2,").unwrap_err(),
207            PointError::EmptyLabel {
208                input: "1,2,".into()
209            }
210        );
211        assert!(matches!(
212            parse_line("1,2,   ").unwrap_err(),
213            PointError::EmptyLabel { .. }
214        ));
215    }
216
217    #[test]
218    fn an_out_of_range_coordinate_is_refused() {
219        assert!(matches!(
220            parse_line("99999999999,0").unwrap_err(),
221            PointError::NotANumber { .. }
222        ));
223    }
224}