Skip to main content

clawless_core/prompt/
scripted_user.rs

1use std::collections::VecDeque;
2
3use super::{AnswerPromptError, ScriptedAnswer};
4use crate::event::prompt::PromptRequest;
5use crate::event::{Event, EventReceiver};
6
7/// A user for tests, who answers every prompt from a script
8///
9/// `ScriptedUser` reads the events of a command in place of a presenter. It answers each prompt
10/// with the next answer of its script, and it uses each answer once.
11///
12/// A prompt fails with [`AnswerPromptError::UnscriptedPrompt`] when the script has ended, and
13/// when the next answer is for another kind of prompt. That answer is used up as well.
14///
15/// Build the [`Context`] or the [`Prompt`] of the test with [`Interactivity::Interactive`].
16/// Otherwise the prompt fails before it sends a request.
17///
18/// # Examples
19///
20/// ```
21/// use clawless_core::context::Interactivity;
22/// use clawless_core::event::event_channel;
23/// use clawless_core::output::Output;
24/// use clawless_core::prompt::{Confirmation, Prompt, ScriptedAnswer, ScriptedUser};
25///
26/// # #[tokio::main]
27/// # async fn main() {
28/// let (sender, receiver) = event_channel();
29/// let user = ScriptedUser::new([ScriptedAnswer::Confirm(Confirmation::Yes)]);
30/// tokio::spawn(user.attend(receiver));
31///
32/// let prompt = Prompt::builder()
33///     .output(Output::new(sender))
34///     .interactivity(Interactivity::Interactive)
35///     .build();
36///
37/// let answer = prompt.confirm("Delete the branch?").await.expect("should answer");
38///
39/// assert_eq!(answer, Confirmation::Yes);
40/// # }
41/// ```
42///
43/// [`Context`]: crate::context::Context
44/// [`Interactivity::Interactive`]: crate::context::Interactivity::Interactive
45/// [`Prompt`]: super::Prompt
46#[derive(Clone, Eq, PartialEq, Hash, Debug, Default)]
47pub struct ScriptedUser {
48    /// The answers that the user has not given yet
49    answers: VecDeque<ScriptedAnswer>,
50}
51
52impl ScriptedUser {
53    /// Creates a user who gives the answers in the order of the script
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use clawless_core::prompt::{Confirmation, ScriptedAnswer, ScriptedUser};
59    ///
60    /// let user = ScriptedUser::new([
61    ///     ScriptedAnswer::Confirm(Confirmation::Yes),
62    ///     ScriptedAnswer::Confirm(Confirmation::No),
63    /// ]);
64    /// ```
65    pub fn new(answers: impl IntoIterator<Item = ScriptedAnswer>) -> Self {
66        Self {
67            answers: answers.into_iter().collect(),
68        }
69    }
70
71    /// Reads the events of a command and answers its prompts, until the command is done
72    ///
73    /// The call returns when the channel closes, which happens when the command drops its
74    /// output. It returns every event that was not a prompt, in the order of arrival.
75    ///
76    /// Run this in a task of its own, because the command waits for each answer.
77    pub async fn attend(mut self, mut receiver: EventReceiver) -> Vec<Event> {
78        let mut events = Vec::new();
79
80        while let Some(event) = receiver.recv().await {
81            match event {
82                Event::Prompt(request) => self.answer(*request),
83                Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Process(_) => {
84                    events.push(event);
85                }
86            }
87        }
88
89        events
90    }
91
92    /// Answers one prompt with the next answer of the script
93    fn answer(&mut self, request: PromptRequest) {
94        match (request, self.answers.pop_front()) {
95            (PromptRequest::Confirm { reply, .. }, Some(ScriptedAnswer::Confirm(answer))) => {
96                reply.answer(answer);
97            }
98            (PromptRequest::Select { reply, .. }, Some(ScriptedAnswer::Select(answer))) => {
99                reply.answer(answer);
100            }
101            (PromptRequest::Text { reply, .. }, Some(ScriptedAnswer::Text(answer))) => {
102                reply.answer(answer);
103            }
104            (
105                request @ PromptRequest::Confirm { .. },
106                Some(ScriptedAnswer::Select(_) | ScriptedAnswer::Text(_)) | None,
107            ) => request.fail(AnswerPromptError::UnscriptedPrompt),
108            (
109                request @ PromptRequest::Select { .. },
110                Some(ScriptedAnswer::Confirm(_) | ScriptedAnswer::Text(_)) | None,
111            ) => request.fail(AnswerPromptError::UnscriptedPrompt),
112            (
113                request @ PromptRequest::Text { .. },
114                Some(ScriptedAnswer::Confirm(_) | ScriptedAnswer::Select(_)) | None,
115            ) => request.fail(AnswerPromptError::UnscriptedPrompt),
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    // An assertion in a test panics by design. A `# Panics` section on every test
123    // would repeat that and give the reader no information.
124    #![allow(clippy::missing_panics_doc)]
125
126    use super::*;
127    use crate::context::Interactivity;
128    use crate::event::event_channel;
129    use crate::output::Output;
130    use crate::prompt::{Confirmation, Prompt, PromptUserError};
131
132    /// Returns whether the error reports a prompt for which the script held no answer
133    fn is_unscripted(error: &PromptUserError) -> bool {
134        match error {
135            PromptUserError::UnansweredPrompt { source, .. } => match source {
136                AnswerPromptError::UnscriptedPrompt => true,
137                AnswerPromptError::ClosedInput => false,
138                AnswerPromptError::DroppedRequest => false,
139                AnswerPromptError::UnusableTerminal { .. } => false,
140            },
141            PromptUserError::AbsentUser { .. } => false,
142            PromptUserError::CancelledPrompt { .. } => false,
143            PromptUserError::UndeliverablePrompt { .. } => false,
144            PromptUserError::MissingOptions { .. } => false,
145            PromptUserError::UnknownOption { .. } => false,
146        }
147    }
148
149    /// Returns a prompt whose user follows the script, and the task in which that user attends
150    fn scripted(
151        answers: impl IntoIterator<Item = ScriptedAnswer>,
152    ) -> (Prompt, tokio::task::JoinHandle<Vec<Event>>) {
153        let (sender, receiver) = event_channel();
154        let user = tokio::spawn(ScriptedUser::new(answers).attend(receiver));
155        let prompt = Prompt::builder()
156            .output(Output::new(sender))
157            .interactivity(Interactivity::Interactive)
158            .build();
159
160        (prompt, user)
161    }
162
163    #[tokio::test]
164    async fn attend_answers_a_confirmation_from_the_script() {
165        let (prompt, _user) = scripted([ScriptedAnswer::Confirm(Confirmation::Yes)]);
166
167        let answer = prompt.confirm("Release?").await.expect("should answer");
168
169        assert_eq!(answer, Confirmation::Yes);
170    }
171
172    #[tokio::test]
173    async fn attend_answers_in_the_order_of_the_script() {
174        let (prompt, _user) = scripted([
175            ScriptedAnswer::Confirm(Confirmation::Yes),
176            ScriptedAnswer::Confirm(Confirmation::No),
177        ]);
178        drop(prompt.confirm("Release?").await);
179
180        let answer = prompt.confirm("Publish?").await.expect("should answer");
181
182        assert_eq!(answer, Confirmation::No);
183    }
184
185    #[tokio::test]
186    async fn attend_answers_a_selection_from_the_script() {
187        let (prompt, _user) = scripted([ScriptedAnswer::Select(1)]);
188
189        let answer = prompt
190            .select("Port", [80, 443])
191            .await
192            .expect("should answer");
193
194        assert_eq!(answer, 443);
195    }
196
197    #[tokio::test]
198    async fn attend_answers_a_text_prompt_from_the_script() {
199        let (prompt, _user) = scripted([ScriptedAnswer::Text("Fix the race".to_owned())]);
200
201        let answer = prompt.text("Title").await.expect("should answer");
202
203        assert_eq!(answer, "Fix the race");
204    }
205
206    #[tokio::test]
207    async fn attend_returns_the_events_that_are_no_prompts() {
208        let (sender, receiver) = event_channel();
209        let user = tokio::spawn(ScriptedUser::default().attend(receiver));
210        let output = Output::new(sender);
211        output.message("released").await.expect("should send");
212        drop(output);
213
214        let events = user.await.expect("should join");
215
216        assert_eq!(
217            events
218                .into_iter()
219                .map(|event| match event {
220                    Event::Message(text) => text,
221                    Event::Detail(text) => text,
222                    Event::Artifact(artifact) => artifact.to_string(),
223                    Event::Process(event) => event.to_string(),
224                    Event::Prompt(request) => format!("{request:?}"),
225                })
226                .collect::<Vec<_>>(),
227            vec!["released".to_owned()]
228        );
229    }
230
231    #[tokio::test]
232    async fn attend_with_an_answer_for_another_kind_fails_the_prompt() {
233        let (prompt, _user) = scripted([ScriptedAnswer::Confirm(Confirmation::Yes)]);
234
235        let error = prompt.text("Title").await.expect_err("should fail");
236
237        assert!(is_unscripted(&error));
238    }
239
240    #[tokio::test]
241    async fn attend_with_an_empty_script_fails_the_prompt() {
242        let (prompt, _user) = scripted([]);
243
244        let error = prompt.confirm("Release?").await.expect_err("should fail");
245
246        assert!(is_unscripted(&error));
247    }
248
249    #[test]
250    fn trait_send() {
251        fn assert_send<T: Send>() {}
252        assert_send::<ScriptedUser>();
253    }
254
255    #[test]
256    fn trait_sync() {
257        fn assert_sync<T: Sync>() {}
258        assert_sync::<ScriptedUser>();
259    }
260
261    #[test]
262    fn trait_unpin() {
263        fn assert_unpin<T: Unpin>() {}
264        assert_unpin::<ScriptedUser>();
265    }
266}