//! Proves the blocks `discovery` prints are blocks `fleet` can load. //! //! The two are coupled by nothing but a string - `Vendor::toml_value` has to //! match what `fleet::build_printer` matches on, and the fields rendered have //! to be the fields it requires. Unit tests on either side cannot catch a //! drift between them, and the symptom of a drift is the worst kind: an //! operator pastes in exactly what they were told to and the gateway refuses //! to start. //! //! So this renders a candidate for every vendor, fills in the credential the //! way a human would, and loads the result for real. use std::io::Write; use continuum_proxy::discovery::{Candidate, Source, Vendor}; use continuum_proxy::fleet; use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; /// Fills in the credential a human is told to supply, so the entry loads. fn with_credentials(entry: &str) -> String { entry .replace("access_code = \"\"", "access_code = \"12345678\"") .replace("api_key = \"\"", "api_key = \"an-api-key\"") } fn load(toml: &str) -> anyhow::Result> { let mut file = tempfile(); file.write_all(toml.as_bytes()).expect("write the fleet file"); fleet::load(&file.path) } /// A self-deleting temporary file, rather than a dependency for four lines. struct TempToml { path: std::path::PathBuf, handle: Option, } impl TempToml { fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> { self.handle.as_mut().expect("still open").write_all(bytes)?; self.handle.as_mut().expect("still open").flush() } } impl Drop for TempToml { fn drop(&mut self) { self.handle.take(); let _ = std::fs::remove_file(&self.path); } } fn tempfile() -> TempToml { // The pid and a counter, so two tests in one binary cannot collide. use std::sync::atomic::{AtomicUsize, Ordering}; static NEXT: AtomicUsize = AtomicUsize::new(0); let path = std::env::temp_dir().join(format!("continuum-fleet-{}-{}.toml", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); let handle = std::fs::File::create(&path).expect("create a temp fleet file"); TempToml { path, handle: Some(handle) } } fn candidate(vendor: Vendor, host: &str) -> Candidate { let mut candidate = Candidate::new(host, vendor, Source::HttpApi); // Bambu needs one, and for every other vendor it is ignored. candidate.serial = Some("01S09B441900143".to_string()); candidate } #[test] fn every_rendered_entry_loads_as_the_printer_it_claims_to_be() { let cases = [ (Vendor::Bambu, "192.168.0.20"), (Vendor::Klipper, "192.168.0.50"), (Vendor::PrusaLink, "192.168.0.51"), (Vendor::Octoprint, "192.168.0.60"), (Vendor::PrusaSerial, "/dev/ttyACM0"), ]; for (vendor, host) in cases { let entry = candidate(vendor, host).to_toml_entry("found"); let fleet = load(&with_credentials(&entry)) .unwrap_or_else(|err| panic!("{vendor:?} rendered an entry fleet::load rejects: {err}\n{entry}")); assert_eq!(fleet.len(), 1, "{vendor:?}"); assert_eq!(fleet[0].base().id, "found", "{vendor:?}"); // The vendor string has to resolve to the matching handle, not just // to *a* handle - `octoprint` and `prusa_link` share a client, so a // mapping that collapsed them would still load and still be wrong. let matched = matches!( (vendor, &fleet[0]), (Vendor::Bambu, PrinterHandle::Bambu(_)) | (Vendor::Klipper, PrinterHandle::Klipper(_)) | (Vendor::PrusaLink | Vendor::Octoprint, PrinterHandle::OctoPrint(_)) | (Vendor::PrusaSerial, PrinterHandle::PrusaSerial(_)) ); assert!(matched, "{vendor:?} loaded as the wrong handle"); } } /// The flavour is the operator's answer to a question no probe can settle, so /// it has to survive the trip from the file into the printer. #[test] fn the_octoprint_flavour_an_operator_chose_reaches_the_client() { use continuum_proxy::printer::OctoPrintFlavour; for (vendor, expected) in [(Vendor::Octoprint, OctoPrintFlavour::Generic), (Vendor::PrusaLink, OctoPrintFlavour::PrusaLink)] { let entry = with_credentials(&candidate(vendor, "192.168.0.51").to_toml_entry("found")); let fleet = load(&entry).expect("loads"); let PrinterHandle::OctoPrint(printer) = &fleet[0] else { panic!("{vendor:?} should load as an OctoPrint-family client"); }; assert_eq!(printer.flavour, expected, "{vendor:?}"); } } /// An entry straight out of discovery, credential still blank, must be /// refused - and refused in a way that names the printer and the field. #[test] fn an_entry_with_the_credential_left_blank_is_refused_by_name() { for vendor in [Vendor::Bambu, Vendor::PrusaLink, Vendor::Octoprint] { let entry = candidate(vendor, "192.168.0.20").to_toml_entry("unfinished"); let Err(err) = load(&entry) else { panic!("{vendor:?}: a blank credential must not load:\n{entry}"); }; let message = err.to_string(); // This is the whole reason discovery prints instead of writing the // file: the failure is loud, and it says which printer and which // field. assert!(message.contains("unfinished"), "{vendor:?}: {message}"); assert!( message.contains("access_code") || message.contains("api_key"), "{vendor:?} must name the missing field: {message}" ); } }