//! TRACES: FR-CAT-13 //! The interoperability contract, exercised from outside the crate. //! //! The unit tests beside each module check the pieces. This checks the promise //! FR-CAT-13 actually makes to a photographer, through the public API only and //! with no knowledge of how any of it works: what DarkRoom writes, another //! application can read; what another application wrote, DarkRoom can read; and //! what DarkRoom does not understand, it does not destroy. use dr_types::ColourLabel; use dr_xmp::{reconcile, Field, Precedence, Rating, Xmp, XmpError, PROPERTIES}; /// Everything the requirement names, in one record. fn everything() -> Xmp { let mut xmp = Xmp { rating: Some(Rating::Stars(4)), keywords: vec!["puffin".into(), "Iceland".into(), "Bells & whistles".into()], hierarchical_subjects: vec!["Places|Iceland|Látrabjarg".into()], title: Some("Puffin on a cliff".into()), description: Some("Late evening, wind off the sea.".into()), creators: vec!["A. Photographer".into(), "An Assistant".into()], copyright: Some("© 2026 A. Photographer".into()), credit: Some("Puffin Pictures".into()), usage_terms: Some("Editorial use only. No .".into()), label: None, }; xmp.set_colour(Some(ColourLabel::Green)); xmp } /// A sidecar as another application left it: its own develop settings, its own /// structures, and its own opinion about the rating. const SOMEBODY_ELSES: &str = r#" 0, 0 255, 255 gannet "#; #[test] fn what_was_written_is_what_reads_back() { // The round trip the requirement asks for, over every field it names. let before = everything(); let after = Xmp::parse(&before.to_text()).expect("our own packet must read"); assert_eq!(after, before); } #[test] fn the_round_trip_survives_a_second_pass() { // Parse, write, parse, write: a format that drifts on the second pass is a // format that drifts once per sync. let once = everything().to_text(); let twice = Xmp::parse(&once).unwrap().to_text(); assert_eq!(once, twice); } #[test] fn what_was_written_into_another_applications_file_reads_back() { // The same round trip, through the path that actually runs in the field: // there was already a sidecar there. let before = everything(); let text = before.rewrite(SOMEBODY_ELSES).expect("a packet to rewrite"); assert_eq!(Xmp::parse(&text).unwrap(), before); } #[test] fn nothing_of_theirs_is_lost_when_we_write_ours() { // The ownership rule, from the outside. Every assertion here is somebody // else's work, and none of it is a property DarkRoom claims. let text = everything().rewrite(SOMEBODY_ELSES).unwrap(); for theirs in [ "crs:Version=\"15.0\"", "crs:Exposure2012=\"+0.75\"", "crs:ProcessVersion=\"11.0\"", "tiff:Orientation=\"6\"", "crs:ToneCurvePV2012", "0, 0", "255, 255", "", "x:xmptk=\"Adobe XMP Core 5.6-c140\"", "", ] { assert!(text.contains(theirs), "lost {theirs:?} from:\n{text}"); } } #[test] fn theirs_is_replaced_rather_than_left_beside_ours() { // The other half of ownership. Their rating and label and keyword are gone, // because those are ours to state; leaving both copies in would make the // next reader's answer depend on which it met first. let text = everything().rewrite(SOMEBODY_ELSES).unwrap(); assert!(!text.contains("xmp:Rating=\"2\""), "{text}"); assert!(!text.contains("xmp:Label=\"Red\""), "{text}"); assert!(!text.contains("gannet"), "{text}"); let back = Xmp::parse(&text).unwrap(); assert_eq!(back.rating, Some(Rating::Stars(4))); assert_eq!(back.colour(), Some(ColourLabel::Green)); } #[test] fn writing_the_same_thing_twice_changes_nothing() { // A sidecar that grew a block on every save would double in size across a // week of culling, and would sync every time. let once = everything().rewrite(SOMEBODY_ELSES).unwrap(); let twice = everything().rewrite(&once).unwrap(); assert_eq!(once, twice); } #[test] fn a_photograph_with_no_sidecar_gets_a_whole_one() { // The caller that looked for a file and found none holds an empty string. let text = everything().rewrite("").unwrap(); assert_eq!(Xmp::parse(&text).unwrap(), everything()); } #[test] fn a_file_that_is_not_a_packet_is_refused_rather_than_replaced() { // Overwriting a file whose contents were not understood is the one failure // a photographer cannot recover from. assert_eq!( everything().rewrite("not xmp at all"), Err(XmpError::NotXmp) ); assert!(matches!( Xmp::parse(""), Err(XmpError::NotXml(_)) )); } #[test] fn reading_their_file_finds_the_work_they_did_in_it() { // The migration case: a library culled and keyworded somewhere else. let theirs = Xmp::parse(SOMEBODY_ELSES).unwrap(); assert_eq!(theirs.rating, Some(Rating::Stars(2))); assert_eq!(theirs.colour(), Some(ColourLabel::Red)); assert_eq!(theirs.keywords, ["gannet"]); } #[test] fn a_disagreement_is_carried_out_conservatively_and_reported() { // The recorded precedence decision, end to end: their keyword joins ours, // our rating stands because nothing here can order the two edits, and the // caller is told which field disagreed so it can offer the reload. let mine = Xmp { rating: Some(Rating::Stars(5)), keywords: vec!["puffin".into()], ..Xmp::default() }; let theirs = Xmp::parse(SOMEBODY_ELSES).unwrap(); let out = reconcile(&mine, &theirs, Precedence::Catalog); assert_eq!(out.merged.keywords, ["puffin", "gannet"]); assert_eq!(out.merged.rating, Some(Rating::Stars(5))); assert_eq!(out.conflicts, [Field::Rating]); assert_eq!( out.merged.colour(), Some(ColourLabel::Red), "a label we never set is not a disagreement, it is an arrival" ); // And the other way, which only a person's explicit request reaches. let reload = reconcile(&mine, &theirs, Precedence::Sidecar); assert_eq!(reload.merged.rating, Some(Rating::Stars(2))); assert_eq!( reload.merged.keywords, ["puffin", "gannet"], "a reload is still not a reason to throw away a keyword" ); } #[test] fn every_property_the_requirement_names_is_owned() { // The list FR-CAT-13 gives, checked against the table that decides what a // rewrite may touch. A property missing here is one this application can // neither read nor hand back. let names: Vec = PROPERTIES .iter() .map(|p| format!("{}:{}", p.prefix, p.local)) .collect(); for required in [ "dc:subject", "lr:hierarchicalSubject", "xmp:Rating", "xmp:Label", "dc:title", "dc:description", "dc:creator", "dc:rights", "photoshop:Credit", "xmpRights:UsageTerms", ] { assert!( names.iter().any(|n| n == required), "{required} is not in PROPERTIES" ); } }