//! Linetypes and hatch patterns from the user's own `.lin` / `.pat` files (#552): LINETYPE's JSON //! form, -LINETYPE at the command line, HATCH with a pattern file, HATCH.PATTERNS. The files are //! tiny synthetic ones written here, with our own names. use cadcraft_engine::Session; use cadcraft_engine::doc::{EntityKind, Hatch}; use serde_json::json; const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n"; /// A user file in the test's temp folder; returns its path. fn file(name: &str, text: &str) -> String { let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let p = dir.join(name); std::fs::write(&p, text).unwrap(); p.to_string_lossy().into_owned() } fn linetype_pattern(s: &Session, name: &str) -> Option> { s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect()) } #[test] fn linetype_json_loads_named_linetypes_from_file_text() { let mut s = Session::new(); let r = s.execute("linetype", &json!({ "load": "MYDASH", "text": LIN, "current": "MYDASH" })).unwrap(); assert_eq!(r["added"], json!(["MYDASH"])); assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![0.5, -0.25])); assert!(linetype_pattern(&s, "MYTEXT").is_none(), "only the named linetype is loaded"); assert_eq!(s.doc().unwrap().header.str("CELTYPE", ""), "MYDASH"); // All of them by default; one already loaded is kept unless asked to reload. let r = s.execute("linetype", &json!({ "text": LIN.replace(".5,-.25", "1,-1") })).unwrap(); assert_eq!((r["added"].clone(), r["alreadyLoaded"].clone()), (json!(["MYTEXT"]), json!(["MYDASH"]))); assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![0.5, -0.25])); let text = &s.doc().unwrap().linetype("MYTEXT").unwrap().pattern[1]; assert_eq!(text.text.as_deref(), Some("MY")); let r = s.execute("linetype", &json!({ "load": "my*", "text": LIN.replace(".5,-.25", "1,-1"), "reload": true })).unwrap(); assert_eq!(r["reloaded"], json!(["MYDASH", "MYTEXT"])); assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![1.0, -1.0])); // Undo takes a file load back like any change. s.execute("undo", &json!({})).unwrap(); assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![0.5, -0.25])); } #[test] fn linetype_json_reads_a_lin_file_by_path() { let path = file("mine.lin", LIN); let mut s = Session::new(); s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap(); assert!(linetype_pattern(&s, "MYTEXT").is_some()); let err = s.execute("linetype", &json!({ "load": "NOPE", "file": path })).unwrap_err().to_string(); assert!(err.contains("NOPE"), "{err}"); let err = s.execute("linetype", &json!({ "file": "/no/such/dir/x.lin" })).unwrap_err().to_string(); assert!(err.contains("x.lin"), "{err}"); } #[test] fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() { let path = file("cmdline.lin", LIN); let mut s = Session::new(); s.cmdline("-linetype").unwrap(); for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] { s.cmdline(line).unwrap(); } assert!(s.running.is_some(), "back at the option prompt"); s.cmdline("").unwrap(); assert!(s.running.is_none()); assert!(linetype_pattern(&s, "MYDASH").is_some() && linetype_pattern(&s, "MYTEXT").is_some()); assert_eq!(s.doc().unwrap().header.str("CELTYPE", ""), "MYTEXT"); assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log); // Already loaded: asked whether to reload, in the file's order. let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2")); s.cmdline("-linetype").unwrap(); for line in ["l", "*", changed.as_str()] { s.cmdline(line).unwrap(); } assert!(s.current_prompt().unwrap().message.contains("MYDASH is already loaded"), "{:?}", s.current_prompt()); s.cmdline("y").unwrap(); assert!(s.current_prompt().unwrap().message.contains("MYTEXT is already loaded")); s.cmdline("n").unwrap(); s.cmdline("").unwrap(); assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![2.0, -2.0])); // Enter at the file prompt: the built-in library. s.cmdline("-linetype").unwrap(); for line in ["l", "DASHED", "", ""] { s.cmdline(line).unwrap(); } assert!(s.running.is_none()); assert!(linetype_pattern(&s, "DASHED").is_some()); } const PAT: &str = "*MYGRID,My grid\n0,0,0,0,.5\n90,0,0,0,.5\n*MYSTEPS,My steps\n0,0,0,.25,.25,.25,-.25\n"; fn hatches(s: &Session) -> Vec { s.doc() .unwrap() .model .iter() .filter_map(|e| match &e.kind { EntityKind::Hatch(h) => Some(h.clone()), _ => None, }) .collect() } #[test] fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() { let mut s = Session::new(); s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "MYGRID", "patternText": PAT })).unwrap(); let h = hatches(&s).pop().unwrap(); assert_eq!(h.pattern, "MYGRID"); assert_eq!(h.pattern_lines.len(), 2); assert_eq!(h.pattern_lines[1].angle, 90.0); let list = s.execute("hatch.patterns", &json!({})).unwrap(); let user: Vec<&str> = list["user"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect(); assert!(user.contains(&"MYGRID") && user.contains(&"MYSTEPS"), "{list}"); assert!(list["standard"].as_array().unwrap().iter().any(|n| n == "ANSI31")); // Another pattern from the list replaces the definition the hatch carries; a library one clears it. let hx = s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).unwrap().handle.hex(); s.execute("hatchedit", &json!({ "handles": [hx], "pattern": "MYSTEPS" })).unwrap(); assert_eq!(hatches(&s).pop().unwrap().pattern_lines[0].dashes, [0.25, -0.25]); s.execute("hatchedit", &json!({ "handles": [hx], "pattern": "ANSI31" })).unwrap(); assert!(hatches(&s).pop().unwrap().pattern_lines.is_empty()); } #[test] fn hatch_pattern_prompt_takes_a_pat_file_name() { let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n"); let mut s = Session::new(); s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); s.cmdline("hatch").unwrap(); s.cmdline("p").unwrap(); s.cmdline(&path).unwrap(); // The pattern named like the file is the one used. s.cmdline("5,5").unwrap(); s.cmdline("").unwrap(); let h = hatches(&s).pop().unwrap(); assert_eq!((h.pattern.as_str(), h.pattern_lines.len()), ("MYSTAIRS", 2)); assert_eq!(s.doc().unwrap().header.str("HPNAME", ""), "MYSTAIRS"); // A loaded pattern is now offered by name, too. s.cmdline("hatch").unwrap(); s.cmdline("p").unwrap(); s.cmdline("myother").unwrap(); s.cmdline("5,5").unwrap(); s.cmdline("").unwrap(); assert_eq!(hatches(&s).iter().filter(|h| h.pattern == "MYOTHER").count(), 1); } #[test] fn a_pat_file_cannot_replace_a_library_pattern() { let mut s = Session::new(); let r = s.execute("hatch.patterns", &json!({ "text": "*ANSI31\n0,0,0,0,9\n*MYKEEP\n0,0,0,0,1\n" })).unwrap(); assert_eq!(r["added"], json!(["MYKEEP"])); assert!(r["warnings"].to_string().contains("ANSI31")); } #[test] fn hostile_support_files_never_panic() { let mut s = Session::new(); s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); let huge = "x".repeat(9 << 20); let garbage = "*\u{0}[\"]]\n,,,,\n*X\nA,[[[\n\u{feff}*Y\n1e999,0,0,0,1\n"; for text in [huge.as_str(), garbage, "", "*A\n"] { assert!(s.execute("linetype", &json!({ "text": text })).is_err()); assert!(s.execute("hatch", &json!({ "points": [[5, 5]], "patternText": text })).is_err()); let _ = s.execute("hatch.patterns", &json!({ "text": text })); } for p in [json!({ "file": 7 }), json!({ "file": [] }), json!({ "text": null, "file": null })] { let _ = s.execute("linetype", &p); let _ = s.execute("hatch.patterns", &p); } assert!(hatches(&s).is_empty()); }