//! Test result types use crate::specs::grasp01::{get_sections, SpecRef, GRASP_01_REQUIREMENTS, GRASP_COMMIT_ID}; use std::collections::BTreeMap; use std::time::{Duration, Instant}; // ANSI color codes const GREEN: &str = "\x1b[1;92m"; // Bold bright green - ANSI standard for high visibility const RED: &str = "\x1b[1;91m"; // Bold bright red - ANSI standard for high visibility const YELLOW: &str = "\x1b[33m"; const BLUE: &str = "\x1b[34m"; const CYAN: &str = "\x1b[36m"; const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; /// Parse line number(s) from a spec_ref string /// Returns a vector of line numbers that this spec_ref covers /// /// Examples: /// - "GRASP-01:nostr-relay:7" -> [7] /// - "GRASP-01:nostr-relay:7-9" -> [7, 8, 9] /// - "NIP-01:basic:2" -> [] (not a GRASP-01 ref) fn parse_spec_lines(spec_ref: &str) -> Vec { // Only parse GRASP-01 refs if !spec_ref.starts_with("GRASP-01:") { return vec![]; } // Get the last part after the last colon let parts: Vec<&str> = spec_ref.split(':').collect(); if parts.len() < 3 { return vec![]; } let line_part = parts.last().unwrap(); // Handle range format like "7-9" if line_part.contains('-') { let range_parts: Vec<&str> = line_part.split('-').collect(); if range_parts.len() == 2 { if let (Ok(start), Ok(end)) = (range_parts[0].parse::(), range_parts[1].parse::()) { return (start..=end).collect(); } } return vec![]; } // Handle single line number if let Ok(line) = line_part.parse::() { return vec![line]; } vec![] } /// Result of a single test #[derive(Debug, Clone)] pub struct TestResult { pub name: String, pub spec_ref: String, pub requirement: String, pub passed: bool, pub error: Option, pub duration: Duration, } impl TestResult { /// Create a new test result /// /// # Arguments /// * `name` - Test name identifier /// * `spec_ref` - Reference to the spec requirement being tested /// * `requirement` - Human-readable description of what this test validates /// (can be more specific than the general spec text) pub fn new(name: &str, spec_ref: SpecRef, requirement: &str) -> Self { TestResult { name: name.to_string(), spec_ref: spec_ref.spec_ref_string().to_string(), requirement: requirement.to_string(), passed: false, error: None, duration: Duration::default(), } } /// Run a test function and capture the result pub async fn run(mut self, test_fn: F) -> Self where F: FnOnce() -> Fut, Fut: std::future::Future>, { let start = Instant::now(); match test_fn().await { Ok(()) => { self.passed = true; } Err(e) => { self.passed = false; self.error = Some(e); } } self.duration = start.elapsed(); self } /// Mark test as passed pub fn pass(mut self) -> Self { self.passed = true; self } /// Mark test as failed with error pub fn fail(mut self, error: impl Into) -> Self { self.passed = false; self.error = Some(error.into()); self } } /// Collection of test results for a spec #[derive(Debug, Clone)] pub struct AuditResult { pub spec: String, pub results: Vec, } impl AuditResult { /// Create a new audit result pub fn new(spec: impl Into) -> Self { Self { spec: spec.into(), results: Vec::new(), } } /// Add a test result pub fn add(&mut self, result: TestResult) { self.results.push(result); } /// Merge another audit result pub fn merge(&mut self, other: AuditResult) { self.results.extend(other.results); } /// Check if all tests passed pub fn all_passed(&self) -> bool { self.results.iter().all(|r| r.passed) } /// Get count of passed tests pub fn passed_count(&self) -> usize { self.results.iter().filter(|r| r.passed).count() } /// Get count of failed tests pub fn failed_count(&self) -> usize { self.results.iter().filter(|r| !r.passed).count() } /// Get total count of tests pub fn total_count(&self) -> usize { self.results.len() } /// Print a detailed report aligned to GRASP-01 specification pub fn print_report(&self) { println!(); println!( "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", BOLD, RESET ); println!("{}GRASP-01 Compliance Report{}", BOLD, RESET); println!( "Source: github.com/nostr-protocol/grasp (commit: {})", GRASP_COMMIT_ID ); println!( "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", BOLD, RESET ); // Build a map of spec line -> tests that cover it let mut tests_by_line: BTreeMap> = BTreeMap::new(); for result in &self.results { let lines = parse_spec_lines(&result.spec_ref); for line in lines { tests_by_line.entry(line).or_default().push(result); } } // Track how many spec requirements have tests let mut tested_requirements = 0; let total_requirements = GRASP_01_REQUIREMENTS.len(); // Print results organized by section and spec line for section in get_sections() { println!(); println!("{}{}## {}{}", CYAN, BOLD, section, RESET); for req in GRASP_01_REQUIREMENTS .iter() .filter(|r| r.section == section) { println!(); // Print spec requirement in blue println!("{}📘 Line {}: {}{}", BLUE, req.line, req.text, RESET); // Get tests for this line if let Some(tests) = tests_by_line.get(&req.line) { tested_requirements += 1; for test in tests { let (color, status) = if test.passed { (GREEN, "✓") } else { (RED, "✗") }; println!(" {}{} {}{}", color, status, test.name, RESET); if let Some(error) = &test.error { // Truncate long errors let error_display = if error.len() > 100 { format!("{}...", &error[..100]) } else { error.clone() }; println!(" {}Error: {}{}", RED, error_display, RESET); } } } else { println!(" {}⚠️ No Tests Implemented{}", YELLOW, RESET); } } } println!(); println!( "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", BOLD, RESET ); // Summary statistics let passed = self.passed_count(); let total_tests = self.total_count(); let spec_coverage = if total_requirements > 0 { (tested_requirements as f64 / total_requirements as f64) * 100.0 } else { 0.0 }; let pass_rate = if total_tests > 0 { (passed as f64 / total_tests as f64) * 100.0 } else { 0.0 }; let summary_color = if passed == total_tests && tested_requirements == total_requirements { GREEN } else if passed == total_tests { YELLOW } else { RED }; println!( "{}Spec coverage: {}/{} requirements tested ({:.1}%){}", summary_color, tested_requirements, total_requirements, spec_coverage, RESET ); println!( "{}Test results: {}/{} tests passed ({:.1}%){}", summary_color, passed, total_tests, pass_rate, RESET ); println!( "{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", BOLD, RESET ); println!(); } /// Get a summary string pub fn summary(&self) -> String { format!( "{}: {}/{} passed", self.spec, self.passed_count(), self.total_count() ) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_result_pass() { let result = TestResult::new( "test", SpecRef::NostrRelayNip01Compliant, "Test requirement", ) .run(|| async { Ok(()) }) .await; assert!(result.passed); assert!(result.error.is_none()); } #[tokio::test] async fn test_result_fail() { let result = TestResult::new( "test", SpecRef::NostrRelayNip01Compliant, "Test requirement", ) .run(|| async { Err("Failed".to_string()) }) .await; assert!(!result.passed); assert_eq!(result.error, Some("Failed".to_string())); } #[test] fn test_audit_result() { let mut audit = AuditResult::new("Test Spec"); audit.add(TestResult::new("test1", SpecRef::NostrRelayNip01Compliant, "Test 1").pass()); audit.add( TestResult::new( "test2", SpecRef::NostrRelayRejectMissingCloneRelays, "Test 2", ) .fail("Error"), ); assert_eq!(audit.total_count(), 2); assert_eq!(audit.passed_count(), 1); assert_eq!(audit.failed_count(), 1); assert!(!audit.all_passed()); } #[test] fn test_parse_spec_lines_single() { assert_eq!(parse_spec_lines("GRASP-01:nostr-relay:7"), vec![7]); assert_eq!(parse_spec_lines("GRASP-01:git-http:34"), vec![34]); } #[test] fn test_parse_spec_lines_range() { assert_eq!(parse_spec_lines("GRASP-01:nostr-relay:7-9"), vec![7, 8, 9]); assert_eq!( parse_spec_lines("GRASP-01:cors:50-53"), vec![50, 51, 52, 53] ); } #[test] fn test_parse_spec_lines_non_grasp() { assert_eq!(parse_spec_lines("NIP-01:basic:1"), Vec::::new()); assert_eq!(parse_spec_lines("OTHER:spec:5"), Vec::::new()); } #[test] fn test_parse_spec_lines_invalid() { assert_eq!(parse_spec_lines("GRASP-01:invalid"), Vec::::new()); assert_eq!(parse_spec_lines("GRASP-01:test:abc"), Vec::::new()); } }