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
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TestNode {
33 pub node_id: TestNodeId,
35
36 pub kind: TestNodeKind,
38
39 pub file_path: String,
41
42 pub lineno: Option<u32>,
44
45 pub name: String,
47
48 pub class_name: Option<String>,
50
51 pub markers: Vec<String>,
53
54 pub keywords: Vec<String>,
56
57 pub parameters: Option<String>,
59
60 pub avg_duration_ms: Option<u64>,
62
63 pub run_count: u32,
65
66 pub fail_count: u32,
68
69 pub xfail: bool,
71
72 pub skip: bool,
74}
75
76impl TestNode {
77 pub fn new(node_id: impl Into<String>, file_path: impl Into<String>) -> Self {
79 let node_id = node_id.into();
80 let name = Self::extract_name(&node_id);
81 let class_name = Self::extract_class(&node_id);
82 let kind = if class_name.is_some() {
83 TestNodeKind::Method
84 } else {
85 TestNodeKind::Function
86 };
87
88 Self {
89 node_id,
90 kind,
91 file_path: file_path.into(),
92 lineno: None,
93 name,
94 class_name,
95 markers: Vec::new(),
96 keywords: Vec::new(),
97 parameters: None,
98 avg_duration_ms: None,
99 run_count: 0,
100 fail_count: 0,
101 xfail: false,
102 skip: false,
103 }
104 }
105
106 fn extract_name(node_id: &str) -> String {
108 let base = if let Some(bracket_pos) = node_id.rfind('[') {
110 &node_id[..bracket_pos]
111 } else {
112 node_id
113 };
114
115 base.rsplit("::").next().unwrap_or(node_id).to_string()
117 }
118
119 fn extract_class(node_id: &str) -> Option<String> {
121 let base = if let Some(bracket_pos) = node_id.rfind('[') {
123 &node_id[..bracket_pos]
124 } else {
125 node_id
126 };
127
128 let parts: Vec<&str> = base.split("::").collect();
129 if parts.len() >= 3 {
130 Some(parts[parts.len() - 2].to_string())
132 } else {
133 None
134 }
135 }
136
137 pub fn add_marker(&mut self, marker: impl Into<String>) {
139 let marker = marker.into();
140 if !self.markers.contains(&marker) {
141 match marker.as_str() {
143 "skip" | "skipif" => self.skip = true,
144 "xfail" => self.xfail = true,
145 _ => {}
146 }
147 self.markers.push(marker);
148 }
149 }
150
151 pub fn build_keywords(&mut self) {
153 self.keywords.clear();
154
155 self.keywords.push(self.name.clone());
157
158 if let Some(ref class) = self.class_name {
160 self.keywords.push(class.clone());
161 }
162
163 if let Some(file_stem) = self.file_path.rsplit('/').next() {
165 if let Some(stem) = file_stem.strip_suffix(".py") {
166 self.keywords.push(stem.to_string());
167 }
168 }
169
170 for marker in &self.markers {
172 self.keywords.push(marker.clone());
173 }
174
175 if let Some(ref params) = self.parameters {
177 self.keywords.push(params.clone());
178 }
179 }
180
181 pub fn matches_keyword(&self, expr: &str) -> bool {
189 let expr = expr.trim();
190
191 if let Some(rest) = expr.strip_prefix("not ") {
193 return !self.matches_keyword(rest);
194 }
195
196 if let Some((left, right)) = expr.split_once(" and ") {
198 return self.matches_keyword(left) && self.matches_keyword(right);
199 }
200
201 if let Some((left, right)) = expr.split_once(" or ") {
203 return self.matches_keyword(left) || self.matches_keyword(right);
204 }
205
206 let expr_lower = expr.to_lowercase();
208 self.keywords
209 .iter()
210 .any(|k| k.to_lowercase().contains(&expr_lower))
211 || self.node_id.to_lowercase().contains(&expr_lower)
212 }
213
214 pub fn has_marker(&self, marker: &str) -> bool {
216 self.markers.iter().any(|m| m == marker)
217 }
218
219 pub fn matches_marker(&self, expr: &str) -> bool {
227 let expr = expr.trim();
228
229 if let Some(rest) = expr.strip_prefix("not ") {
231 return !self.matches_marker(rest);
232 }
233
234 if let Some((left, right)) = expr.split_once(" and ") {
236 return self.matches_marker(left) && self.matches_marker(right);
237 }
238
239 if let Some((left, right)) = expr.split_once(" or ") {
241 return self.matches_marker(left) || self.matches_marker(right);
242 }
243
244 self.has_marker(expr)
246 }
247
248 pub fn record_duration(&mut self, duration_ms: u64) {
250 self.run_count += 1;
251 let current_avg = self.avg_duration_ms.unwrap_or(0);
252 self.avg_duration_ms =
254 Some((current_avg * (self.run_count - 1) as u64 + duration_ms) / self.run_count as u64);
255 }
256
257 pub fn record_failure(&mut self) {
259 self.fail_count += 1;
260 }
261
262 pub fn failure_rate(&self) -> f64 {
264 if self.run_count == 0 {
265 0.0
266 } else {
267 (self.fail_count as f64 / self.run_count as f64) * 100.0
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn test_node_creation() {
278 let node = TestNode::new("test_module.py::test_function", "test_module.py");
279
280 assert_eq!(node.name, "test_function");
281 assert_eq!(node.class_name, None);
282 assert_eq!(node.kind, TestNodeKind::Function);
283 }
284
285 #[test]
286 fn test_method_node() {
287 let node = TestNode::new("test_module.py::TestClass::test_method", "test_module.py");
288
289 assert_eq!(node.name, "test_method");
290 assert_eq!(node.class_name, Some("TestClass".to_string()));
291 assert_eq!(node.kind, TestNodeKind::Method);
292 }
293
294 #[test]
295 fn test_parametrized_node() {
296 let node = TestNode::new("test_module.py::test_func[param1-param2]", "test_module.py");
297
298 assert_eq!(node.name, "test_func");
299 assert_eq!(node.class_name, None);
300 }
301
302 #[test]
303 fn test_keyword_matching() {
304 let mut node = TestNode::new(
305 "test_math.py::TestArithmetic::test_addition",
306 "test_math.py",
307 );
308 node.add_marker("slow");
309 node.build_keywords();
310
311 assert!(node.matches_keyword("addition"));
312 assert!(node.matches_keyword("Arithmetic"));
313 assert!(node.matches_keyword("slow"));
314 assert!(node.matches_keyword("test_math"));
315 assert!(!node.matches_keyword("subtraction"));
316
317 assert!(node.matches_keyword("addition and slow"));
319 assert!(node.matches_keyword("addition or subtraction"));
320 assert!(!node.matches_keyword("addition and fast"));
321 assert!(node.matches_keyword("not subtraction"));
322 }
323
324 #[test]
325 fn test_marker_matching() {
326 let mut node = TestNode::new("test.py::test_func", "test.py");
327 node.add_marker("slow");
328 node.add_marker("integration");
329
330 assert!(node.matches_marker("slow"));
331 assert!(node.matches_marker("integration"));
332 assert!(!node.matches_marker("unit"));
333
334 assert!(node.matches_marker("slow and integration"));
335 assert!(node.matches_marker("slow or unit"));
336 assert!(!node.matches_marker("slow and unit"));
337 assert!(node.matches_marker("not unit"));
338 }
339
340 #[test]
341 fn test_duration_tracking() {
342 let mut node = TestNode::new("test.py::test_func", "test.py");
343
344 node.record_duration(100);
345 assert_eq!(node.avg_duration_ms, Some(100));
346
347 node.record_duration(200);
348 assert_eq!(node.avg_duration_ms, Some(150));
349
350 node.record_duration(300);
351 assert_eq!(node.avg_duration_ms, Some(200));
352 }
353
354 #[test]
355 fn test_failure_tracking() {
356 let mut node = TestNode::new("test.py::test_func", "test.py");
357
358 node.record_duration(100);
359 node.record_duration(100);
360 node.record_failure();
361 node.record_duration(100);
362 node.record_failure();
363
364 assert_eq!(node.run_count, 3);
365 assert_eq!(node.fail_count, 2);
366 assert!((node.failure_rate() - 66.67).abs() < 0.1);
367 }
368
369 #[test]
370 fn test_skip_xfail_markers() {
371 let mut node = TestNode::new("test.py::test_func", "test.py");
372
373 assert!(!node.skip);
374 assert!(!node.xfail);
375
376 node.add_marker("skip");
377 assert!(node.skip);
378
379 node.add_marker("xfail");
380 assert!(node.xfail);
381 }
382}