Skip to content

Commit 0a5c356

Browse files
marcnuluclaude
andcommitted
Add 29 tests for recur flatten: XML, JSON, format detection, cross-format
Tests cover: simple/nested elements, attributes, repeated siblings with indexing, CDATA, whitespace handling, self-closing tags, custom separators, max depth, namespace preservation, entity decoding, JSON objects/arrays/ primitives, format auto-detection, and cross-format path equivalence (same XML and JSON produce same hierarchy paths). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2c4309 commit 0a5c356

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

src/main_command_flatten_impl.rs

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,3 +441,355 @@ pub fn execute(
441441

442442
Ok(())
443443
}
444+
445+
// ── Tests ────────────────────────────────────────────────────
446+
447+
#[cfg(test)]
448+
mod tests {
449+
use super::*;
450+
451+
// Helper to get path-value pairs for easy assertion
452+
fn flat(entries: &[FlatEntry]) -> Vec<(&str, Option<&str>)> {
453+
entries
454+
.iter()
455+
.map(|e| (e.path.as_str(), e.value.as_deref()))
456+
.collect()
457+
}
458+
459+
// ── XML: Basic elements ──────────────────────────────────
460+
461+
#[test]
462+
fn xml_simple_elements() {
463+
let xml = r#"<root><name>hello</name><version>1.0</version></root>"#;
464+
let entries = flatten_xml(xml, '.', 0).unwrap();
465+
let pairs = flat(&entries);
466+
assert_eq!(pairs, vec![
467+
("root.name", Some("hello")),
468+
("root.version", Some("1.0")),
469+
]);
470+
}
471+
472+
#[test]
473+
fn xml_nested_elements() {
474+
let xml = r#"<a><b><c>deep</c></b></a>"#;
475+
let entries = flatten_xml(xml, '.', 0).unwrap();
476+
assert_eq!(entries.len(), 1);
477+
assert_eq!(entries[0].path, "a.b.c");
478+
assert_eq!(entries[0].value.as_deref(), Some("deep"));
479+
}
480+
481+
#[test]
482+
fn xml_empty_element() {
483+
let xml = r#"<root><empty></empty><has>text</has></root>"#;
484+
let entries = flatten_xml(xml, '.', 0).unwrap();
485+
// Empty element produces no entry (no text, no attrs)
486+
assert_eq!(entries.len(), 1);
487+
assert_eq!(entries[0].path, "root.has");
488+
}
489+
490+
// ── XML: Attributes ──────────────────────────────────────
491+
492+
#[test]
493+
fn xml_attributes() {
494+
let xml = r#"<file src="tools\**" target="tools" />"#;
495+
let entries = flatten_xml(xml, '.', 0).unwrap();
496+
let pairs = flat(&entries);
497+
assert_eq!(pairs, vec![
498+
("file@src", Some("tools\\**")),
499+
("file@target", Some("tools")),
500+
]);
501+
}
502+
503+
#[test]
504+
fn xml_element_with_attrs_and_text() {
505+
let xml = r#"<item id="42" type="widget">gadget</item>"#;
506+
let entries = flatten_xml(xml, '.', 0).unwrap();
507+
let pairs = flat(&entries);
508+
assert_eq!(pairs, vec![
509+
("item@id", Some("42")),
510+
("item@type", Some("widget")),
511+
("item", Some("gadget")),
512+
]);
513+
}
514+
515+
// ── XML: Repeated siblings ───────────────────────────────
516+
517+
#[test]
518+
fn xml_repeated_siblings_get_indexed() {
519+
let xml = r#"<list><item>a</item><item>b</item><item>c</item></list>"#;
520+
let entries = flatten_xml(xml, '.', 0).unwrap();
521+
let pairs = flat(&entries);
522+
assert_eq!(pairs, vec![
523+
("list.item[0]", Some("a")),
524+
("list.item[1]", Some("b")),
525+
("list.item[2]", Some("c")),
526+
]);
527+
}
528+
529+
#[test]
530+
fn xml_single_element_not_indexed() {
531+
let xml = r#"<list><item>only</item></list>"#;
532+
let entries = flatten_xml(xml, '.', 0).unwrap();
533+
assert_eq!(entries[0].path, "list.item"); // No [0]
534+
}
535+
536+
#[test]
537+
fn xml_mixed_siblings_only_duplicates_indexed() {
538+
let xml = r#"<root><name>foo</name><tag>a</tag><tag>b</tag></root>"#;
539+
let entries = flatten_xml(xml, '.', 0).unwrap();
540+
let pairs = flat(&entries);
541+
assert_eq!(pairs, vec![
542+
("root.name", Some("foo")),
543+
("root.tag[0]", Some("a")),
544+
("root.tag[1]", Some("b")),
545+
]);
546+
}
547+
548+
// ── XML: CDATA ───────────────────────────────────────────
549+
550+
#[test]
551+
fn xml_cdata_treated_as_text() {
552+
let xml = r#"<doc><content><![CDATA[Hello <world>]]></content></doc>"#;
553+
let entries = flatten_xml(xml, '.', 0).unwrap();
554+
assert_eq!(entries[0].path, "doc.content");
555+
assert_eq!(entries[0].value.as_deref(), Some("Hello <world>"));
556+
}
557+
558+
// ── XML: Whitespace handling ─────────────────────────────
559+
560+
#[test]
561+
fn xml_whitespace_only_text_ignored() {
562+
let xml = r#"<root>
563+
<child>value</child>
564+
</root>"#;
565+
let entries = flatten_xml(xml, '.', 0).unwrap();
566+
// Only the actual text content, not the formatting whitespace
567+
assert_eq!(entries.len(), 1);
568+
assert_eq!(entries[0].path, "root.child");
569+
assert_eq!(entries[0].value.as_deref(), Some("value"));
570+
}
571+
572+
// ── XML: Self-closing tags ───────────────────────────────
573+
574+
#[test]
575+
fn xml_self_closing_with_attrs() {
576+
let xml = r#"<config><db host="localhost" port="5432" /></config>"#;
577+
let entries = flatten_xml(xml, '.', 0).unwrap();
578+
let pairs = flat(&entries);
579+
assert_eq!(pairs, vec![
580+
("config.db@host", Some("localhost")),
581+
("config.db@port", Some("5432")),
582+
]);
583+
}
584+
585+
// ── XML: Custom separator ────────────────────────────────
586+
587+
#[test]
588+
fn xml_custom_separator() {
589+
let xml = r#"<a><b><c>val</c></b></a>"#;
590+
let entries = flatten_xml(xml, '/', 0).unwrap();
591+
assert_eq!(entries[0].path, "a/b/c");
592+
}
593+
594+
// ── XML: Max depth ───────────────────────────────────────
595+
596+
#[test]
597+
fn xml_max_depth_limits_traversal() {
598+
let xml = r#"<a><b><c><d>deep</d></c></b></a>"#;
599+
let entries = flatten_xml(xml, '.', 2).unwrap();
600+
// Depth 2 means: a (depth 0) -> b (depth 1) -> c (depth 2, but children skipped)
601+
assert!(entries.is_empty() || !entries.iter().any(|e| e.path.contains("d")));
602+
}
603+
604+
// ── XML: Namespaces ──────────────────────────────────────
605+
606+
#[test]
607+
fn xml_namespace_prefix_preserved() {
608+
let xml = r#"<soap:Envelope><soap:Body><ns:Data>val</ns:Data></soap:Body></soap:Envelope>"#;
609+
let entries = flatten_xml(xml, '.', 0).unwrap();
610+
assert_eq!(entries[0].path, "soap:Envelope.soap:Body.ns:Data");
611+
assert_eq!(entries[0].value.as_deref(), Some("val"));
612+
}
613+
614+
// ── XML: Real-world nuspec ───────────────────────────────
615+
616+
#[test]
617+
fn xml_nuspec_structure() {
618+
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
619+
<package>
620+
<metadata>
621+
<id>recur</id>
622+
<version>0.2.1</version>
623+
<title>recur</title>
624+
</metadata>
625+
<files>
626+
<file src="tools\**" target="tools" />
627+
</files>
628+
</package>"#;
629+
let entries = flatten_xml(xml, '.', 0).unwrap();
630+
let pairs = flat(&entries);
631+
assert!(pairs.contains(&("package.metadata.id", Some("recur"))));
632+
assert!(pairs.contains(&("package.metadata.version", Some("0.2.1"))));
633+
assert!(pairs.contains(&("package.metadata.title", Some("recur"))));
634+
assert!(pairs.contains(&("package.files.file@src", Some("tools\\**"))));
635+
assert!(pairs.contains(&("package.files.file@target", Some("tools"))));
636+
}
637+
638+
// ── XML: Entity references ───────────────────────────────
639+
640+
#[test]
641+
fn xml_entities_decoded() {
642+
let xml = r#"<root><text>a &amp; b &lt; c</text></root>"#;
643+
let entries = flatten_xml(xml, '.', 0).unwrap();
644+
assert_eq!(entries[0].value.as_deref(), Some("a & b < c"));
645+
}
646+
647+
// ── JSON: Basic objects ──────────────────────────────────
648+
649+
#[test]
650+
fn json_simple_object() {
651+
let json = r#"{"name":"recur","version":"0.2.5"}"#;
652+
let entries = flatten_json(json, '.', 0).unwrap();
653+
let pairs = flat(&entries);
654+
assert!(pairs.contains(&("name", Some("recur"))));
655+
assert!(pairs.contains(&("version", Some("0.2.5"))));
656+
}
657+
658+
#[test]
659+
fn json_nested_object() {
660+
let json = r#"{"config":{"database":{"host":"localhost","port":"5432"}}}"#;
661+
let entries = flatten_json(json, '.', 0).unwrap();
662+
let pairs = flat(&entries);
663+
assert!(pairs.contains(&("config.database.host", Some("localhost"))));
664+
assert!(pairs.contains(&("config.database.port", Some("5432"))));
665+
}
666+
667+
// ── JSON: Arrays ─────────────────────────────────────────
668+
669+
#[test]
670+
fn json_arrays_indexed() {
671+
let json = r#"{"tags":["search","grep","hierarchy"]}"#;
672+
let entries = flatten_json(json, '.', 0).unwrap();
673+
let pairs = flat(&entries);
674+
assert!(pairs.contains(&("tags[0]", Some("search"))));
675+
assert!(pairs.contains(&("tags[1]", Some("grep"))));
676+
assert!(pairs.contains(&("tags[2]", Some("hierarchy"))));
677+
}
678+
679+
#[test]
680+
fn json_array_of_objects() {
681+
let json = r#"{"users":[{"name":"Joe"},{"name":"Skippy"}]}"#;
682+
let entries = flatten_json(json, '.', 0).unwrap();
683+
let pairs = flat(&entries);
684+
assert!(pairs.contains(&("users[0].name", Some("Joe"))));
685+
assert!(pairs.contains(&("users[1].name", Some("Skippy"))));
686+
}
687+
688+
// ── JSON: Primitive types ────────────────────────────────
689+
690+
#[test]
691+
fn json_booleans_and_null() {
692+
let json = r#"{"active":true,"deleted":false,"notes":null}"#;
693+
let entries = flatten_json(json, '.', 0).unwrap();
694+
let pairs = flat(&entries);
695+
assert!(pairs.contains(&("active", Some("true"))));
696+
assert!(pairs.contains(&("deleted", Some("false"))));
697+
assert!(pairs.contains(&("notes", Some("null"))));
698+
}
699+
700+
#[test]
701+
fn json_numbers() {
702+
let json = r#"{"count":42,"ratio":3.14}"#;
703+
let entries = flatten_json(json, '.', 0).unwrap();
704+
let pairs = flat(&entries);
705+
assert!(pairs.contains(&("count", Some("42"))));
706+
assert!(pairs.contains(&("ratio", Some("3.14"))));
707+
}
708+
709+
// ── JSON: Max depth ──────────────────────────────────────
710+
711+
#[test]
712+
fn json_max_depth_limits_traversal() {
713+
let json = r#"{"a":{"b":{"c":{"d":"deep"}}}}"#;
714+
let entries = flatten_json(json, '.', 2).unwrap();
715+
// At depth 2 we hit "a.b" which contains {"c":{"d":"deep"}}
716+
// That should be serialized as a raw JSON string
717+
assert!(!entries.iter().any(|e| e.path == "a.b.c.d"));
718+
assert!(entries.iter().any(|e| e.path == "a.b"));
719+
}
720+
721+
// ── JSON: Custom separator ───────────────────────────────
722+
723+
#[test]
724+
fn json_custom_separator() {
725+
let json = r#"{"a":{"b":"val"}}"#;
726+
let entries = flatten_json(json, '/', 0).unwrap();
727+
assert_eq!(entries[0].path, "a/b");
728+
}
729+
730+
// ── JSON: Real-world package.json style ──────────────────
731+
732+
#[test]
733+
fn json_package_json_style() {
734+
let json = r#"{
735+
"name": "my-app",
736+
"version": "1.0.0",
737+
"dependencies": {
738+
"express": "^4.18.0",
739+
"lodash": "^4.17.21"
740+
},
741+
"scripts": {
742+
"start": "node index.js",
743+
"test": "jest"
744+
}
745+
}"#;
746+
let entries = flatten_json(json, '.', 0).unwrap();
747+
let pairs = flat(&entries);
748+
assert!(pairs.contains(&("name", Some("my-app"))));
749+
assert!(pairs.contains(&("dependencies.express", Some("^4.18.0"))));
750+
assert!(pairs.contains(&("scripts.test", Some("jest"))));
751+
}
752+
753+
// ── Format detection ─────────────────────────────────────
754+
755+
#[test]
756+
fn detect_xml_extensions() {
757+
assert_eq!(detect_format(Some(&PathBuf::from("file.xml")), None), Format::Xml);
758+
assert_eq!(detect_format(Some(&PathBuf::from("file.nuspec")), None), Format::Xml);
759+
assert_eq!(detect_format(Some(&PathBuf::from("file.csproj")), None), Format::Xml);
760+
assert_eq!(detect_format(Some(&PathBuf::from("file.svg")), None), Format::Xml);
761+
}
762+
763+
#[test]
764+
fn detect_json_extensions() {
765+
assert_eq!(detect_format(Some(&PathBuf::from("file.json")), None), Format::Json);
766+
assert_eq!(detect_format(Some(&PathBuf::from("data.jsonl")), None), Format::Json);
767+
}
768+
769+
#[test]
770+
fn detect_format_override() {
771+
// Override should win over extension
772+
assert_eq!(detect_format(Some(&PathBuf::from("file.xml")), Some("json")), Format::Json);
773+
assert_eq!(detect_format(Some(&PathBuf::from("file.json")), Some("xml")), Format::Xml);
774+
}
775+
776+
// ── Cross-format: same hierarchy, same output ────────────
777+
778+
#[test]
779+
fn xml_and_json_produce_same_paths() {
780+
let xml = r#"<config><db><host>localhost</host><port>5432</port></db></config>"#;
781+
let json = r#"{"config":{"db":{"host":"localhost","port":"5432"}}}"#;
782+
783+
let xml_entries = flatten_xml(xml, '.', 0).unwrap();
784+
let json_entries = flatten_json(json, '.', 0).unwrap();
785+
786+
let xml_pairs: Vec<_> = xml_entries.iter().map(|e| (&e.path, e.value.as_deref())).collect();
787+
let json_pairs: Vec<_> = json_entries.iter().map(|e| (&e.path, e.value.as_deref())).collect();
788+
789+
// Both should produce config.db.host = localhost and config.db.port = 5432
790+
assert!(xml_pairs.iter().any(|(p, v)| p.as_str() == "config.db.host" && *v == Some("localhost")));
791+
assert!(json_pairs.iter().any(|(p, v)| p.as_str() == "config.db.host" && *v == Some("localhost")));
792+
assert!(xml_pairs.iter().any(|(p, v)| p.as_str() == "config.db.port" && *v == Some("5432")));
793+
assert!(json_pairs.iter().any(|(p, v)| p.as_str() == "config.db.port" && *v == Some("5432")));
794+
}
795+
}

0 commit comments

Comments
 (0)