1mod 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 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 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 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 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 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 #[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 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 const SKINS_CSS: &str = include_str!("../../shared/css/skins.css");
375
376 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
740 review_outcome_with(&[(a, Approved), (b, Approved), (c, Rejected)], &two),
741 ReviewOutcome::Rejected
742 );
743 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 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 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 let wider = NumberScheme::parse("{PROJECT}-{TYPE}-{YYYY}-{SEQ:6}").unwrap();
850 assert_eq!(wider.counter_key(&ctx), s.counter_key(&ctx));
851
852 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 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", "{SEQ}-{SEQ:3}", "{SEQ:0}", "{SEQ:13}", "{SEQ:x}", "{DATE}-{SEQ}", "{TYPE-{SEQ}", "{TYPE} {SEQ}", "A/{SEQ}", "-{SEQ}", "{PROJECT}-{PROJECT}-{SEQ:4}", &format!("{}{{SEQ}}", "A".repeat(100)), ] {
899 assert!(
900 NumberScheme::parse(bad).is_err(),
901 "{bad:?} must be rejected"
902 );
903 }
904 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 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 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 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 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 assert!(validate_password(&"x".repeat(MIN_PASSWORD_LEN - 1)).is_err());
1045 assert!(validate_password(&"x".repeat(MIN_PASSWORD_LEN)).is_ok());
1046 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 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 assert!(New.can_transition_to(InTriage));
1076 assert!(InTriage.can_transition_to(Accepted));
1077 assert!(Accepted.can_transition_to(Implemented));
1078 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 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 assert!(!New.can_transition_to(Implemented));
1092 assert!(!InTriage.can_transition_to(Implemented));
1093 assert!(!Accepted.can_transition_to(Rejected));
1094 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 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 assert!(normalize_folder(&"y".repeat(MAX_FOLDER_SEGMENT)).is_ok());
1189 let path = |len: usize| {
1190 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 for ok in ["COMX", "LPTA", "CONSOLE", "nullable"] {
1206 assert!(normalize_folder(ok).is_ok(), "{ok}");
1207 }
1208 }
1209}
1210
1211#[cfg(test)]
1214mod property_tests {
1215 use super::*;
1216 use proptest::prelude::*;
1217
1218 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 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 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 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 prop_assert!(scheme.counter_key(&ctx).starts_with("num:"));
1316 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 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 prop_assert!(a != LifecycleState::Obsolete);
1348 if b == LifecycleState::Released {
1349 prop_assert_eq!(a, LifecycleState::InReview);
1350 }
1351 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}