xapi_rs/data/
result.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    data::{DataError, Extensions, Fingerprint, MyDuration, Score, Validate, ValidationError},
5    emit_error,
6};
7use core::fmt;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use serde_with::skip_serializing_none;
11use std::{hash::Hasher, str::FromStr};
12
13/// Structure capturing a [quantifiable xAPI outcome][1].
14///
15/// [1]: <https://opensource.ieee.org/xapi/xapi-base-standard-documentation/-/blob/main/9274.1.1%20xAPI%20Base%20Standard%20for%20LRSs.md#4224-result>
16#[skip_serializing_none]
17#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct XResult {
20    score: Option<Score>,
21    success: Option<bool>,
22    completion: Option<bool>,
23    response: Option<String>,
24    duration: Option<MyDuration>,
25    extensions: Option<Extensions>,
26}
27
28impl XResult {
29    /// Return an [XResult] _Builder_.
30    pub fn builder() -> XResultBuilder {
31        XResultBuilder::default()
32    }
33
34    /// When set, defines the _score_ of the participant in relation to the
35    /// success or quality of an experience.
36    pub fn score(&self) -> Option<&Score> {
37        self.score.as_ref()
38    }
39
40    /// When set, defines the _success_ or not of the participant in relation
41    /// to an experience.
42    pub fn success(&self) -> Option<bool> {
43        self.success
44    }
45
46    /// When set, defines a participant's _completion_ or not of an experience.
47    pub fn completion(&self) -> Option<bool> {
48        self.completion
49    }
50
51    /// When set, defines a participant's _response_ to an interaction.
52    pub fn response(&self) -> Option<&str> {
53        self.response.as_deref()
54    }
55
56    /// When set, defines a participant's period of time during which the
57    /// interaction occurred.
58    pub fn duration(&self) -> Option<&MyDuration> {
59        if self.duration.is_none() {
60            None
61        } else {
62            // Some(&self.duration.as_ref().unwrap().0)
63            self.duration.as_ref()
64        }
65    }
66
67    /// Return _duration_ truncated and in ISO8601 format; i.e. "P9DT9H9M9.99S"
68    pub fn duration_to_iso8601(&self) -> Option<String> {
69        self.duration.as_ref().map(|x| x.to_iso8601())
70    }
71
72    /// When set, defines a collection of additional free-form key/value
73    /// properties associated w/ this [Result].
74    pub fn extensions(&self) -> Option<&Extensions> {
75        self.extensions.as_ref()
76    }
77}
78
79impl Fingerprint for XResult {
80    fn fingerprint<H: Hasher>(&self, state: &mut H) {
81        if self.score.is_some() {
82            self.score().unwrap().fingerprint(state)
83        }
84        if self.success.is_some() {
85            state.write_u8(if self.success().unwrap() { 1 } else { 0 })
86        }
87        if self.completion.is_some() {
88            state.write_u8(if self.completion().unwrap() { 1 } else { 0 })
89        }
90        if self.response.is_some() {
91            state.write(self.response().unwrap().as_bytes())
92        }
93        if self.duration.is_some() {
94            self.duration().unwrap().fingerprint(state);
95        }
96        if self.extensions.is_some() {
97            self.extensions().unwrap().fingerprint(state)
98        }
99    }
100}
101
102impl fmt::Display for XResult {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        let mut vec = vec![];
105
106        if self.score.is_some() {
107            vec.push(format!("score: {}", self.score.as_ref().unwrap()))
108        }
109        if self.success.is_some() {
110            vec.push(format!("success? {}", self.success.unwrap()))
111        }
112        if self.completion.is_some() {
113            vec.push(format!("completion? {}", self.completion.unwrap()))
114        }
115        if self.response.is_some() {
116            vec.push(format!("response: \"{}\"", self.response.as_ref().unwrap()))
117        }
118        if self.duration.is_some() {
119            vec.push(format!(
120                "duration: \"{}\"",
121                self.duration_to_iso8601().unwrap()
122            ))
123        }
124        if self.extensions.is_some() {
125            vec.push(format!("extensions: {}", self.extensions.as_ref().unwrap()))
126        }
127
128        let res = vec
129            .iter()
130            .map(|x| x.to_string())
131            .collect::<Vec<_>>()
132            .join(", ");
133        write!(f, "Result{{ {res} }}")
134    }
135}
136
137impl Validate for XResult {
138    fn validate(&self) -> Vec<ValidationError> {
139        let mut vec = vec![];
140
141        if self.score.is_some() {
142            vec.extend(self.score.as_ref().unwrap().validate())
143        };
144        // no need to validate booleans...
145        if self.response.is_some() && self.response.as_ref().unwrap().is_empty() {
146            vec.push(ValidationError::Empty("response".into()))
147        }
148        // no need to validate duration...
149
150        vec
151    }
152}
153
154/// A Type that knows how to construct an xAPI [Result][XResult].
155#[derive(Debug, Default)]
156pub struct XResultBuilder {
157    _score: Option<Score>,
158    _success: Option<bool>,
159    _completion: Option<bool>,
160    _response: Option<String>,
161    _duration: Option<MyDuration>,
162    _extensions: Option<Extensions>,
163}
164
165impl XResultBuilder {
166    /// Set the `score` field.
167    ///
168    /// Raise [DataError] if the argument is invalid.
169    pub fn score(mut self, val: Score) -> Result<Self, DataError> {
170        val.check_validity()?;
171        self._score = Some(val);
172        Ok(self)
173    }
174
175    /// Set the `success` flag.
176    pub fn success(mut self, val: bool) -> Self {
177        self._success = Some(val);
178        self
179    }
180
181    /// Set the `completion` flag.
182    pub fn completion(mut self, val: bool) -> Self {
183        self._completion = Some(val);
184        self
185    }
186
187    /// Set the `response` field.
188    ///
189    /// Raise [DataError] if the input string is empty.
190    pub fn response(mut self, val: &str) -> Result<Self, DataError> {
191        let val = val.trim();
192        if val.is_empty() {
193            emit_error!(DataError::Validation(ValidationError::Empty(
194                "response".into()
195            )))
196        } else {
197            self._response = Some(val.to_owned());
198            Ok(self)
199        }
200    }
201
202    /// Set the `duration` field.
203    ///
204    /// Raise [DataError] if the input string is empty.
205    pub fn duration(mut self, val: &str) -> Result<Self, DataError> {
206        let val = val.trim();
207        if val.is_empty() {
208            emit_error!(DataError::Validation(ValidationError::Empty(
209                "duration".into()
210            )))
211        } else {
212            self._duration = Some(MyDuration::from_str(val)?);
213            Ok(self)
214        }
215    }
216
217    /// Add an extension...
218    pub fn extension(mut self, key: &str, value: &Value) -> Result<Self, DataError> {
219        if self._extensions.is_none() {
220            self._extensions = Some(Extensions::new());
221        }
222        let _ = self._extensions.as_mut().unwrap().add(key, value);
223        Ok(self)
224    }
225
226    /// Set (as in replace) the `extensions` property of this instance  w/ the
227    /// given argument.
228    pub fn with_extensions(mut self, map: Extensions) -> Result<Self, DataError> {
229        self._extensions = Some(map);
230        Ok(self)
231    }
232
233    /// Create an [XResult] from set field vaues.
234    ///
235    /// Raise [DataError] if no field was set.
236    pub fn build(self) -> Result<XResult, DataError> {
237        if self._score.is_none()
238            && self._success.is_none()
239            && self._completion.is_none()
240            && self._response.is_none()
241            && self._duration.is_none()
242            && self._extensions.is_none()
243        {
244            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
245                "At least one field must be set".into()
246            )))
247        } else {
248            Ok(XResult {
249                score: self._score,
250                success: self._success,
251                completion: self._completion,
252                response: self._response.as_ref().map(|x| x.to_string()),
253                duration: self._duration,
254                extensions: self._extensions,
255            })
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use iri_string::types::IriStr;
264    use std::str::FromStr;
265    use tracing_test::traced_test;
266
267    #[traced_test]
268    #[test]
269    fn test_simple() -> Result<(), DataError> {
270        const JSON: &str = r#"{
271            "extensions": {
272                "http://example.com/profiles/meetings/resultextensions/minuteslocation": "X:\\meetings\\minutes\\examplemeeting.one"
273            },
274            "success": true,
275            "completion": true,
276            "response": "We agreed on some example actions.",
277            "duration": "PT1H0M0S"
278        }"#;
279        let de_result = serde_json::from_str::<XResult>(JSON);
280        assert!(de_result.is_ok());
281        let res = de_result.unwrap();
282
283        assert!(res.success().is_some());
284        assert!(res.success().unwrap());
285        assert!(res.completion().is_some());
286        assert!(res.completion().unwrap());
287        assert!(res.response().is_some());
288        assert_eq!(
289            res.response().unwrap(),
290            "We agreed on some example actions."
291        );
292        assert!(res.duration().is_some());
293        let duration = MyDuration::from_str("PT1H0M0S").unwrap();
294        assert_eq!(res.duration().unwrap(), &duration);
295        assert!(res.extensions().is_some());
296        let exts = res.extensions().unwrap();
297
298        let iri =
299            IriStr::new("http://example.com/profiles/meetings/resultextensions/minuteslocation");
300        assert!(iri.is_ok());
301        let val = exts.get(iri.unwrap());
302        assert!(val.is_some());
303        assert_eq!(val.unwrap(), "X:\\meetings\\minutes\\examplemeeting.one");
304
305        Ok(())
306    }
307
308    #[traced_test]
309    #[test]
310    fn test_builder_w_duration() -> Result<(), DataError> {
311        const D: &str = "PT4H35M59.14S";
312
313        let res = XResult::builder().duration(D)?.build()?;
314
315        let d = res.duration().unwrap();
316        assert_eq!(d.second(), (4 * 60 * 60) + (35 * 60) + 59);
317        assert_eq!(d.microsecond(), /* 0.14 * 1000 */ 140 * 1_000);
318
319        Ok(())
320    }
321
322    #[traced_test]
323    #[test]
324    fn test_iso_duration() {
325        const D1: &str = "PT1H0M0S";
326        const D2: &str = "PT4H35M59.14S";
327
328        let res = MyDuration::from_str(D1);
329        assert!(res.is_ok());
330        let d = res.unwrap();
331        assert_eq!(d.second(), /* 1 hour */ 60 * 60);
332        assert_eq!(d.microsecond(), 0);
333
334        let res = MyDuration::from_str(D2);
335        assert!(res.is_ok());
336        let d = res.unwrap();
337        assert_eq!(d.second(), (4 * 60 * 60) + (35 * 60) + 59);
338        assert_eq!(d.microsecond(), /* 0.14 * 1000 */ 140 * 1_000);
339    }
340
341    #[traced_test]
342    #[test]
343    fn test_iso_duration_fmt() {
344        const D1: &str = "PT1H0M0S";
345        let d1 = MyDuration::from_str(D1).unwrap();
346        assert_eq!(D1, d1.to_iso8601());
347        let d1_ = MyDuration::from_str("PT1H").unwrap();
348        assert_eq!(d1, d1_);
349
350        const D2: &str = "PT1H0M0.05S";
351        let d2 = MyDuration::from_str(D2).unwrap();
352        assert_eq!(D2, d2.to_iso8601());
353
354        const D3: &str = "PT1H0.0574S";
355        let d3 = MyDuration::from_str(D3).unwrap();
356        // to_iso... should drop microsecond digits after the first 2...
357        assert_eq!(d2.to_iso8601(), d3.to_iso8601());
358        // second fields should be equal...
359        assert_eq!(d2.second(), d3.second());
360        // first 2 digits of microsecond fields should be equal...
361        assert_eq!(d2.microsecond() / 10_000, d3.microsecond() / 10_000);
362    }
363
364    #[traced_test]
365    #[test]
366    fn test_iso_duration_truncated() -> Result<(), DataError> {
367        const D1: &str = "PT1H0.0574S";
368        const D2: &str = "PT1H0.05S";
369        const D3: &str = "PT1H0M0.05S";
370
371        let res = XResult::builder().duration(D1)?.build()?;
372        assert!(res.duration().is_some());
373        let d2 = MyDuration::from_str(D2).unwrap();
374        let d3 = MyDuration::from_str(D3).unwrap();
375        assert_eq!(d2, d3);
376        assert_eq!(res.duration_to_iso8601().unwrap(), d3.to_iso8601());
377
378        Ok(())
379    }
380
381    #[test]
382    #[should_panic]
383    fn test_duration_deserialization() {
384        const R: &str = r#"{
385  "score":{"scaled":0.95,"raw":95,"min":0,"max":100},
386  "extensions":{"http://example.com/profiles/meetings/resultextensions/minuteslocation":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one","http://example.com/profiles/meetings/resultextensions/reporter":{"name":"Thomas","id":"http://openid.com/342"}},
387  "success":true,
388  "completion":true,
389  "response":"We agreed on some example actions.",
390  "duration":"P4W1D"}"#;
391
392        serde_json::from_str::<XResult>(R).unwrap();
393    }
394}