Skip to main content

xapi_data/
result.rs

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