pixelcoords_core/report.rs
1//! The document every scoring command prints.
2//!
3//! `assert`, `find`, `resolve`, `wait`, and `diff` answer one class of
4//! question — what is true on screen right now — and a caller that reads
5//! two of them should not have to write two parsers. One envelope, one
6//! schema counter, one place the aggregate answer lives.
7//!
8//! The aggregate is `ok`, and it is the only thing that moves up here.
9//! Row-level answers stay on the rows: `assert --stdin` returns one
10//! verdict per input line, and a caller scoring a trajectory needs to
11//! know *which* click missed, not merely that one did.
12//!
13//! `doctor` and `windows` keep their own documents. They report on the
14//! machine rather than on a session, and forcing them into `results[]`
15//! would buy nothing.
16
17use serde::Serialize;
18
19/// The schema version of every document in this module.
20///
21/// Starts at 2 rather than 1: it replaces two independent counters that
22/// were both sitting at 1, and a consumer that pinned either of them
23/// would otherwise see a version it recognizes on a shape it does not.
24pub const CLI_SCHEMA_VERSION: u32 = 2;
25
26/// Which command produced a document, so a consumer reading a stored
27/// report does not have to infer it from which fields are present.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Command {
31 Assert,
32 Find,
33 Resolve,
34 Wait,
35 Diff,
36}
37
38/// A command's answer: the rows it produced, and whether they pass.
39///
40/// Generic over the row type rather than an enum of every command's
41/// shape, so adding a command adds a row type instead of editing a type
42/// every existing consumer matches on.
43#[derive(Debug, Clone, PartialEq, Serialize)]
44pub struct Report<T> {
45 pub schema: u32,
46 pub command: Command,
47 /// When the frames behind this answer were taken. Absent for the
48 /// commands that do not capture — `assert`, and `resolve` without
49 /// `--relocate`. Supplied by the caller; this crate has no clock.
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub captured_utc: Option<String>,
52 /// How many times the screen was polled to reach this answer, and how
53 /// long that took. Present only for commands that loop — `wait` — and
54 /// provenance in the same sense `captured_utc` is: they describe how
55 /// the answer was obtained, never what it is.
56 ///
57 /// `elapsed_ms` is measured rather than derived. `wait` decides when
58 /// to stop by counting polls, not by watching a clock, so the two do
59 /// not imply each other: capture time is real and is not budgeted.
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub polls: Option<u32>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub elapsed_ms: Option<u64>,
64 /// The aggregate the exit code mirrors: 0 when true, 1 when false.
65 ///
66 /// Each command computes this from its own rows, in its own module.
67 /// What makes a command report failure is the most important line it
68 /// has, and it belongs where a reader will look for it rather than in
69 /// a trait implementation five files away.
70 pub ok: bool,
71 pub results: Vec<T>,
72}
73
74impl<T> Report<T> {
75 /// A document from a command that captured.
76 pub fn captured(command: Command, captured_utc: String, ok: bool, results: Vec<T>) -> Self {
77 Self {
78 schema: CLI_SCHEMA_VERSION,
79 command,
80 captured_utc: Some(captured_utc),
81 polls: None,
82 elapsed_ms: None,
83 ok,
84 results,
85 }
86 }
87
88 /// A document from a command that answered from the session alone.
89 pub fn offline(command: Command, ok: bool, results: Vec<T>) -> Self {
90 Self {
91 schema: CLI_SCHEMA_VERSION,
92 command,
93 captured_utc: None,
94 polls: None,
95 elapsed_ms: None,
96 ok,
97 results,
98 }
99 }
100
101 /// Record how much polling produced this answer.
102 ///
103 /// Nothing calls this: `wait` sets `polls` and `elapsed_ms` on the
104 /// fields directly. Kept for now rather than removed mid-patch,
105 /// because dropping a public item from a published crate is a break
106 /// and belongs in a minor.
107 #[must_use]
108 pub fn polled(mut self, polls: u32, elapsed_ms: u64) -> Self {
109 self.polls = Some(polls);
110 self.elapsed_ms = Some(elapsed_ms);
111 self
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[derive(Debug, Clone, PartialEq, Serialize)]
120 struct Row {
121 hit: bool,
122 }
123
124 #[test]
125 fn a_captured_document_carries_its_timestamp() {
126 let report = Report::captured(
127 Command::Find,
128 "2026-07-31T00:00:00Z".into(),
129 true,
130 vec![Row { hit: true }],
131 );
132 let json = serde_json::to_value(&report).unwrap();
133 assert_eq!(json["schema"], 2);
134 assert_eq!(json["command"], "find");
135 assert_eq!(json["captured_utc"], "2026-07-31T00:00:00Z");
136 assert_eq!(json["ok"], true);
137 assert_eq!(json["results"][0]["hit"], true);
138 }
139
140 #[test]
141 fn an_offline_document_omits_the_timestamp_rather_than_nulling_it() {
142 let report = Report::offline(Command::Assert, false, vec![Row { hit: false }]);
143 let json = serde_json::to_value(&report).unwrap();
144 assert!(
145 json.get("captured_utc").is_none(),
146 "a command that did not capture has no capture time to report, \
147 and null would invite a consumer to parse one"
148 );
149 assert_eq!(json["command"], "assert");
150 assert_eq!(json["ok"], false);
151 }
152
153 #[test]
154 fn row_answers_survive_the_aggregate() {
155 // The whole reason `hit` does not move up to `ok`: a caller
156 // scoring a trajectory needs to know which click missed.
157 let report = Report::offline(
158 Command::Assert,
159 false,
160 vec![Row { hit: true }, Row { hit: false }],
161 );
162 let json = serde_json::to_value(&report).unwrap();
163 assert_eq!(json["ok"], false);
164 assert_eq!(json["results"][0]["hit"], true);
165 assert_eq!(json["results"][1]["hit"], false);
166 }
167
168 #[test]
169 fn every_command_serializes_lowercase() {
170 for (command, name) in [
171 (Command::Assert, "assert"),
172 (Command::Find, "find"),
173 (Command::Resolve, "resolve"),
174 (Command::Wait, "wait"),
175 (Command::Diff, "diff"),
176 ] {
177 assert_eq!(serde_json::to_value(command).unwrap(), name);
178 }
179 }
180
181 #[test]
182 fn only_a_polling_command_reports_polls() {
183 // The two fields are provenance for the one command that loops.
184 // Every other document must not grow them, or a consumer would
185 // start expecting a poll count from `assert`.
186 let still = Report::offline(Command::Assert, true, vec![Row { hit: true }]);
187 let json = serde_json::to_value(&still).unwrap();
188 assert!(json.get("polls").is_none() && json.get("elapsed_ms").is_none());
189
190 let polled = Report::captured(Command::Wait, "t".into(), true, vec![Row { hit: true }])
191 .polled(61, 30_412);
192 let json = serde_json::to_value(&polled).unwrap();
193 assert_eq!(json["polls"], 61);
194 assert_eq!(
195 json["elapsed_ms"], 30_412,
196 "measured, not derived from the budget — capture time is real"
197 );
198 }
199
200 #[test]
201 fn an_empty_result_set_still_serializes_as_an_array() {
202 let report: Report<Row> = Report::offline(Command::Diff, true, Vec::new());
203 let json = serde_json::to_value(&report).unwrap();
204 assert!(
205 json["results"].is_array(),
206 "a consumer indexing results[] must not have to handle null"
207 );
208 }
209}