rpytest_core/inventory/
nodes.rs1use serde::{Deserialize, Serialize};
4
5pub type TestNodeId = String;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[derive(Default)]
17pub enum TestNodeKind {
18 #[default]
20 Function,
21 Method,
23 Class,
25 Module,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TestNode {
32 pub node_id: TestNodeId,
34
35 pub kind: TestNodeKind,
37
38 pub file_path: String,
40
41 pub lineno: Option<u32>,
43
44 pub name: String,
46
47 pub class_name: Option<String>,
49
50 pub markers: Vec<String>,
52
53 pub keywords: Vec<String>,
55
56 pub parameters: Option<String>,
58
59 pub avg_duration_ms: Option<u64>,
61
62 pub run_count: u32,
64
65 pub fail_count: u32,
67
68 pub xfail: bool,
70
71 pub skip: bool,
73}
74
75impl TestNode {
76 pub fn new(node_id: impl Into<String>, file_path: impl Into<String>) -> Self {
78 let node_id = node_id.into();
79 let name = Self::extract_name(&node_id);
80 let class_name = Self::extract_class(&node_id);
81 let kind = if class_name.is_some() {
82 TestNodeKind::Method
83 } else {
84 TestNodeKind::Function
85 };
86
87 Self {
88 node_id,
89 kind,
90 file_path: file_path.into(),
91 lineno: None,
92 name,
93 class_name,
94 markers: Vec::new(),
95 keywords: Vec::new(),
96 parameters: None,
97 avg_duration_ms: None,
98 run_count: 0,
99 fail_count: 0,
100 xfail: false,
101 skip: false,
102 }
103 }
104
105 fn extract_name(node_id: &str) -> String {
107 let base = if let Some(bracket_pos) = node_id.rfind('[') {
109 &node_id[..bracket_pos]
110 } else {
111 node_id
112 };
113
114 base.rsplit("::").next().unwrap_or(node_id).to_string()
116 }
117
118 fn extract_class(node_id: &str) -> Option<String> {
120 let base = if let Some(bracket_pos) = node_id.rfind('[') {
122 &node_id[..bracket_pos]
123 } else {
124 node_id
125 };
126
127 let parts: Vec<&str> = base.split("::").collect();
128 if parts.len() >= 3 {
129 Some(parts[parts.len() - 2].to_string())
131 } else {
132 None
133 }
134 }
135
136 pub fn add_marker(&mut self, marker: impl Into<String>) {
138 let marker = marker.into();
139 if !self.markers.contains(&marker) {
140 match marker.as_str() {
142 "skip" | "skipif" => self.skip = true,
143 "xfail" => self.xfail = true,
144 _ => {}
145 }
146 self.markers.push(marker);
147 }
148 }
149
150 pub fn build_keywords(&mut self) {
152 self.keywords.clear();
153
154 self.keywords.push(self.name.clone());
156
157 if let Some(ref class) = self.class_name {
159 self.keywords.push(class.clone());
160 }
161
162 if let Some(file_stem) = self.file_path.rsplit('/').next() {
164 if let Some(stem) = file_stem.strip_suffix(".py") {
165 self.keywords.push(stem.to_string());
166 }
167 }
168
169 for marker in &self.markers {
171 self.keywords.push(marker.clone());
172 }
173
174 if let Some(ref params) = self.parameters {
176 self.keywords.push(params.clone());
177 }
178 }
179
180 pub fn matches_keyword(&self, expr: &str) -> bool {
188 let expr = expr.trim();
189
190 if let Some(rest) = expr.strip_prefix("not ") {
192 return !self.matches_keyword(rest);
193 }
194
195 if let Some((left, right)) = expr.split_once(" and ") {
197 return self.matches_keyword(left) && self.matches_keyword(right);
198 }
199
200 if let Some((left, right)) = expr.split_once(" or ") {
202 return self.matches_keyword(left) || self.matches_keyword(right);
203 }
204
205 let expr_lower = expr.to_lowercase();
207 self.keywords
208 .iter()
209 .any(|k| k.to_lowercase().contains(&expr_lower))
210 || self.node_id.to_lowercase().contains(&expr_lower)
211 }
212
213 pub fn has_marker(&self, marker: &str) -> bool {
215 self.markers.iter().any(|m| m == marker)
216 }
217
218 pub fn matches_marker(&self, expr: &str) -> bool {
226 let expr = expr.trim();
227
228 if let Some(rest) = expr.strip_prefix("not ") {
230 return !self.matches_marker(rest);
231 }
232
233 if let Some((left, right)) = expr.split_once(" and ") {
235 return self.matches_marker(left) && self.matches_marker(right);
236 }
237
238 if let Some((left, right)) = expr.split_once(" or ") {
240 return self.matches_marker(left) || self.matches_marker(right);
241 }
242
243 self.has_marker(expr)
245 }
246
247 pub fn record_duration(&mut self, duration_ms: u64) {
249 self.run_count += 1;
250 let current_avg = self.avg_duration_ms.unwrap_or(0);
251 self.avg_duration_ms =
253 Some((current_avg * (self.run_count - 1) as u64 + duration_ms) / self.run_count as u64);
254 }
255
256 pub fn record_failure(&mut self) {
258 self.fail_count += 1;
259 }
260
261 pub fn failure_rate(&self) -> f64 {
263 if self.run_count == 0 {
264 0.0
265 } else {
266 (self.fail_count as f64 / self.run_count as f64) * 100.0
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn test_node_creation() {
277 let node = TestNode::new("test_module.py::test_function", "test_module.py");
278
279 assert_eq!(node.name, "test_function");
280 assert_eq!(node.class_name, None);
281 assert_eq!(node.kind, TestNodeKind::Function);
282 }
283
284 #[test]
285 fn test_method_node() {
286 let node = TestNode::new("test_module.py::TestClass::test_method", "test_module.py");
287
288 assert_eq!(node.name, "test_method");
289 assert_eq!(node.class_name, Some("TestClass".to_string()));
290 assert_eq!(node.kind, TestNodeKind::Method);
291 }
292
293 #[test]
294 fn test_parametrized_node() {
295 let node = TestNode::new("test_module.py::test_func[param1-param2]", "test_module.py");
296
297 assert_eq!(node.name, "test_func");
298 assert_eq!(node.class_name, None);
299 }
300
301 #[test]
302 fn test_keyword_matching() {
303 let mut node = TestNode::new(
304 "test_math.py::TestArithmetic::test_addition",
305 "test_math.py",
306 );
307 node.add_marker("slow");
308 node.build_keywords();
309
310 assert!(node.matches_keyword("addition"));
311 assert!(node.matches_keyword("Arithmetic"));
312 assert!(node.matches_keyword("slow"));
313 assert!(node.matches_keyword("test_math"));
314 assert!(!node.matches_keyword("subtraction"));
315
316 assert!(node.matches_keyword("addition and slow"));
318 assert!(node.matches_keyword("addition or subtraction"));
319 assert!(!node.matches_keyword("addition and fast"));
320 assert!(node.matches_keyword("not subtraction"));
321 }
322
323 #[test]
324 fn test_marker_matching() {
325 let mut node = TestNode::new("test.py::test_func", "test.py");
326 node.add_marker("slow");
327 node.add_marker("integration");
328
329 assert!(node.matches_marker("slow"));
330 assert!(node.matches_marker("integration"));
331 assert!(!node.matches_marker("unit"));
332
333 assert!(node.matches_marker("slow and integration"));
334 assert!(node.matches_marker("slow or unit"));
335 assert!(!node.matches_marker("slow and unit"));
336 assert!(node.matches_marker("not unit"));
337 }
338
339 #[test]
340 fn test_duration_tracking() {
341 let mut node = TestNode::new("test.py::test_func", "test.py");
342
343 node.record_duration(100);
344 assert_eq!(node.avg_duration_ms, Some(100));
345
346 node.record_duration(200);
347 assert_eq!(node.avg_duration_ms, Some(150));
348
349 node.record_duration(300);
350 assert_eq!(node.avg_duration_ms, Some(200));
351 }
352
353 #[test]
354 fn test_failure_tracking() {
355 let mut node = TestNode::new("test.py::test_func", "test.py");
356
357 node.record_duration(100);
358 node.record_duration(100);
359 node.record_failure();
360 node.record_duration(100);
361 node.record_failure();
362
363 assert_eq!(node.run_count, 3);
364 assert_eq!(node.fail_count, 2);
365 assert!((node.failure_rate() - 66.67).abs() < 0.1);
366 }
367
368 #[test]
369 fn test_skip_xfail_markers() {
370 let mut node = TestNode::new("test.py::test_func", "test.py");
371
372 assert!(!node.skip);
373 assert!(!node.xfail);
374
375 node.add_marker("skip");
376 assert!(node.skip);
377
378 node.add_marker("xfail");
379 assert!(node.xfail);
380 }
381}