use mem_store::{ObsidianProjector, EventRecord}; use serde_json::json; use std::fs; use std::path::Path; use std::thread; use std::time::Duration; fn make_test_events(project: &str, query: &str) -> Vec { vec![ EventRecord { project: project.to_string(), query: query.to_string(), run: "run1".to_string(), turn: 1, event_type: "Gate".to_string(), data: json!({}), }, EventRecord { project: project.to_string(), query: query.to_string(), run: "run1".to_string(), turn: 2, event_type: "Evidence".to_string(), data: json!({"parent": "pi-source-001"}), }, EventRecord { project: project.to_string(), query: query.to_string(), run: "run1".to_string(), turn: 3, event_type: "Memory".to_string(), data: json!({"text": "test memory"}), }, ] } #[test] fn a1_byte_identical_twice() { let _ = fs::remove_dir_all("test_vault_1a"); let _ = fs::remove_dir_all("test_vault_1b"); let events = make_test_events("proj", "query1"); // Project to first vault let p1 = ObsidianProjector::new("log1", "test_vault_1a", false); p1.project(&events).unwrap(); // Project to second vault let p2 = ObsidianProjector::new("log2", "test_vault_1b", false); p2.project(&events).unwrap(); // Compare files byte-by-byte let files1 = collect_files("test_vault_1a"); let files2 = collect_files("test_vault_1b"); assert_eq!(files1.len(), files2.len(), "File counts differ"); for file in files1.iter() { let path1 = format!("test_vault_1a/{}", file); let path2 = format!("test_vault_1b/{}", file); let content1 = fs::read_to_string(&path1).unwrap(); let content2 = fs::read_to_string(&path2).unwrap(); assert_eq!( content1, content2, "File {} differs between projections", file ); } let _ = fs::remove_dir_all("test_vault_1a"); let _ = fs::remove_dir_all("test_vault_1b"); } #[test] fn a2_no_generation_timestamp() { let _ = fs::remove_dir_all("test_vault_2a"); let _ = fs::remove_dir_all("test_vault_2b"); let events = make_test_events("proj", "query2"); let projector = ObsidianProjector::new("log", "test_vault_2a", false); // First projection projector.project(&events).unwrap(); let content1 = fs::read_to_string("test_vault_2a/proj/query2.md").unwrap(); // Sleep to ensure time passes thread::sleep(Duration::from_millis(100)); // Second projection (same events) let projector2 = ObsidianProjector::new("log", "test_vault_2b", false); projector2.project(&events).unwrap(); let content2 = fs::read_to_string("test_vault_2b/proj/query2.md").unwrap(); assert_eq!(content1, content2, "Content should be identical despite time passing"); let _ = fs::remove_dir_all("test_vault_2a"); let _ = fs::remove_dir_all("test_vault_2b"); } #[test] fn a3_frontmatter_key_order() { let _ = fs::remove_dir_all("test_vault_3"); let events = make_test_events("proj", "query3"); let projector = ObsidianProjector::new("log", "test_vault_3", false); projector.project(&events).unwrap(); let content = fs::read_to_string("test_vault_3/proj/query3.md").unwrap(); // Extract frontmatter let lines: Vec<&str> = content.lines().collect(); assert_eq!(lines[0], "---", "First line should be ---"); // Find key order let mut fm_lines = Vec::new(); for i in 1..lines.len() { if lines[i] == "---" { break; } fm_lines.push(lines[i]); } // Verify stable alphabetical order (BTreeMap) for i in 1..fm_lines.len() { let key1 = fm_lines[i - 1].split(':').next().unwrap(); let key2 = fm_lines[i].split(':').next().unwrap(); assert!( key1 <= key2, "Keys not in sorted order: {} > {}", key1, key2 ); } let _ = fs::remove_dir_all("test_vault_3"); } #[test] fn a4_golden_tree() { let _ = fs::remove_dir_all("test_vault_4"); let events = make_test_events("poimen", "infra-debug"); let projector = ObsidianProjector::new("log", "test_vault_4", false); projector.project(&events).unwrap(); // Verify structure assert!(Path::new("test_vault_4/poimen/index.md").exists()); assert!(Path::new("test_vault_4/poimen/infra-debug.md").exists()); // Verify index.md contains title let index = fs::read_to_string("test_vault_4/poimen/index.md").unwrap(); assert!(index.contains("poimen")); // Verify query note contains query title let query_note = fs::read_to_string("test_vault_4/poimen/infra-debug.md").unwrap(); assert!(query_note.contains("infra-debug")); let _ = fs::remove_dir_all("test_vault_4"); } #[test] fn a5_empty_memory_still_writes() { let _ = fs::remove_dir_all("test_vault_5"); let events = vec![EventRecord { project: "proj".to_string(), query: "query5".to_string(), run: "run1".to_string(), turn: 1, event_type: "Gate".to_string(), data: json!({}), }]; let projector = ObsidianProjector::new("log", "test_vault_5", false); projector.project(&events).unwrap(); // File should exist even with empty memory let note = fs::read_to_string("test_vault_5/proj/query5.md").unwrap(); assert!(note.contains("No evidence found"), "Empty memory should say so"); let _ = fs::remove_dir_all("test_vault_5"); } #[test] fn a6_links_bidirectional() { let _ = fs::remove_dir_all("test_vault_6"); let events1 = make_test_events("proj", "query-a"); let events2 = make_test_events("proj", "query-b"); let mut all_events = events1; all_events.extend(events2); let projector = ObsidianProjector::new("log", "test_vault_6", false); projector.project(&all_events).unwrap(); // Both L1 notes should exist assert!(Path::new("test_vault_6/proj/query-a.md").exists()); assert!(Path::new("test_vault_6/proj/query-b.md").exists()); // Index should reference both let index = fs::read_to_string("test_vault_6/proj/index.md").unwrap(); assert!(index.contains("# proj")); let _ = fs::remove_dir_all("test_vault_6"); } #[test] fn a7_evidence_notes_flag() { let _ = fs::remove_dir_all("test_vault_7a"); let _ = fs::remove_dir_all("test_vault_7b"); let events = make_test_events("proj", "query7"); // Without evidence notes let p1 = ObsidianProjector::new("log", "test_vault_7a", false); p1.project(&events).unwrap(); let evidence_dir_a = Path::new("test_vault_7a/proj/evidence"); assert!(!evidence_dir_a.exists(), "Evidence dir should not exist when flag is false"); // With evidence notes (would create evidence/ subdir if implemented) let p2 = ObsidianProjector::new("log", "test_vault_7b", true); p2.project(&events).unwrap(); // For now, flag is tracked but not used in basic version // Real implementation would generate L0 notes here let _ = fs::remove_dir_all("test_vault_7a"); let _ = fs::remove_dir_all("test_vault_7b"); } #[test] fn a8_line_endings() { let _ = fs::remove_dir_all("test_vault_8"); let events = make_test_events("proj", "query8"); let projector = ObsidianProjector::new("log", "test_vault_8", false); projector.project(&events).unwrap(); let content = fs::read_to_string("test_vault_8/proj/query8.md").unwrap(); // No \r (Windows line endings) assert!(!content.contains('\r'), "Should not contain carriage returns"); // Exactly one trailing newline assert!(content.ends_with('\n'), "Must end with newline"); assert!( !content.ends_with("\n\n"), "Must not end with multiple newlines" ); let _ = fs::remove_dir_all("test_vault_8"); } /// Collect all relative paths in directory. fn collect_files(dir: &str) -> Vec { let mut files = Vec::new(); if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { let rel = path.strip_prefix(dir).unwrap(); files.push(rel.to_string_lossy().to_string()); } else if path.is_dir() { let subdir = path.to_string_lossy().to_string(); let subfiles = collect_files(&subdir); let rel = path.strip_prefix(dir).unwrap(); for f in subfiles { files.push(format!("{}/{}", rel.display(), f)); } } } } files.sort(); files }