use serde::{Deserialize, Serialize};
pub type TestNodeId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TestNodeKind {
#[default]
Function,
Method,
Class,
Module,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestNode {
pub node_id: TestNodeId,
pub kind: TestNodeKind,
pub file_path: String,
pub lineno: Option<u32>,
pub name: String,
pub class_name: Option<String>,
pub markers: Vec<String>,
pub keywords: Vec<String>,
pub parameters: Option<String>,
pub avg_duration_ms: Option<u64>,
pub run_count: u32,
pub fail_count: u32,
pub xfail: bool,
pub skip: bool,
}
impl TestNode {
pub fn new(node_id: impl Into<String>, file_path: impl Into<String>) -> Self {
let node_id = node_id.into();
let name = Self::extract_name(&node_id);
let class_name = Self::extract_class(&node_id);
let kind = if class_name.is_some() {
TestNodeKind::Method
} else {
TestNodeKind::Function
};
Self {
node_id,
kind,
file_path: file_path.into(),
lineno: None,
name,
class_name,
markers: Vec::new(),
keywords: Vec::new(),
parameters: None,
avg_duration_ms: None,
run_count: 0,
fail_count: 0,
xfail: false,
skip: false,
}
}
fn extract_name(node_id: &str) -> String {
let base = if let Some(bracket_pos) = node_id.rfind('[') {
&node_id[..bracket_pos]
} else {
node_id
};
base.rsplit("::").next().unwrap_or(node_id).to_string()
}
fn extract_class(node_id: &str) -> Option<String> {
let base = if let Some(bracket_pos) = node_id.rfind('[') {
&node_id[..bracket_pos]
} else {
node_id
};
let parts: Vec<&str> = base.split("::").collect();
if parts.len() >= 3 {
Some(parts[parts.len() - 2].to_string())
} else {
None
}
}
pub fn add_marker(&mut self, marker: impl Into<String>) {
let marker = marker.into();
if !self.markers.contains(&marker) {
match marker.as_str() {
"skip" | "skipif" => self.skip = true,
"xfail" => self.xfail = true,
_ => {}
}
self.markers.push(marker);
}
}
pub fn build_keywords(&mut self) {
self.keywords.clear();
self.keywords.push(self.name.clone());
if let Some(ref class) = self.class_name {
self.keywords.push(class.clone());
}
if let Some(file_stem) = self.file_path.rsplit('/').next() {
if let Some(stem) = file_stem.strip_suffix(".py") {
self.keywords.push(stem.to_string());
}
}
for marker in &self.markers {
self.keywords.push(marker.clone());
}
if let Some(ref params) = self.parameters {
self.keywords.push(params.clone());
}
}
pub fn matches_keyword(&self, expr: &str) -> bool {
let expr = expr.trim();
if let Some(rest) = expr.strip_prefix("not ") {
return !self.matches_keyword(rest);
}
if let Some((left, right)) = expr.split_once(" and ") {
return self.matches_keyword(left) && self.matches_keyword(right);
}
if let Some((left, right)) = expr.split_once(" or ") {
return self.matches_keyword(left) || self.matches_keyword(right);
}
let expr_lower = expr.to_lowercase();
self.keywords
.iter()
.any(|k| k.to_lowercase().contains(&expr_lower))
|| self.node_id.to_lowercase().contains(&expr_lower)
}
pub fn has_marker(&self, marker: &str) -> bool {
self.markers.iter().any(|m| m == marker)
}
pub fn matches_marker(&self, expr: &str) -> bool {
let expr = expr.trim();
if let Some(rest) = expr.strip_prefix("not ") {
return !self.matches_marker(rest);
}
if let Some((left, right)) = expr.split_once(" and ") {
return self.matches_marker(left) && self.matches_marker(right);
}
if let Some((left, right)) = expr.split_once(" or ") {
return self.matches_marker(left) || self.matches_marker(right);
}
self.has_marker(expr)
}
pub fn record_duration(&mut self, duration_ms: u64) {
self.run_count += 1;
let current_avg = self.avg_duration_ms.unwrap_or(0);
self.avg_duration_ms =
Some((current_avg * (self.run_count - 1) as u64 + duration_ms) / self.run_count as u64);
}
pub fn record_failure(&mut self) {
self.fail_count += 1;
}
pub fn failure_rate(&self) -> f64 {
if self.run_count == 0 {
0.0
} else {
(self.fail_count as f64 / self.run_count as f64) * 100.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_node_creation() {
let node = TestNode::new("test_module.py::test_function", "test_module.py");
assert_eq!(node.name, "test_function");
assert_eq!(node.class_name, None);
assert_eq!(node.kind, TestNodeKind::Function);
}
#[test]
fn test_method_node() {
let node = TestNode::new("test_module.py::TestClass::test_method", "test_module.py");
assert_eq!(node.name, "test_method");
assert_eq!(node.class_name, Some("TestClass".to_string()));
assert_eq!(node.kind, TestNodeKind::Method);
}
#[test]
fn test_parametrized_node() {
let node = TestNode::new("test_module.py::test_func[param1-param2]", "test_module.py");
assert_eq!(node.name, "test_func");
assert_eq!(node.class_name, None);
}
#[test]
fn test_keyword_matching() {
let mut node = TestNode::new(
"test_math.py::TestArithmetic::test_addition",
"test_math.py",
);
node.add_marker("slow");
node.build_keywords();
assert!(node.matches_keyword("addition"));
assert!(node.matches_keyword("Arithmetic"));
assert!(node.matches_keyword("slow"));
assert!(node.matches_keyword("test_math"));
assert!(!node.matches_keyword("subtraction"));
assert!(node.matches_keyword("addition and slow"));
assert!(node.matches_keyword("addition or subtraction"));
assert!(!node.matches_keyword("addition and fast"));
assert!(node.matches_keyword("not subtraction"));
}
#[test]
fn test_marker_matching() {
let mut node = TestNode::new("test.py::test_func", "test.py");
node.add_marker("slow");
node.add_marker("integration");
assert!(node.matches_marker("slow"));
assert!(node.matches_marker("integration"));
assert!(!node.matches_marker("unit"));
assert!(node.matches_marker("slow and integration"));
assert!(node.matches_marker("slow or unit"));
assert!(!node.matches_marker("slow and unit"));
assert!(node.matches_marker("not unit"));
}
#[test]
fn test_duration_tracking() {
let mut node = TestNode::new("test.py::test_func", "test.py");
node.record_duration(100);
assert_eq!(node.avg_duration_ms, Some(100));
node.record_duration(200);
assert_eq!(node.avg_duration_ms, Some(150));
node.record_duration(300);
assert_eq!(node.avg_duration_ms, Some(200));
}
#[test]
fn test_failure_tracking() {
let mut node = TestNode::new("test.py::test_func", "test.py");
node.record_duration(100);
node.record_duration(100);
node.record_failure();
node.record_duration(100);
node.record_failure();
assert_eq!(node.run_count, 3);
assert_eq!(node.fail_count, 2);
assert!((node.failure_rate() - 66.67).abs() < 0.1);
}
#[test]
fn test_skip_xfail_markers() {
let mut node = TestNode::new("test.py::test_func", "test.py");
assert!(!node.skip);
assert!(!node.xfail);
node.add_marker("skip");
assert!(node.skip);
node.add_marker("xfail");
assert!(node.xfail);
}
}