Skip to main content

teamcraft/domain/
mod.rs

1//! Pure domain rules: roles, lifecycle states, numbering and validation.
2//!
3//! Nothing in here touches the database, which keeps the rules easy to unit test.
4//!
5//! The rules are split by area; everything is re-exported here, so callers
6//! keep using `crate::domain::Name`.
7
8mod as_built;
9mod attributes;
10mod changes;
11mod concurrency;
12mod files_hint;
13mod items;
14mod lifecycle;
15mod notifications;
16mod references;
17mod roles;
18mod simulations;
19mod skins;
20mod tokens;
21mod validation;
22
23pub use as_built::*;
24pub use attributes::*;
25pub use changes::*;
26pub use concurrency::*;
27pub use files_hint::*;
28pub use items::*;
29pub use lifecycle::*;
30pub use notifications::*;
31pub use references::*;
32pub use roles::*;
33pub use simulations::*;
34pub use skins::*;
35pub use tokens::*;
36pub use validation::*;
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[test]
43    fn custom_attributes() {
44        use serde_json::json;
45        assert_eq!(normalize_attribute_key(" Order No. ").unwrap(), "order_no");
46        assert_eq!(normalize_attribute_key("length_mm").unwrap(), "length_mm");
47        // separators at the start and in a row fold into nothing or one '_'
48        // (found by cargo mutants)
49        assert_eq!(
50            normalize_attribute_key("--Order -- No").unwrap(),
51            "order_no"
52        );
53        assert_eq!(normalize_attribute_key("/vendor").unwrap(), "vendor");
54        assert_eq!(normalize_attribute_key("a._/b").unwrap(), "a_b");
55        assert_eq!(normalize_attribute_key(&"a".repeat(40)).unwrap().len(), 40);
56        for bad in ["", "1abc", "ä", "a/b?", &"a".repeat(41)] {
57            assert!(normalize_attribute_key(bad).is_err(), "{bad:?}");
58        }
59        assert_eq!(
60            parse_attribute_choices("Steel; steel\n Aluminium \n\n").unwrap(),
61            ["Steel", "Aluminium"]
62        );
63        // both limits are inclusive (found by cargo mutants)
64        assert!(parse_attribute_choices(&"x".repeat(100)).is_ok());
65        for n in [101, 102] {
66            assert!(parse_attribute_choices(&"x".repeat(n)).is_err(), "{n}");
67        }
68        let choices = |n: usize| {
69            (0..n)
70                .map(|i| format!("c{i}"))
71                .collect::<Vec<_>>()
72                .join(";")
73        };
74        assert_eq!(parse_attribute_choices(&choices(200)).unwrap().len(), 200);
75        for n in [201, 202] {
76            assert!(parse_attribute_choices(&choices(n)).is_err(), "{n}");
77        }
78
79        let none: [String; 0] = [];
80        let v = |t, raw| parse_attribute_value("X", t, &none, raw);
81        assert_eq!(
82            v(AttributeType::Text, "  Bosch ").unwrap(),
83            Some(json!("Bosch"))
84        );
85        assert_eq!(
86            v(AttributeType::Text, "   ").unwrap(),
87            None,
88            "empty removes"
89        );
90        assert_eq!(v(AttributeType::Number, "12,5").unwrap(), Some(json!(12.5)));
91        assert!(v(AttributeType::Number, "12 mm").is_err());
92        assert!(v(AttributeType::Number, "NaN").is_err());
93        assert_eq!(v(AttributeType::Boolean, "Ja").unwrap(), Some(json!(true)));
94        assert_eq!(
95            v(AttributeType::Boolean, "false").unwrap(),
96            Some(json!(false))
97        );
98        assert!(v(AttributeType::Boolean, "maybe").is_err());
99        assert!(v(AttributeType::Text, &"x".repeat(501)).is_err());
100        let choices = ["S235".to_string(), "S355".to_string()];
101        assert_eq!(
102            parse_attribute_value("Steel", AttributeType::Choice, &choices, "s355").unwrap(),
103            Some(json!("S355")),
104            "choices keep their spelling"
105        );
106        assert!(parse_attribute_value("Steel", AttributeType::Choice, &choices, "S275").is_err());
107
108        assert_eq!(attribute_input_text(&json!(3)).unwrap(), "3");
109        assert_eq!(attribute_input_text(&json!(true)).unwrap(), "true");
110        assert_eq!(attribute_input_text(&json!(null)).unwrap(), "");
111        assert_eq!(attribute_input_text(&json!("Bosch")).unwrap(), "Bosch");
112        assert!(attribute_input_text(&json!([1])).is_err());
113
114        let c = |t, raw| parse_attribute_condition("X", t, raw).unwrap();
115        use AttributeCondition::*;
116        assert_eq!(c(AttributeType::Text, " "), Present);
117        assert_eq!(c(AttributeType::Choice, "bos"), Contains("bos".into()));
118        assert_eq!(c(AttributeType::Boolean, "no"), Bool(false));
119        assert_eq!(
120            c(AttributeType::Number, "12"),
121            Range(Some(12.0), Some(12.0))
122        );
123        assert_eq!(c(AttributeType::Number, ">=1,5"), Range(Some(1.5), None));
124        assert_eq!(c(AttributeType::Number, "<=-2"), Range(None, Some(-2.0)));
125        assert_eq!(
126            c(AttributeType::Number, "10..20"),
127            Range(Some(10.0), Some(20.0))
128        );
129        assert_eq!(c(AttributeType::Number, "..20"), Range(None, Some(20.0)));
130        assert!(parse_attribute_condition("X", AttributeType::Number, "10-20").is_err());
131        // a number that overflows to infinity is no bound (found by cargo mutants)
132        for raw in ["1e999", "1e999..", "..-1e999"] {
133            assert!(
134                parse_attribute_condition("X", AttributeType::Number, raw).is_err(),
135                "{raw}"
136            );
137        }
138
139        for k in Classification::ALL {
140            assert_eq!(Classification::parse(k.as_str()), Some(k));
141        }
142        assert_eq!(
143            Classification::parse("Purchased"),
144            Some(Classification::Purchased)
145        );
146        assert_eq!(Classification::parse_field(Some(" ")).unwrap(), None);
147        for (raw, expected) in [
148            ("Normal", Some(Classification::Make)),
149            ("Phantom", Some(Classification::Make)),
150            ("Purchased", Some(Classification::Purchased)),
151            ("Kaufteil", Some(Classification::Purchased)),
152            ("Zukaufteil", Some(Classification::Purchased)),
153            ("Content Center", Some(Classification::Standard)),
154            ("Normteil", Some(Classification::Standard)),
155            ("", None),
156            ("Superpart", None),
157        ] {
158            assert_eq!(Classification::from_bom_structure(raw), expected, "{raw:?}");
159        }
160        assert!(Classification::parse_field(Some("phantom")).is_err());
161        for t in AttributeType::ALL {
162            assert_eq!(AttributeType::parse(t.as_str()), Some(t));
163        }
164        // the labels on the item and attribute pages (found by cargo mutants)
165        let labels: Vec<&str> = Classification::ALL.iter().map(|k| k.label()).collect();
166        assert_eq!(labels, ["In-house", "Purchased", "Standard part"]);
167        let labels: Vec<&str> = AttributeType::ALL.iter().map(|t| t.label()).collect();
168        assert_eq!(labels, ["Text", "Number", "Yes/no", "Choice list"]);
169    }
170
171    #[test]
172    fn project_visibility_parse() {
173        for v in [ProjectVisibility::Internal, ProjectVisibility::Restricted] {
174            assert_eq!(ProjectVisibility::parse(v.as_str()), Some(v));
175        }
176        assert_eq!(
177            ProjectVisibility::parse(" Restricted "),
178            Some(ProjectVisibility::Restricted)
179        );
180        assert_eq!(ProjectVisibility::parse("secret"), None);
181    }
182
183    #[test]
184    fn admin_read_parse() {
185        for m in [AdminRead::Implicit, AdminRead::WithReason, AdminRead::None] {
186            assert_eq!(AdminRead::parse(m.as_str()), Some(m));
187            assert!(!m.label().is_empty());
188        }
189        assert_eq!(
190            AdminRead::parse(" With_Reason "),
191            Some(AdminRead::WithReason)
192        );
193        assert_eq!(AdminRead::parse("always"), None);
194    }
195
196    #[test]
197    fn base_versions() {
198        let sha = "AB".repeat(32);
199        assert_eq!(
200            BaseVersion::parse(&sha),
201            Some(BaseVersion::Sha256("ab".repeat(32)))
202        );
203        assert_eq!(BaseVersion::parse(" 3 "), Some(BaseVersion::Version(3)));
204        assert_eq!(BaseVersion::parse("v12"), Some(BaseVersion::Version(12)));
205        assert_eq!(BaseVersion::parse(""), None);
206        assert_eq!(BaseVersion::parse("-1"), None);
207        assert_eq!(BaseVersion::parse("abc"), None);
208        assert_eq!(
209            BaseVersion::from_json(&serde_json::json!(2)).unwrap(),
210            BaseVersion::Version(2)
211        );
212        assert!(BaseVersion::from_json(&serde_json::json!(-2)).is_err());
213        assert!(BaseVersion::from_json(&serde_json::json!(null)).is_err());
214
215        let cur = Some(ServerFile {
216            version: 4,
217            sha256: "c4",
218        });
219        assert_eq!(check_base(&BaseVersion::Version(4), cur, "new"), Ok(()));
220        assert_eq!(
221            check_base(&BaseVersion::Version(3), cur, "new"),
222            Err(BaseConflict::Moved)
223        );
224        assert_eq!(
225            check_base(&BaseVersion::Version(3), cur, "c4"),
226            Ok(()),
227            "identical content overwrites nothing"
228        );
229        assert_eq!(
230            check_base(&BaseVersion::Sha256("C4".into()), cur, "new"),
231            Ok(())
232        );
233        assert_eq!(
234            check_base(&BaseVersion::Sha256("c3".into()), cur, "new"),
235            Err(BaseConflict::Moved)
236        );
237        assert_eq!(
238            check_base(&BaseVersion::Version(0), cur, "new"),
239            Err(BaseConflict::AddedMeanwhile)
240        );
241        assert_eq!(check_base(&BaseVersion::Version(0), None, "new"), Ok(()));
242        assert_eq!(
243            check_base(&BaseVersion::Version(2), None, "new"),
244            Err(BaseConflict::Removed)
245        );
246    }
247
248    #[test]
249    fn sync_states() {
250        use FileSyncState::*;
251        let history = [(1, "a1"), (2, "a2"), (3, "a3")];
252        let cur = Some(ServerFile {
253            version: 3,
254            sha256: "a3",
255        });
256        let v = |n| BaseVersion::Version(n);
257        assert_eq!(sync_state(cur, &history, Some("a3"), None), UpToDate);
258        assert_eq!(sync_state(cur, &history, Some("A3"), Some(&v(1))), UpToDate);
259        assert_eq!(sync_state(cur, &history, Some("a1"), None), Outdated);
260        assert_eq!(sync_state(cur, &history, Some("zz"), None), Modified);
261        assert_eq!(sync_state(cur, &history, Some("zz"), Some(&v(3))), Modified);
262        assert_eq!(sync_state(cur, &history, Some("a2"), Some(&v(2))), Outdated);
263        assert_eq!(sync_state(cur, &history, Some("zz"), Some(&v(2))), Conflict);
264        assert_eq!(
265            sync_state(
266                cur,
267                &history,
268                Some("zz"),
269                Some(&BaseVersion::Sha256("a1".into()))
270            ),
271            Conflict
272        );
273        assert_eq!(sync_state(cur, &history, Some("zz"), Some(&v(0))), Conflict);
274        assert_eq!(sync_state(None, &[], Some("zz"), None), New);
275        assert_eq!(sync_state(cur, &history, None, None), Missing);
276        assert_eq!(FileSyncState::UpToDate.as_str(), "up_to_date");
277    }
278
279    #[test]
280    fn ages() {
281        assert_eq!(format_age(-5), "just now");
282        assert_eq!(format_age(59), "just now");
283        assert_eq!(format_age(60), "1 min");
284        assert_eq!(format_age(3_600 * 5 + 7), "5 h");
285        assert_eq!(format_age(86_400 + 1), "1 day");
286        assert_eq!(format_age(86_400 * 9), "9 days");
287    }
288
289    #[test]
290    fn skins() {
291        assert_eq!(skin_or_default("arch"), "arch");
292        assert_eq!(skin_or_default("nonsense"), DEFAULT_SKIN);
293        assert!(is_skin("teamcraft-day"));
294        assert!(!is_skin("TeamCraft"));
295        let mut ids: Vec<_> = SKINS.iter().map(|s| s.id).collect();
296        ids.sort();
297        ids.dedup();
298        assert_eq!(ids.len(), SKINS.len(), "skin ids are unique");
299        // every skin has a CSS block
300        let css = include_str!("../../shared/css/skins.css");
301        for skin in SKINS.iter().filter(|s| s.id != DEFAULT_SKIN) {
302            assert!(
303                css.contains(&format!("[data-skin=\"{}\"] {{", skin.id)),
304                "CSS for {}",
305                skin.id
306            );
307        }
308    }
309
310    /// The desktop app has its own copy of the skin list (it runs without the
311    /// server's templates); it must name the same skins and logos.
312    #[test]
313    fn desktop_app_knows_every_skin() {
314        let app = include_str!("../../desktop/ui/js/core.js");
315        for skin in SKINS {
316            assert!(
317                app.contains(&format!(
318                    "id: \"{}\", name: \"{}\", logo: \"{}\"",
319                    skin.id, skin.name, skin.logo
320                )),
321                "desktop/ui/js/core.js SKINS: {} ({}, {})",
322                skin.id,
323                skin.name,
324                skin.logo
325            );
326        }
327    }
328
329    #[test]
330    fn light_and_dark_skins() {
331        assert_eq!(skin_scheme("teamcraft"), Scheme::System);
332        assert_eq!(skin_scheme("teamcraft-night"), Scheme::Dark);
333        assert_eq!(skin_scheme("nonsense"), Scheme::System, "unknown → default");
334        assert!(skin_is_light("teamcraft-day"));
335        assert!(!skin_is_light("teamcraft-night"));
336        assert!(!skin_is_light("teamcraft"));
337        assert!(!skin_is_light("nonsense"));
338        assert_eq!(
339            SKINS.iter().filter(|s| s.scheme == Scheme::Light).count(),
340            1
341        );
342        assert_eq!(Scheme::System.as_str(), "system");
343    }
344
345    #[test]
346    fn avatar_initials_and_tints() {
347        assert_eq!(initials("Alex Morgan", "admin"), "AM");
348        assert_eq!(initials("Erika Maria Mustermann", "erika"), "EM");
349        assert_eq!(initials("", "max"), "M");
350        assert_eq!(initials(" ", ""), "?");
351        assert_eq!(initials("łukasz", "l"), "Ł");
352        assert_eq!(avatar_tint("admin"), avatar_tint("admin"));
353        assert!((1..=6).contains(&avatar_tint("")));
354        assert!((1..=6).contains(&avatar_tint("someone else")));
355        // FNV-1a is stable across builds: the tint of a user never changes,
356        // and all six tints are used (found by cargo mutants)
357        assert_eq!(
358            [
359                avatar_tint("admin"),
360                avatar_tint("erika"),
361                avatar_tint("max"),
362                avatar_tint("")
363            ],
364            [3, 2, 6, 2]
365        );
366        let mut tints: Vec<u32> = (0..60).map(|i| avatar_tint(&format!("user{i}"))).collect();
367        tints.sort_unstable();
368        tints.dedup();
369        assert_eq!(tints, [1, 2, 3, 4, 5, 6]);
370    }
371
372    // --- skins.css: token blocks and contrast --------------------------------
373
374    const SKINS_CSS: &str = include_str!("../../shared/css/skins.css");
375
376    /// The text between two marker comments.
377    fn css_between<'a>(css: &'a str, begin: &str, end: &str) -> &'a str {
378        let start = css
379            .find(begin)
380            .unwrap_or_else(|| panic!("{begin} in skins.css"))
381            + begin.len();
382        let stop = css[start..]
383            .find(end)
384            .unwrap_or_else(|| panic!("{end} in skins.css"))
385            + start;
386        &css[start..stop]
387    }
388
389    /// `--name: value` declarations of the first `{ … }` block in `text`.
390    fn css_decls(text: &str) -> Vec<(String, String)> {
391        let open = text.find('{').expect("a CSS block") + 1;
392        let close = text[open..].find('}').expect("end of the CSS block") + open;
393        let mut body = text[open..close].to_string();
394        while let Some(a) = body.find("/*") {
395            let b = body[a..]
396                .find("*/")
397                .map(|b| a + b + 2)
398                .unwrap_or(body.len());
399            body.replace_range(a..b, "");
400        }
401        body.split(';')
402            .filter_map(|d| d.split_once(':'))
403            .map(|(k, v)| {
404                (
405                    k.trim().to_string(),
406                    v.split_whitespace().collect::<Vec<_>>().join(" "),
407                )
408            })
409            .filter(|(k, _)| !k.is_empty())
410            .collect()
411    }
412
413    fn css_map(decls: &[(String, String)]) -> std::collections::HashMap<String, String> {
414        decls.iter().cloned().collect()
415    }
416
417    #[test]
418    fn dark_token_blocks_identical() {
419        let media = css_decls(css_between(
420            SKINS_CSS,
421            "/* DARK-TOKENS:BEGIN media */",
422            "/* DARK-TOKENS:END media */",
423        ));
424        let forced = css_decls(css_between(
425            SKINS_CSS,
426            "/* DARK-TOKENS:BEGIN forced */",
427            "/* DARK-TOKENS:END forced */",
428        ));
429        assert!(media.len() > 40, "{} dark tokens", media.len());
430        assert_eq!(
431            media, forced,
432            "the System dark block and the forced dark block differ"
433        );
434    }
435
436    /// WCAG 2 relative luminance of `#rrggbb`.
437    fn luminance(hex: &str) -> f64 {
438        let hex = hex
439            .strip_prefix('#')
440            .filter(|h| h.len() == 6)
441            .unwrap_or_else(|| panic!("not #rrggbb: {hex}"));
442        let channel = |i: usize| {
443            let c = f64::from(u8::from_str_radix(&hex[i..i + 2], 16).expect("hex digit")) / 255.0;
444            if c <= 0.039_28 {
445                c / 12.92
446            } else {
447                ((c + 0.055) / 1.055).powf(2.4)
448            }
449        };
450        0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
451    }
452
453    fn contrast(a: &str, b: &str) -> f64 {
454        let (la, lb) = (luminance(a), luminance(b));
455        (la.max(lb) + 0.05) / (la.min(lb) + 0.05)
456    }
457
458    #[test]
459    fn skins_meet_contrast() {
460        let light = css_map(&css_decls(css_between(
461            SKINS_CSS,
462            "/* LIGHT-TOKENS:BEGIN */",
463            "/* LIGHT-TOKENS:END */",
464        )));
465        let dark = css_map(&css_decls(css_between(
466            SKINS_CSS,
467            "/* DARK-TOKENS:BEGIN forced */",
468            "/* DARK-TOKENS:END forced */",
469        )));
470        let mut skins = vec![
471            ("light base".to_string(), light),
472            ("dark base".to_string(), dark.clone()),
473        ];
474        for skin in SKINS.iter().filter(|s| !s.id.starts_with("teamcraft")) {
475            let selector = format!("[data-skin=\"{}\"] {{", skin.id);
476            let at = SKINS_CSS
477                .find(&selector)
478                .unwrap_or_else(|| panic!("{selector}"));
479            let mut tokens = dark.clone();
480            tokens.extend(css_map(&css_decls(&SKINS_CSS[at..])));
481            skins.push((skin.id.to_string(), tokens));
482        }
483        let mut failures = Vec::new();
484        for (name, t) in &skins {
485            // A token may name another one (`--count-bg: var(--primary)`);
486            // it resolves against the same skin, as in the browser.
487            let get = |k: &str| {
488                let mut key = k;
489                loop {
490                    let v = t
491                        .get(key)
492                        .unwrap_or_else(|| panic!("{name}: {key}"))
493                        .as_str();
494                    match v.strip_prefix("var(").and_then(|r| r.strip_suffix(')')) {
495                        Some(inner) => key = inner.trim(),
496                        None => break v,
497                    }
498                }
499            };
500            let mut check = |fg: &str, bg: &str, min: f64| {
501                let ratio = contrast(get(fg), get(bg));
502                if ratio < min {
503                    failures.push(format!("{name}: {fg} on {bg} = {ratio:.2} < {min}"));
504                }
505            };
506            for fg in ["--text", "--text-muted", "--text-dim"] {
507                for bg in ["--bg", "--surface", "--surface-2"] {
508                    check(fg, bg, 4.5);
509                }
510            }
511            check("--text", "--selected", 4.5);
512            check("--text-muted", "--selected", 4.5);
513            check("--text-dim", "--selected", 4.5);
514            for bg in ["--bg", "--surface", "--surface-2", "--selected"] {
515                check("--link", bg, 4.5);
516            }
517            check("--on-primary", "--primary", 4.5);
518            // a hovered or pressed primary button keeps its edge and its label
519            for state in ["--primary-hover", "--primary-active"] {
520                check(state, "--surface", 3.0);
521                check("--on-primary", state, 4.5);
522            }
523            for bg in ["--bg", "--surface", "--surface-2"] {
524                check("--focus", bg, 3.0);
525            }
526            for bg in ["--surface", "--surface-2", "--input-bg", "--sidebar"] {
527                check("--control-border", bg, 3.0);
528            }
529            for state in ["wip", "review", "ok", "danger", "impact"] {
530                let fg = format!("--st-{state}-fg");
531                check(&fg, &format!("--st-{state}-bg"), 4.5);
532                check(&fg, "--surface", 4.5);
533            }
534            check("--st-lock-other", "--surface", 4.5);
535            check("--count-fg", "--count-bg", 4.5);
536        }
537        assert!(failures.is_empty(), "{failures:#?}");
538    }
539
540    #[test]
541    fn revision_codes_follow_asme_alphabet() {
542        assert_eq!(revision_code(1), "A");
543        assert_eq!(revision_code(2), "B");
544        assert_eq!(revision_code(8), "H");
545        // I is skipped
546        assert_eq!(revision_code(9), "J");
547        assert_eq!(revision_code(20), "Y");
548        assert_eq!(revision_code(21), "AA");
549        assert_eq!(revision_code(22), "AB");
550        assert_eq!(revision_code(40), "AY");
551        assert_eq!(revision_code(41), "BA");
552        assert_eq!(revision_code(420), "YY");
553        assert_eq!(revision_code(421), "AAA");
554    }
555
556    #[test]
557    fn revision_codes_round_trip() {
558        for seq in 1..2000 {
559            let code = revision_code(seq);
560            assert_eq!(revision_seq(&code), Some(seq), "code {code}");
561            for forbidden in ['I', 'O', 'Q', 'S', 'X', 'Z'] {
562                assert!(!code.contains(forbidden), "{code} contains {forbidden}");
563            }
564        }
565        assert_eq!(revision_seq("b"), Some(2));
566        assert_eq!(revision_seq(""), None);
567        assert_eq!(revision_seq("I"), None);
568        // at most six letters, although seven would still fit an i32 (found
569        // by cargo mutants)
570        assert!(revision_seq("AAAAAA").is_some());
571        assert_eq!(revision_seq("AAAAAAA"), None);
572        assert_eq!(revision_seq("A1"), None);
573    }
574
575    #[test]
576    fn lifecycle_transitions() {
577        use LifecycleState::*;
578        assert!(InWork.can_transition_to(InReview));
579        assert!(InReview.can_transition_to(Released));
580        assert!(InReview.can_transition_to(InWork));
581        assert!(Released.can_transition_to(Obsolete));
582        assert!(!InWork.can_transition_to(Released));
583        assert!(!Released.can_transition_to(InWork));
584        assert!(!Obsolete.can_transition_to(Released));
585        assert!(InWork.is_editable());
586        assert!(!InReview.is_editable());
587        assert!(!Released.is_editable());
588        assert!(InReview.is_open());
589        assert!(!Released.is_open());
590        for s in LifecycleState::ALL {
591            assert_eq!(LifecycleState::parse(s.as_str()), Some(s));
592        }
593        // stored values, labels and the two flags of every state; file
594        // retention values (found by cargo mutants)
595        let states: Vec<(&str, &str, bool, bool)> = LifecycleState::ALL
596            .iter()
597            .map(|s| (s.as_str(), s.label(), s.is_editable(), s.is_open()))
598            .collect();
599        assert_eq!(
600            states,
601            [
602                ("in_work", "WIP", true, true),
603                ("in_review", "In Review", false, true),
604                ("released", "Released", false, false),
605                ("obsolete", "Obsolete", false, false),
606            ]
607        );
608        assert_eq!(LifecycleState::parse(" Released "), Some(Released));
609        assert_eq!(LifecycleState::parse("wip"), None);
610        for r in [FileRetention::All, FileRetention::Latest] {
611            assert_eq!(FileRetention::parse(r.as_str()), Some(r));
612        }
613        assert_eq!(
614            (FileRetention::All.as_str(), FileRetention::Latest.as_str()),
615            ("all", "latest")
616        );
617        assert_eq!(
618            FileRetention::parse(" LATEST "),
619            Some(FileRetention::Latest)
620        );
621        assert_eq!(FileRetention::parse("newest"), None);
622    }
623
624    #[test]
625    fn lifecycle_rules_document_every_transition() {
626        // every documented rule is allowed …
627        for rule in LIFECYCLE_RULES {
628            assert!(rule.from.can_transition_to(rule.to), "{rule:?}");
629            assert!(!rule.how.is_empty() && !rule.who.is_empty());
630        }
631        // … and every allowed transition is documented exactly once
632        for from in LifecycleState::ALL {
633            for to in LifecycleState::ALL {
634                let documented = LIFECYCLE_RULES
635                    .iter()
636                    .filter(|r| r.from == from && r.to == to)
637                    .count();
638                assert_eq!(
639                    documented,
640                    usize::from(from.can_transition_to(to)),
641                    "{from:?} → {to:?}"
642                );
643            }
644        }
645        assert_eq!(rules_from(LifecycleState::InReview).len(), 2);
646        assert!(
647            rules_from(LifecycleState::Obsolete).is_empty(),
648            "final state"
649        );
650        let json = serde_json::to_value(LIFECYCLE_RULES[0]).unwrap();
651        assert_eq!(json["from"], "in_work");
652        assert_eq!(json["to"], "in_review");
653    }
654
655    #[test]
656    fn roles_are_ordered() {
657        assert!(Role::Viewer < Role::Editor);
658        assert!(Role::Editor < Role::Admin);
659        for r in Role::ALL {
660            assert_eq!(Role::parse(r.as_str()), Some(r));
661        }
662        assert_eq!(Role::parse(" ADMIN "), Some(Role::Admin));
663        assert_eq!(Role::parse("CAD editor"), Some(Role::Editor));
664        assert_eq!(Role::parse("engineer"), Some(Role::Editor));
665        assert_eq!(Role::Editor.label(), "CAD editor");
666        assert_eq!(Role::parse("root"), None);
667    }
668
669    #[test]
670    fn separation_of_duties() {
671        use uuid::Uuid;
672        let (author, admin, editor, other) = (
673            Uuid::new_v4(),
674            Uuid::new_v4(),
675            Uuid::new_v4(),
676            Uuid::new_v4(),
677        );
678        let editors = [editor];
679        let check = |who, submitter| conflicting_reviewer(who, author, submitter, &editors);
680        assert_eq!(check(author, None), Some(ReviewConflict::Author));
681        assert_eq!(check(editor, None), Some(ReviewConflict::Editor));
682        assert_eq!(check(admin, Some(admin)), Some(ReviewConflict::Submitter));
683        assert_eq!(check(admin, None), None);
684        assert_eq!(check(other, Some(admin)), None);
685        // the author wins over the other reasons
686        assert_eq!(
687            conflicting_reviewer(author, author, Some(author), &[author]),
688            Some(ReviewConflict::Author)
689        );
690    }
691
692    #[test]
693    fn review_outcomes() {
694        use Decision::*;
695        assert_eq!(review_outcome(&[]), ReviewOutcome::Pending);
696        assert_eq!(review_outcome(&[Pending, Approved]), ReviewOutcome::Pending);
697        assert_eq!(
698            review_outcome(&[Approved, Approved]),
699            ReviewOutcome::Approved
700        );
701        assert_eq!(
702            review_outcome(&[Approved, Rejected]),
703            ReviewOutcome::Rejected
704        );
705        assert_eq!(
706            review_outcome(&[Pending, Rejected]),
707            ReviewOutcome::Rejected
708        );
709    }
710
711    #[test]
712    fn review_policy_outcomes() {
713        use uuid::Uuid;
714        use Decision::*;
715        let (a, b, c) = (Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3));
716        let none = ReviewRequirement::default();
717        // the default equals the old rule
718        assert_eq!(
719            review_outcome_with(&[(a, Approved), (b, Pending)], &none),
720            ReviewOutcome::Pending
721        );
722        assert_eq!(
723            review_outcome_with(&[(a, Approved), (b, Approved)], &none),
724            ReviewOutcome::Approved
725        );
726        assert_eq!(review_outcome_with(&[], &none), ReviewOutcome::Pending);
727        // two of three
728        let two = ReviewRequirement {
729            min_approvals: Some(2),
730            ..Default::default()
731        };
732        let votes = [(a, Approved), (b, Approved), (c, Pending)];
733        assert_eq!(review_outcome_with(&votes, &two), ReviewOutcome::Approved);
734        assert_eq!(
735            review_outcome_with(&[(a, Approved), (b, Pending), (c, Pending)], &two),
736            ReviewOutcome::Pending
737        );
738        // a rejection still rejects
739        assert_eq!(
740            review_outcome_with(&[(a, Approved), (b, Approved), (c, Rejected)], &two),
741            ReviewOutcome::Rejected
742        );
743        // a required person
744        let need_c = ReviewRequirement {
745            min_approvals: Some(1),
746            required_users: vec![(c, "Carl".into())],
747            ..Default::default()
748        };
749        assert_eq!(
750            review_outcome_with(&[(a, Approved), (c, Pending)], &need_c),
751            ReviewOutcome::Pending
752        );
753        assert_eq!(
754            review_outcome_with(&[(a, Pending), (c, Approved)], &need_c),
755            ReviewOutcome::Approved
756        );
757        // a role group
758        let qa = ReviewRequirement {
759            min_approvals: Some(1),
760            role_groups: vec![RoleRequirement {
761                label: "QA".into(),
762                members: vec![b],
763            }],
764            ..Default::default()
765        };
766        assert_eq!(
767            review_outcome_with(&[(a, Approved), (b, Pending)], &qa),
768            ReviewOutcome::Pending
769        );
770        assert_eq!(
771            review_outcome_with(&[(a, Approved), (b, Approved)], &qa),
772            ReviewOutcome::Approved
773        );
774        // min larger than the reviewers is capped (submit refuses it earlier)
775        let ten = ReviewRequirement {
776            min_approvals: Some(10),
777            ..Default::default()
778        };
779        assert_eq!(
780            review_outcome_with(&[(a, Approved)], &ten),
781            ReviewOutcome::Approved
782        );
783    }
784
785    #[test]
786    fn review_policy_combination_and_shortfalls() {
787        use uuid::Uuid;
788        let (a, b) = (Uuid::from_u128(1), Uuid::from_u128(2));
789        assert_eq!(combine_min_approvals(&[]), None);
790        assert_eq!(combine_min_approvals(&[Some(2), None]), None);
791        assert_eq!(combine_min_approvals(&[Some(2), Some(3)]), Some(3));
792        let req = ReviewRequirement {
793            min_approvals: Some(3),
794            required_users: vec![(b, "Bea".into())],
795            role_groups: vec![RoleRequirement {
796                label: "QA".into(),
797                members: vec![b],
798            }],
799        };
800        let s = policy_shortfalls(&[a], &req);
801        assert_eq!(s.len(), 3, "{s:?}");
802        assert!(s[0].contains("3 approvals") && s[0].contains("only 1 reviewer is"));
803        assert!(s[1].contains("Bea"));
804        assert!(s[2].contains("QA"));
805        assert!(policy_shortfalls(&[a, b, Uuid::from_u128(3)], &req).is_empty());
806        assert!(policy_shortfalls(&[], &ReviewRequirement::default()).is_empty());
807        let rest = attainable(&req, &[a]);
808        assert!(rest.required_users.is_empty() && rest.role_groups.is_empty());
809        assert_eq!(attainable(&req, &[a, b]), req);
810    }
811
812    #[test]
813    fn item_numbers() {
814        let ctx = |t| NumberContext {
815            project: "ROVER",
816            item_type: t,
817            year: 2026,
818            month: 3,
819        };
820        let default = NumberScheme::default_scheme();
821        assert_eq!(default.format(&ctx(ItemType::Part), 42), "PRT-000042");
822        assert_eq!(default.format(&ctx(ItemType::Software), 1), "SW-000001");
823        assert_eq!(default.counter_key(&ctx(ItemType::Part)), "num:PRT-{SEQ}");
824        assert_eq!(format_change_number(7), "CHG-000007");
825        assert_eq!(
826            normalize_item_number(" exp-prop-0012 ").unwrap(),
827            "EXP-PROP-0012"
828        );
829        assert!(normalize_item_number("").is_err());
830        assert!(normalize_item_number("-ABC").is_err());
831        assert!(normalize_item_number("A/B").is_err());
832        assert!(normalize_item_number(&"A".repeat(65)).is_err());
833    }
834
835    #[test]
836    fn numbering_schemes() {
837        let ctx = NumberContext {
838            project: "ROVER",
839            item_type: ItemType::Assembly,
840            year: 2026,
841            month: 3,
842        };
843        let s = NumberScheme::parse(" {project}-{Type}-{YYYY}-{SEQ:4} ").unwrap();
844        assert_eq!(s.as_string(), "{PROJECT}-{TYPE}-{YYYY}-{SEQ:4}");
845        assert_eq!(s.format(&ctx, 7), "ROVER-ASM-2026-0007");
846        assert_eq!(s.format(&ctx, 123_456), "ROVER-ASM-2026-123456");
847        assert_eq!(s.counter_key(&ctx), "num:ROVER-ASM-2026-{SEQ}");
848        // The padding width does not change the counter.
849        let wider = NumberScheme::parse("{PROJECT}-{TYPE}-{YYYY}-{SEQ:6}").unwrap();
850        assert_eq!(wider.counter_key(&ctx), s.counter_key(&ctx));
851
852        // Numbers the expansion can produce, at any width.
853        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2026-0012"), Some(12));
854        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2026-123456"), Some(123_456));
855        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2025-0012"), None);
856        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2026-12A"), None);
857        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2026-"), None);
858        // digits only and at most 18 of them (found by cargo mutants: `+12`
859        // and 19 digits would parse as i64)
860        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2026-+12"), None);
861        assert_eq!(s.seq_of(&ctx, "ROVER-ASM-2026-1000000000000000000"), None);
862        assert_eq!(
863            s.seq_of(&ctx, "ROVER-ASM-2026-100000000000000000"),
864            Some(100_000_000_000_000_000),
865            "18 digits"
866        );
867
868        let s = NumberScheme::parse("x{YY}{MM}.{SEQ}").unwrap();
869        assert_eq!(s.format(&ctx, 12), "X2603.12");
870        assert_eq!(s.around_seq(&ctx), ("X2603.".to_string(), String::new()));
871        let leading = NumberScheme::parse("{SEQ:3}-{PROJECT}").unwrap();
872        assert_eq!(leading.seq_of(&ctx, "042-ROVER"), Some(42));
873        assert_eq!(leading.seq_of(&ctx, "042-ROVERX"), None);
874        assert_eq!(s.as_string(), "X{YY}{MM}.{SEQ}");
875        assert_eq!(
876            NumberScheme::of_project(Some("  ")).unwrap(),
877            NumberScheme::default_scheme()
878        );
879        assert_eq!(
880            NumberScheme::of_project(None).unwrap().as_string(),
881            DEFAULT_NUMBER_SCHEME
882        );
883
884        for bad in [
885            "",
886            "PRT-0001",                             // no sequence
887            "{SEQ}-{SEQ:3}",                        // two sequences
888            "{SEQ:0}",                              // width out of range
889            "{SEQ:13}",                             // width out of range
890            "{SEQ:x}",                              // width not a number
891            "{DATE}-{SEQ}",                         // unknown placeholder
892            "{TYPE-{SEQ}",                          // unclosed brace
893            "{TYPE} {SEQ}",                         // space
894            "A/{SEQ}",                              // slash
895            "-{SEQ}",                               // starts with '-'
896            "{PROJECT}-{PROJECT}-{SEQ:4}",          // up to 69 characters
897            &format!("{}{{SEQ}}", "A".repeat(100)), // too long
898        ] {
899            assert!(
900                NumberScheme::parse(bad).is_err(),
901                "{bad:?} must be rejected"
902            );
903        }
904        // the reason is named (found by cargo mutants)
905        let msg = |raw: &str| NumberScheme::parse(raw).unwrap_err().public_message();
906        assert!(msg("PRT-{YYYY}").contains("needs a sequence"));
907        assert!(msg("{SEQ}-{SEQ:3}").contains("only once"));
908        // generated numbers of exactly 64 characters are fine, 65 are not
909        // (project code 32 + text + sequence 12)
910        let numbers = |text: usize| format!("{{PROJECT}}{}{{SEQ:12}}", "A".repeat(text));
911        assert!(NumberScheme::parse(&numbers(20)).is_ok(), "64 characters");
912        assert!(msg(&numbers(21)).contains("at most 64"));
913        // the scheme text itself has at most 100 characters; `{YY}` makes a
914        // long text with short numbers (found by cargo mutants)
915        let scheme = |extra: usize| format!("{}{{SEQ:1}}A{}", "{YY}".repeat(23), "B".repeat(extra));
916        assert_eq!(scheme(0).len(), 100);
917        assert!(
918            NumberScheme::parse(&scheme(0)).is_ok(),
919            "exactly 100 characters"
920        );
921        for extra in [1, 2] {
922            let err = NumberScheme::parse(&scheme(extra))
923                .unwrap_err()
924                .public_message();
925            assert!(err.contains("at most 100"), "{err}");
926        }
927    }
928
929    #[test]
930    fn usernames_and_codes() {
931        assert_eq!(
932            normalize_username(" Max.Mustermann ").unwrap(),
933            "max.mustermann"
934        );
935        assert!(normalize_username("a").is_err());
936        assert!(normalize_username("with space").is_err());
937        assert_eq!(normalize_project_code("rover").unwrap(), "ROVER");
938        assert!(normalize_project_code("NI XUS").is_err());
939    }
940
941    #[test]
942    fn decimals_accept_comma() {
943        assert_eq!(parse_decimal("Mass", Some("1,5")).unwrap(), Some(1.5));
944        assert_eq!(parse_decimal("Mass", Some(" 2.25 ")).unwrap(), Some(2.25));
945        assert_eq!(parse_decimal("Mass", Some("")).unwrap(), None);
946        assert_eq!(parse_decimal("Mass", None).unwrap(), None);
947        assert!(parse_decimal("Mass", Some("abc")).is_err());
948        assert!(validate_non_negative("Mass", Some(-1.0)).is_err());
949        assert!(validate_non_negative("Mass", Some(f64::NAN)).is_err());
950        assert!(validate_quantity(0.0).is_err());
951        assert_eq!(validate_quantity(2.0).unwrap(), 2.0);
952    }
953
954    #[test]
955    fn simulation_items_and_basis_roles() {
956        let ctx = NumberContext {
957            project: "ROVER",
958            item_type: ItemType::Simulation,
959            year: 2026,
960            month: 3,
961        };
962        assert_eq!(NumberScheme::default_scheme().format(&ctx, 3), "SIM-000003");
963        assert_eq!(ItemType::parse(" Simulation "), Some(ItemType::Simulation));
964        for t in ItemType::ALL {
965            assert_eq!(ItemType::parse(t.as_str()), Some(t));
966        }
967        // stored value, label and number prefix of each type (found by cargo
968        // mutants)
969        let types: Vec<(&str, &str, &str)> = ItemType::ALL
970            .iter()
971            .map(|t| (t.as_str(), t.label(), t.number_prefix()))
972            .collect();
973        assert_eq!(
974            types,
975            [
976                ("part", "Part", "PRT"),
977                ("assembly", "Assembly", "ASM"),
978                ("document", "Document", "DOC"),
979                ("software", "Software", "SW"),
980                ("simulation", "Simulation", "SIM"),
981                ("as_built", "As-built", "AB"),
982            ]
983        );
984        assert_eq!(ItemType::parse("As-Built"), Some(ItemType::AsBuilt));
985        assert_eq!(ItemType::parse("as built"), Some(ItemType::AsBuilt));
986        assert_eq!(ItemType::parse("gadget"), None);
987        for r in BasisRole::ALL {
988            assert_eq!(BasisRole::parse(r.as_str()), Some(r));
989        }
990        assert_eq!(BasisRole::parse("Load-Case"), Some(BasisRole::LoadCase));
991        assert_eq!(BasisRole::parse("load case"), Some(BasisRole::LoadCase));
992        assert_eq!(BasisRole::parse("sketch"), None);
993    }
994
995    #[test]
996    fn result_filter_names_round_trip() {
997        for f in ResultFilter::ALL {
998            assert_eq!(ResultFilter::parse(f.as_str()), Some(f));
999            assert!(!f.label().is_empty());
1000        }
1001        assert_eq!(ResultFilter::parse(" failed "), Some(ResultFilter::Failed));
1002        assert_eq!(ResultFilter::parse("fail"), None);
1003        assert_eq!(ResultFilter::parse_field(None).unwrap(), None);
1004        assert_eq!(ResultFilter::parse_field(Some("  ")).unwrap(), None);
1005        assert!(ResultFilter::parse_field(Some("nope")).is_err());
1006    }
1007
1008    #[test]
1009    fn result_pass_fail() {
1010        use ResultStatus::{Fail, Pass};
1011        assert_eq!(result_status(5.0, None, None), ResultStatus::NoLimits);
1012        assert_eq!(result_status(5.0, Some(1.0), Some(10.0)), Pass);
1013        assert_eq!(result_status(1.0, Some(1.0), Some(10.0)), Pass, "inclusive");
1014        assert_eq!(
1015            result_status(10.0, Some(1.0), Some(10.0)),
1016            Pass,
1017            "inclusive"
1018        );
1019        assert_eq!(result_status(0.5, Some(1.0), Some(10.0)), Fail);
1020        assert_eq!(result_status(11.0, Some(1.0), Some(10.0)), Fail);
1021        // only an upper limit (max. stress), only a lower one (safety factor)
1022        assert_eq!(result_status(180e6, None, Some(200e6)), Pass);
1023        assert_eq!(result_status(210e6, None, Some(200e6)), Fail);
1024        assert_eq!(result_status(1.4, Some(1.5), None), Fail);
1025        assert_eq!(result_status(-3.0, Some(-5.0), None), Pass);
1026        assert_eq!(result_status(f64::NAN, Some(0.0), None), Fail);
1027        assert_eq!(Pass.as_str(), "pass");
1028        assert!(validate_result_numbers("Stress", 1.0, Some(2.0), Some(1.0)).is_err());
1029        assert!(validate_result_numbers("Stress", f64::INFINITY, None, None).is_err());
1030        assert!(validate_result_numbers("Stress", 1.0, Some(f64::NAN), None).is_err());
1031        assert!(validate_result_numbers("Stress", 1.0, Some(1.0), Some(1.0)).is_ok());
1032    }
1033
1034    #[test]
1035    fn like_patterns_are_escaped() {
1036        assert_eq!(like_pattern("valve"), "%valve%");
1037        assert_eq!(like_pattern("50%_x\\"), "%50\\%\\_x\\\\%");
1038    }
1039
1040    #[test]
1041    fn password_policy() {
1042        // generated at the boundary, not literal phrases: a password literal
1043        // reads as a hard-coded credential (CodeQL) and says less about the rule
1044        assert!(validate_password(&"x".repeat(MIN_PASSWORD_LEN - 1)).is_err());
1045        assert!(validate_password(&"x".repeat(MIN_PASSWORD_LEN)).is_ok());
1046        // text limits count characters and are inclusive; numbers (found by
1047        // cargo mutants)
1048        assert_eq!(limit_text("Name", " äöü ", 3).unwrap(), "äöü");
1049        assert!(limit_text("Name", "äöüß", 3).is_err());
1050        assert_eq!(limit_text("Name", "", 3).unwrap(), "");
1051        assert!(require_text("Name", "  ", 3).is_err());
1052        assert_eq!(require_text("Name", "abc", 3).unwrap(), "abc");
1053        assert!(require_text("Name", "abcd", 3).is_err());
1054        assert_eq!(validate_non_negative("Mass", Some(0.0)).unwrap(), Some(0.0));
1055        assert_eq!(validate_non_negative("Mass", None).unwrap(), None);
1056        for bad in [-0.001, f64::NAN, f64::INFINITY] {
1057            assert!(validate_non_negative("Mass", Some(bad)).is_err(), "{bad}");
1058        }
1059        assert_eq!(validate_quantity(0.5).unwrap(), 0.5);
1060        assert_eq!(validate_quantity(999_999_999.0).unwrap(), 999_999_999.0);
1061        for bad in [0.0, -1.0, 1e9, f64::NAN, f64::INFINITY] {
1062            assert!(validate_quantity(bad).is_err(), "{bad}");
1063        }
1064        // at most 1024 bytes (found by cargo mutants)
1065        assert!(validate_password(&"x".repeat(1024)).is_ok());
1066        for n in [1025, 1026] {
1067            assert!(validate_password(&"x".repeat(n)).is_err(), "{n}");
1068        }
1069    }
1070
1071    #[test]
1072    fn change_request_transitions() {
1073        use RequestStatus::*;
1074        // the regular path: raised → triage → accepted → implemented
1075        assert!(New.can_transition_to(InTriage));
1076        assert!(InTriage.can_transition_to(Accepted));
1077        assert!(Accepted.can_transition_to(Implemented));
1078        // decisions straight from new, and out of deferred
1079        assert!(New.can_transition_to(Accepted));
1080        assert!(New.can_transition_to(Rejected));
1081        assert!(InTriage.can_transition_to(Deferred));
1082        assert!(Deferred.can_transition_to(InTriage));
1083        assert!(Deferred.can_transition_to(Accepted));
1084        assert!(Deferred.can_transition_to(Rejected));
1085        // the requester withdraws only before a decision
1086        assert!(New.can_transition_to(Withdrawn));
1087        assert!(InTriage.can_transition_to(Withdrawn));
1088        assert!(!Accepted.can_transition_to(Withdrawn));
1089        assert!(!Deferred.can_transition_to(Withdrawn));
1090        // implemented only through a released change order
1091        assert!(!New.can_transition_to(Implemented));
1092        assert!(!InTriage.can_transition_to(Implemented));
1093        assert!(!Accepted.can_transition_to(Rejected));
1094        // final states
1095        for end in [Rejected, Implemented, Withdrawn] {
1096            assert!(!end.is_open());
1097            for to in RequestStatus::ALL {
1098                assert!(!end.can_transition_to(to), "{end:?} → {to:?}");
1099            }
1100        }
1101        for s in RequestStatus::ALL {
1102            assert!(!s.can_transition_to(s), "no self transition for {s:?}");
1103            assert_eq!(RequestStatus::parse(s.as_str()), Some(s));
1104        }
1105        assert!(New.is_open() && Accepted.is_open() && Deferred.is_open());
1106        assert_eq!(RequestStatus::parse(" IN_TRIAGE "), Some(InTriage));
1107        assert_eq!(RequestStatus::parse("open"), None);
1108    }
1109
1110    #[test]
1111    fn change_request_reasons_and_decisions() {
1112        for r in RequestReason::ALL {
1113            assert_eq!(RequestReason::parse(r.as_str()), Some(r));
1114        }
1115        assert_eq!(RequestReason::parse(""), Some(RequestReason::Other));
1116        assert_eq!(RequestReason::parse("bogus"), None);
1117        assert_eq!(
1118            RequestDecision::parse("accept").map(RequestDecision::status),
1119            Some(RequestStatus::Accepted)
1120        );
1121        assert_eq!(
1122            RequestDecision::parse("Defer").map(RequestDecision::status),
1123            Some(RequestStatus::Deferred)
1124        );
1125        assert_eq!(RequestDecision::parse("approve"), None);
1126        // the desktop app has its own copy of the reasons
1127        let app = include_str!("../../desktop/ui/js/core.js");
1128        for r in RequestReason::ALL {
1129            assert!(
1130                app.contains(&format!(
1131                    "{{ value: \"{}\", label: \"{}\" }}",
1132                    r.as_str(),
1133                    r.label()
1134                )),
1135                "desktop/ui/js/core.js lists the reason {}",
1136                r.as_str()
1137            );
1138        }
1139        assert_eq!(format_request_number(7), "ECR-000007");
1140        assert_eq!(
1141            NotificationKind::parse("change_request"),
1142            Some(NotificationKind::ChangeRequest)
1143        );
1144    }
1145
1146    #[test]
1147    fn folders_are_normalised() {
1148        let ok = |raw: &str| normalize_folder(raw).unwrap();
1149        assert_eq!(ok(""), "");
1150        assert_eq!(ok("  "), "");
1151        assert_eq!(ok("/"), "");
1152        assert_eq!(ok("chassis"), "chassis");
1153        assert_eq!(ok("chassis/brackets"), "chassis/brackets");
1154        assert_eq!(ok("chassis\\brackets\\"), "chassis/brackets");
1155        assert_eq!(ok("/chassis//./brackets/"), "chassis/brackets");
1156        assert_eq!(ok("Rover 2 (old)/Parts"), "Rover 2 (old)/Parts");
1157        assert_eq!(ok("a..b/v1.2"), "a..b/v1.2", "dots inside names are fine");
1158        assert_eq!(ok("Ölwanne/Gehäuse"), "Ölwanne/Gehäuse");
1159        assert_eq!(ok("CONSOLE/COM10"), "CONSOLE/COM10");
1160        for bad in [
1161            "..",
1162            "chassis/../etc",
1163            "../outside",
1164            "a\\..\\b",
1165            "C:/CAD",
1166            "a|b",
1167            "what?",
1168            "star*",
1169            "quote\"",
1170            "<x>",
1171            "tab\there",
1172            "trailing./x",
1173            "space /x",
1174            "CON",
1175            "parts/nul.txt",
1176            "Com1",
1177            "LPT9/x",
1178        ] {
1179            assert!(normalize_folder(bad).is_err(), "{bad:?} must be refused");
1180        }
1181        let deep = vec!["a"; MAX_FOLDER_DEPTH + 1].join("/");
1182        assert!(normalize_folder(&deep).is_err());
1183        assert!(normalize_folder(&vec!["a"; MAX_FOLDER_DEPTH].join("/")).is_ok());
1184        let long = vec!["x".repeat(100); 5].join("/");
1185        assert!(normalize_folder(&long).is_err(), "longer than 400");
1186        assert!(normalize_folder(&"y".repeat(MAX_FOLDER_SEGMENT + 1)).is_err());
1187        // both length limits are inclusive (found by cargo mutants)
1188        assert!(normalize_folder(&"y".repeat(MAX_FOLDER_SEGMENT)).is_ok());
1189        let path = |len: usize| {
1190            // folders of 99 characters plus '/', the last one non-empty
1191            let mut parts = vec!["x".repeat(99); (len - 1) / 100];
1192            parts.push("z".repeat(len - parts.len() * 100));
1193            parts.join("/")
1194        };
1195        assert_eq!(path(MAX_FOLDER_LEN).chars().count(), MAX_FOLDER_LEN);
1196        assert_eq!(
1197            normalize_folder(&path(MAX_FOLDER_LEN))
1198                .unwrap()
1199                .chars()
1200                .count(),
1201            MAX_FOLDER_LEN
1202        );
1203        assert!(normalize_folder(&path(MAX_FOLDER_LEN + 1)).is_err());
1204        // reserved names need a digit after COM/LPT, and only these four
1205        for ok in ["COMX", "LPTA", "CONSOLE", "nullable"] {
1206            assert!(normalize_folder(ok).is_ok(), "{ok}");
1207        }
1208    }
1209}
1210
1211/// Property tests for the pure rules: they must hold for every input, not
1212/// only for the examples of `tests`.
1213#[cfg(test)]
1214mod property_tests {
1215    use super::*;
1216    use proptest::prelude::*;
1217
1218    /// Largest sequence whose code `revision_seq` still reads back (six
1219    /// letters: 20 + 20² + … + 20⁶).
1220    const MAX_READABLE_SEQ: i32 = 67_368_420;
1221
1222    fn lifecycle_states() -> impl Strategy<Value = LifecycleState> {
1223        prop_oneof![
1224            Just(LifecycleState::InWork),
1225            Just(LifecycleState::InReview),
1226            Just(LifecycleState::Released),
1227            Just(LifecycleState::Obsolete),
1228        ]
1229    }
1230
1231    fn item_types() -> impl Strategy<Value = ItemType> {
1232        prop::sample::select(ItemType::ALL.to_vec())
1233    }
1234
1235    /// A valid scheme: literal text and placeholders with exactly one `{SEQ}`.
1236    fn schemes() -> impl Strategy<Value = String> {
1237        let token = prop_oneof![
1238            "[A-Za-z0-9][A-Za-z0-9._-]{0,5}",
1239            Just("{PROJECT}".to_string()),
1240            Just("{type}".to_string()),
1241            Just("{YYYY}".to_string()),
1242            Just("{YY}".to_string()),
1243            Just("{MM}".to_string()),
1244            Just("-".to_string()),
1245        ];
1246        (
1247            prop::collection::vec(token.clone(), 0..3),
1248            1usize..=9,
1249            prop::collection::vec(token, 0..3),
1250        )
1251            .prop_map(|(before, width, after)| {
1252                format!("{}{{SEQ:{width}}}{}", before.concat(), after.concat())
1253            })
1254    }
1255
1256    proptest! {
1257        #[test]
1258        fn revision_codes_round_trip(seq in 1..=MAX_READABLE_SEQ) {
1259            let code = revision_code(seq);
1260            prop_assert!(code.len() <= 6);
1261            prop_assert!(code.bytes().all(|b| REVISION_ALPHABET.contains(&b)));
1262            prop_assert_eq!(revision_seq(&code), Some(seq));
1263        }
1264
1265        #[test]
1266        fn revision_codes_sort_like_their_sequence(a in 1..=MAX_READABLE_SEQ, b in 1..=MAX_READABLE_SEQ) {
1267            let (ca, cb) = (revision_code(a), revision_code(b));
1268            // shorter codes come first, equal lengths sort alphabetically
1269            prop_assert_eq!(a.cmp(&b), (ca.len(), &ca).cmp(&(cb.len(), &cb)));
1270        }
1271
1272        #[test]
1273        fn revision_seq_never_panics_and_only_accepts_canonical_codes(code in "\\PC{0,12}") {
1274            if let Some(seq) = revision_seq(&code) {
1275                prop_assert!(seq >= 1);
1276                prop_assert_eq!(revision_code(seq), code.trim().to_ascii_uppercase());
1277            }
1278        }
1279
1280        #[test]
1281        fn number_schemes_never_panic(raw in "\\PC{0,120}") {
1282            let _ = NumberScheme::parse(&raw);
1283        }
1284
1285        #[test]
1286        fn number_schemes_with_braces_never_panic(raw in "[A-Za-z0-9{}:._ -]{0,120}") {
1287            let _ = NumberScheme::parse(&raw);
1288        }
1289
1290        #[test]
1291        fn valid_schemes_have_one_canonical_spelling(raw in schemes()) {
1292            // a generated scheme can still be too long; only valid ones count
1293            if let Ok(scheme) = NumberScheme::parse(&raw) {
1294                let canonical = scheme.as_string();
1295                let again = NumberScheme::parse(&canonical).expect("the canonical spelling parses");
1296                prop_assert_eq!(&again, &scheme);
1297                prop_assert_eq!(again.as_string(), canonical);
1298            }
1299        }
1300
1301        #[test]
1302        fn generated_numbers_are_read_back_and_fit(
1303            raw in schemes(),
1304            ty in item_types(),
1305            project in "[A-Z][A-Z0-9]{0,31}",
1306            year in 1990i32..2200,
1307            month in 1u32..=12,
1308            seq in 0i64..1_000_000_000,
1309        ) {
1310            if let Ok(scheme) = NumberScheme::parse(&raw) {
1311                let ctx = NumberContext { project: &project, item_type: ty, year, month };
1312                let number = scheme.format(&ctx, seq);
1313                prop_assert_eq!(scheme.seq_of(&ctx, &number), Some(seq), "{} -> {}", raw, number);
1314                // the counter does not depend on the sequence or its width
1315                prop_assert!(scheme.counter_key(&ctx).starts_with("num:"));
1316                // the item-number alphabet and length rule of the items table
1317                prop_assert!(number.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')), "{number}");
1318                prop_assert!(number.starts_with(|c: char| c.is_ascii_alphanumeric()));
1319                let width = scheme.as_string().split("{SEQ").nth(1).map_or(1, |s| {
1320                    s.trim_start_matches(':').split('}').next().and_then(|n| n.parse().ok()).unwrap_or(1)
1321                });
1322                if seq < 10i64.pow(width as u32) {
1323                    prop_assert!(number.len() <= 64, "{} is {} characters", number, number.len());
1324                }
1325            }
1326        }
1327
1328        #[test]
1329        fn numbers_of_other_prefixes_are_not_read(
1330            raw in schemes(),
1331            project in "[A-Z][A-Z0-9]{0,31}",
1332            other in "[a-z]{1,4}",
1333        ) {
1334            if let Ok(scheme) = NumberScheme::parse(&raw) {
1335                let ctx = NumberContext { project: &project, item_type: ItemType::Part, year: 2026, month: 11 };
1336                let number = format!("{other}{}", scheme.format(&ctx, 7));
1337                // lower case never occurs in generated numbers
1338                prop_assert_eq!(scheme.seq_of(&ctx, &number), None);
1339            }
1340        }
1341
1342        #[test]
1343        fn lifecycle_states_only_move_forward_or_back_to_work(a in lifecycle_states(), b in lifecycle_states()) {
1344            if a.can_transition_to(b) {
1345                prop_assert!(a != b);
1346                // nothing leaves Obsolete, nothing re-enters Released from outside review
1347                prop_assert!(a != LifecycleState::Obsolete);
1348                if b == LifecycleState::Released {
1349                    prop_assert_eq!(a, LifecycleState::InReview);
1350                }
1351                // editable states are open, and only editable states can be left to review
1352                if a.is_editable() {
1353                    prop_assert!(a.is_open());
1354                }
1355            }
1356        }
1357
1358        #[test]
1359        fn lifecycle_has_no_path_back_from_released(start in lifecycle_states(), steps in prop::collection::vec(lifecycle_states(), 0..12)) {
1360            let mut at = start;
1361            let mut released = start == LifecycleState::Released;
1362            for next in steps {
1363                if at.can_transition_to(next) {
1364                    at = next;
1365                    released |= at == LifecycleState::Released;
1366                }
1367                if released {
1368                    prop_assert!(matches!(at, LifecycleState::Released | LifecycleState::Obsolete));
1369                }
1370            }
1371        }
1372    }
1373}