Skip to main content

pdfrum_form/script/
transcript.rs

1//! What a script asked the host to do, as data the host reads.
2//!
3//! **A value, not a trait.** The script does not need the host's answer to
4//! continue, so the line goes on a list and the host reads the list on its own
5//! schedule.
6//!
7//! It is also the conformance comparison surface: a golden run compares the
8//! oracle's stdout, line-wise and byte-exact, against these lines rendered.
9//! [`TranscriptLine::render`] is that contract rather than a debugging
10//! convenience.
11
12use std::fmt::Write as _;
13
14/// One thing a script asked the host to do.
15///
16/// Ten shapes, because the goldens carry ten. Each variant's `render` output
17/// is quoted from the callback that produces it.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TranscriptLine {
20    /// `app.alert` — 1933 of the 2004 golden lines.
21    ///
22    /// The message is printed with a conditional decoration: see
23    /// [`TranscriptLine::render`].
24    Alert {
25        /// `cTitle`, defaulting to the literal `"Alert"`.
26        title: String,
27        /// `cMsg`. An array argument is joined first — see `app.alert`.
28        message: String,
29        /// `nIcon`, defaulting to 0.
30        icon: i32,
31        /// `nType`, defaulting to 0.
32        button: i32,
33    },
34    /// `app.beep(n)` — `ExampleAppBeep`.
35    Beep(i32),
36    /// `app.response(...)` — `ExampleAppResponse`. The host answers with the
37    /// empty string; nothing is prompted.
38    Response {
39        /// `cQuestion`.
40        question: String,
41        /// `cTitle`.
42        title: String,
43        /// `cDefault`.
44        default_value: String,
45        /// `cLabel`.
46        label: String,
47        /// `bPassword`.
48        password: bool,
49    },
50    /// `app.mailMsg` / `Doc.mailDoc` / `Doc.mailForm` — `ExampleDocMail`.
51    /// **Nothing is sent**: no network, no MAPI, no process spawn.
52    MailMsg {
53        /// `bUI`.
54        ui: bool,
55        /// `To`.
56        to: String,
57        /// `cc`.
58        cc: String,
59        /// `bcc`.
60        bcc: String,
61        /// `cSubject`.
62        subject: String,
63        /// `cMsg`.
64        body: String,
65    },
66    /// `Doc.print` — `ExampleDocPrint`. **Nothing is printed.**
67    Print {
68        /// `bUI`.
69        ui: bool,
70        /// `nStart`.
71        start: i32,
72        /// `nEnd`.
73        end: i32,
74        /// `bSilent`.
75        silent: bool,
76        /// `bShrinkToFit`.
77        shrink_to_fit: bool,
78        /// `bPrintAsImage`.
79        print_as_image: bool,
80        /// `bReverse`.
81        reverse: bool,
82        /// `bAnnotations`.
83        annotations: bool,
84    },
85    /// `Doc.submitForm` — `ExampleDocSubmitForm`.
86    ///
87    /// **The most dangerous entry point in the object model, and the one
88    /// where "hand back the data, do not act on it" matters most.** No request
89    /// is made; the URL and the bytes come back here for a host to decide
90    /// about.
91    SubmitForm {
92        /// Where the script wanted to post.
93        url: String,
94        /// What it wanted to post.
95        data: Vec<u8>,
96    },
97    /// `Doc.gotoNamedDest` and page navigation — `ExampleDocGotoPage`.
98    GotoPage(i32),
99    /// A named action fired from a non-JavaScript callback —
100    /// `ExampleNamedAction`. `named_action.in` is its one fixture.
101    NamedAction(String),
102    /// `console.println`. **The oracle discards it**, so this variant renders
103    /// to nothing and is carried only so a host can see what a script
104    /// logged.
105    ConsolePrintln(String),
106    /// An `AF*` function's own alert, which is not routed through `app.alert`.
107    ///
108    /// The **function's own name** is the title, always with `icon = 3` and
109    /// `button = 0` — so these render in the decorated form and appear
110    /// *unprefixed by `Alert:`*, interleaved with the plain lines. Getting
111    /// that interleaving right is a scoring requirement.
112    FunctionAlert {
113        /// The function's own name, e.g. `AFNumber_Keystroke`.
114        caller: String,
115        /// The message.
116        message: String,
117    },
118}
119
120/// The literal `cTitle` an `app.alert` with no title takes.
121pub const DEFAULT_ALERT_TITLE: &str = "Alert";
122
123impl TranscriptLine {
124    /// The line the oracle would print, without its newline.
125    ///
126    /// `None` for a shape that prints nothing — [`TranscriptLine::ConsolePrintln`]
127    /// alone, because the oracle's `console` methods are empty.
128    ///
129    /// # The alert decoration is conditional, and it is exact
130    ///
131    /// Three forms, chosen by what differs from the default:
132    ///
133    /// | condition | line |
134    /// |---|---|
135    /// | default title, icon and type | `Alert: <msg>` |
136    /// | non-default title only | `<title>: <msg>` |
137    /// | non-default icon **or** type | `<title>[icon=N,type=N]: <msg>` |
138    ///
139    /// An `AF*` alert always takes the third form with `icon=3,type=0`, so it
140    /// reads `AFNumber_Keystroke[icon=3,type=0]: The input value is invalid.`
141    /// with no `Alert:` prefix.
142    #[must_use]
143    pub fn render(&self) -> Option<String> {
144        let mut out = String::new();
145        match self {
146            TranscriptLine::Alert {
147                title,
148                message,
149                icon,
150                button,
151            } => {
152                if *icon != 0 || *button != 0 {
153                    let _ = write!(out, "{title}[icon={icon},type={button}]: {message}");
154                } else {
155                    let _ = write!(out, "{title}: {message}");
156                }
157            }
158            TranscriptLine::FunctionAlert { caller, message } => {
159                // `AlertIfPossible` is always icon 3, type 0.
160                let _ = write!(out, "{caller}[icon=3,type=0]: {message}");
161            }
162            TranscriptLine::Beep(kind) => {
163                let _ = write!(out, "BEEP!!! {kind}");
164            }
165            TranscriptLine::Response {
166                question,
167                title,
168                default_value,
169                label,
170                password,
171            } => {
172                // `"%ls: %ls, …"` — **title first, then the question**, and
173                // `length` is the *buffer* the host offered rather than
174                // anything the script said. `pdfium_test` always passes 2048.
175                let _ = write!(
176                    out,
177                    "{title}: {question}, defaultValue={default_value}, label={label}, \
178                     isPassword={}, length=2048",
179                    u8::from(*password)
180                );
181            }
182            TranscriptLine::MailMsg {
183                ui,
184                to,
185                cc,
186                bcc,
187                subject,
188                body,
189            } => {
190                let _ = write!(
191                    out,
192                    "Mail Msg: {}, to={to}, cc={cc}, bcc={bcc}, subject={subject}, body={body}",
193                    u8::from(*ui)
194                );
195            }
196            TranscriptLine::Print {
197                ui,
198                start,
199                end,
200                silent,
201                shrink_to_fit,
202                print_as_image,
203                reverse,
204                annotations,
205            } => {
206                let _ = write!(
207                    out,
208                    "Doc Print: {}, {start}, {end}, {}, {}, {}, {}, {}",
209                    u8::from(*ui),
210                    u8::from(*silent),
211                    u8::from(*shrink_to_fit),
212                    u8::from(*print_as_image),
213                    u8::from(*reverse),
214                    u8::from(*annotations)
215                );
216            }
217            TranscriptLine::SubmitForm { url, data } => {
218                // Two lines: the header, then **every byte on one line**,
219                // each as `" %02x"` — a *leading* space, no wrapping and no
220                // indent, however long the form is. `submitform_expected.txt`
221                // carries a 174-byte dump as a single line.
222                let _ = writeln!(
223                    out,
224                    "Doc Submit Form: url={url} + {} data bytes:",
225                    data.len()
226                );
227                for byte in data {
228                    let _ = write!(out, " {byte:02x}");
229                }
230            }
231            TranscriptLine::GotoPage(page) => {
232                let _ = write!(out, "Goto Page: {page}");
233            }
234            TranscriptLine::NamedAction(name) => {
235                let _ = write!(out, "Execute named action: {name}");
236            }
237            // Upstream's `console` is four empty functions.
238            TranscriptLine::ConsolePrintln(_) => return None,
239        }
240        Some(out)
241    }
242
243    /// A default-decoration alert: the shape 1922 of the 2004 golden lines
244    /// take.
245    #[must_use]
246    pub fn alert(message: impl Into<String>) -> TranscriptLine {
247        TranscriptLine::Alert {
248            title: DEFAULT_ALERT_TITLE.to_string(),
249            message: message.into(),
250            icon: 0,
251            button: 0,
252        }
253    }
254}
255
256/// Renders a whole transcript the way the oracle writes stdout: one line
257/// each, newline-terminated, lines that print nothing omitted.
258#[must_use]
259pub fn render(lines: &[TranscriptLine]) -> String {
260    let mut out = String::new();
261    for line in lines {
262        if let Some(rendered) = line.render() {
263            out.push_str(&rendered);
264            out.push('\n');
265        }
266    }
267    out
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    /// The default form, which is 1922 of the 2004 golden lines.
275    #[test]
276    fn a_default_alert_is_prefixed_with_the_literal_alert() {
277        assert_eq!(
278            TranscriptLine::alert("Hello").render().as_deref(),
279            Some("Alert: Hello")
280        );
281    }
282
283    /// A non-default title replaces the prefix rather than adding to it.
284    #[test]
285    fn a_titled_alert_prints_its_own_title() {
286        let line = TranscriptLine::Alert {
287            title: "Warning".to_string(),
288            message: "careful".to_string(),
289            icon: 0,
290            button: 0,
291        };
292        assert_eq!(line.render().as_deref(), Some("Warning: careful"));
293    }
294
295    /// A non-default icon **or** type adds the bracket, and both are printed
296    /// whichever one differs.
297    #[test]
298    fn a_decorated_alert_prints_both_numbers() {
299        let with_icon = TranscriptLine::Alert {
300            title: "Alert".to_string(),
301            message: "m".to_string(),
302            icon: 3,
303            button: 0,
304        };
305        assert_eq!(
306            with_icon.render().as_deref(),
307            Some("Alert[icon=3,type=0]: m")
308        );
309
310        let with_type = TranscriptLine::Alert {
311            title: "Alert".to_string(),
312            message: "m".to_string(),
313            icon: 0,
314            button: 2,
315        };
316        assert_eq!(
317            with_type.render().as_deref(),
318            Some("Alert[icon=0,type=2]: m")
319        );
320    }
321
322    /// An `AF*` alert takes the third form with the function's own name and
323    /// no `Alert:` prefix — `public_methods_expected.txt`'s interleaving.
324    #[test]
325    fn a_function_alert_is_titled_by_its_caller() {
326        let line = TranscriptLine::FunctionAlert {
327            caller: "AFNumber_Keystroke".to_string(),
328            message: "The input value is invalid.".to_string(),
329        };
330        assert_eq!(
331            line.render().as_deref(),
332            Some("AFNumber_Keystroke[icon=3,type=0]: The input value is invalid.")
333        );
334    }
335
336    /// `console.println` prints nothing, because upstream's four console
337    /// methods are empty — a golden expecting output would pin behaviour
338    /// PDFium does not have.
339    #[test]
340    fn console_output_is_discarded_as_upstream_discards_it() {
341        assert_eq!(
342            TranscriptLine::ConsolePrintln("x".to_string()).render(),
343            None
344        );
345        // And it is omitted from the rendered transcript rather than leaving
346        // a blank line, which a line-wise diff would see.
347        let rendered = render(&[
348            TranscriptLine::alert("one"),
349            TranscriptLine::ConsolePrintln("hidden".to_string()),
350            TranscriptLine::alert("two"),
351        ]);
352        assert_eq!(rendered, "Alert: one\nAlert: two\n");
353    }
354
355    #[test]
356    fn the_other_seven_shapes_render_their_golden_form() {
357        assert_eq!(
358            TranscriptLine::Beep(2).render().as_deref(),
359            Some("BEEP!!! 2")
360        );
361        assert_eq!(
362            TranscriptLine::GotoPage(4).render().as_deref(),
363            Some("Goto Page: 4")
364        );
365        assert_eq!(
366            TranscriptLine::NamedAction("Print".to_string())
367                .render()
368                .as_deref(),
369            Some("Execute named action: Print")
370        );
371        assert_eq!(
372            TranscriptLine::Response {
373                question: "q".to_string(),
374                title: "t".to_string(),
375                default_value: String::new(),
376                label: String::new(),
377                password: false,
378            }
379            .render()
380            .as_deref(),
381            Some("t: q, defaultValue=, label=, isPassword=0, length=2048")
382        );
383        assert_eq!(
384            TranscriptLine::MailMsg {
385                ui: true,
386                to: String::new(),
387                cc: String::new(),
388                bcc: String::new(),
389                subject: String::new(),
390                body: String::new(),
391            }
392            .render()
393            .as_deref(),
394            Some("Mail Msg: 1, to=, cc=, bcc=, subject=, body=")
395        );
396        assert_eq!(
397            TranscriptLine::Print {
398                ui: false,
399                start: 0,
400                end: 0,
401                silent: false,
402                shrink_to_fit: false,
403                print_as_image: false,
404                reverse: false,
405                annotations: false,
406            }
407            .render()
408            .as_deref(),
409            Some("Doc Print: 0, 0, 0, 0, 0, 0, 0, 0")
410        );
411    }
412
413    /// **The dump does not wrap.** Every byte goes on one line as `" %02x"`,
414    /// however long the form is — `submitform_expected.txt` carries 174 bytes
415    /// as a single line, and a reimplementation that wrapped at sixteen would
416    /// fail a byte-exact diff on both of the two fixtures that reach it.
417    #[test]
418    fn a_submitted_form_dumps_every_byte_on_one_line() {
419        let line = TranscriptLine::SubmitForm {
420            url: "https://example.com".to_string(),
421            // `submitform.in`'s shorter dump, verbatim: "name=Tralfaz&age=12".
422            data: b"name=Tralfaz&age=12".to_vec(),
423        };
424        let rendered = line.render().unwrap_or_default();
425        let mut lines = rendered.lines();
426        assert_eq!(
427            lines.next(),
428            Some("Doc Submit Form: url=https://example.com + 19 data bytes:")
429        );
430        assert_eq!(
431            lines.next(),
432            Some(" 6e 61 6d 65 3d 54 72 61 6c 66 61 7a 26 61 67 65 3d 31 32")
433        );
434        assert_eq!(lines.next(), None);
435    }
436}