Skip to main content

exspec_core/
rules.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8pub enum Severity {
9    Info,
10    Warn,
11    Block,
12}
13
14impl Severity {
15    pub fn as_str(&self) -> &'static str {
16        match self {
17            Severity::Block => "BLOCK",
18            Severity::Warn => "WARN",
19            Severity::Info => "INFO",
20        }
21    }
22
23    pub fn exit_code(&self) -> i32 {
24        match self {
25            Severity::Block => 1,
26            Severity::Warn => 0,
27            Severity::Info => 0,
28        }
29    }
30}
31
32impl fmt::Display for Severity {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(f, "{}", self.as_str())
35    }
36}
37
38impl FromStr for Severity {
39    type Err = String;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s.to_ascii_uppercase().as_str() {
43            "BLOCK" => Ok(Severity::Block),
44            "WARN" => Ok(Severity::Warn),
45            "INFO" => Ok(Severity::Info),
46            _ => Err(format!("unknown severity: {s}")),
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct RuleId(pub String);
53
54impl RuleId {
55    pub fn new(id: &str) -> Self {
56        Self(id.to_string())
57    }
58}
59
60impl fmt::Display for RuleId {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(f, "{}", self.0)
63    }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Diagnostic {
68    pub rule: RuleId,
69    pub severity: Severity,
70    pub file: String,
71    pub line: Option<usize>,
72    pub message: String,
73    pub details: Option<String>,
74}
75
76/// Canonical list of all known rule IDs.
77/// Single source of truth — used for config validation and SARIF output.
78pub const KNOWN_RULE_IDS: &[&str] = &[
79    "T001", "T002", "T003", "T004", "T005", "T006", "T007", "T008", "T101", "T102", "T103", "T105",
80    "T106", "T107", "T108", "T109", "T110",
81];
82
83pub struct Config {
84    pub mock_max: usize,
85    pub mock_class_max: usize,
86    pub test_max_lines: usize,
87    pub parameterized_min_ratio: f64,
88    pub fixture_max: usize,
89    pub min_assertions_for_t105: usize,
90    pub min_duplicate_count: usize,
91    pub disabled_rules: Vec<RuleId>,
92    pub custom_assertion_patterns: Vec<String>,
93    pub ignore_patterns: Vec<String>,
94    pub min_severity: Severity,
95    pub severity_overrides: HashMap<String, Severity>,
96    pub max_fan_out_percent: f64,
97    pub max_reverse_fan_out: usize,
98}
99
100impl Default for Config {
101    fn default() -> Self {
102        Self {
103            mock_max: 5,
104            mock_class_max: 3,
105            test_max_lines: 50,
106            parameterized_min_ratio: 0.1,
107            fixture_max: 5,
108            min_assertions_for_t105: 5,
109            min_duplicate_count: 3,
110            disabled_rules: vec![RuleId::new("T106")], // 93% FP rate (Phase 8a-3)
111            custom_assertion_patterns: Vec::new(),
112            ignore_patterns: Vec::new(),
113            min_severity: Severity::Info,
114            severity_overrides: HashMap::new(),
115            max_fan_out_percent: 6.5,
116            max_reverse_fan_out: 5,
117        }
118    }
119}
120
121use crate::extractor::TestFunction;
122
123pub fn evaluate_rules(functions: &[TestFunction], config: &Config) -> Vec<Diagnostic> {
124    let mut diagnostics = Vec::new();
125
126    for func in functions {
127        let analysis = &func.analysis;
128
129        // T001: assertion-free
130        if !is_disabled(config, "T001")
131            && !is_suppressed(analysis, "T001")
132            && analysis.assertion_count == 0
133            && !analysis.has_skip_call
134        {
135            diagnostics.push(Diagnostic {
136                rule: RuleId::new("T001"),
137                severity: effective_severity(config, "T001", Severity::Block),
138                file: func.file.clone(),
139                line: Some(func.line),
140                message: format!("assertion-free: {} has no assertions", func.name),
141                details: None,
142            });
143        }
144
145        // T002: mock-overuse
146        if !is_disabled(config, "T002")
147            && !is_suppressed(analysis, "T002")
148            && (analysis.mock_count > config.mock_max
149                || analysis.mock_classes.len() > config.mock_class_max)
150        {
151            diagnostics.push(Diagnostic {
152                rule: RuleId::new("T002"),
153                severity: effective_severity(config, "T002", Severity::Warn),
154                file: func.file.clone(),
155                line: Some(func.line),
156                message: format!(
157                    "mock-overuse: {} mocks ({} classes), threshold: {} mocks / {} classes",
158                    analysis.mock_count,
159                    analysis.mock_classes.len(),
160                    config.mock_max,
161                    config.mock_class_max,
162                ),
163                details: None,
164            });
165        }
166
167        // T003: giant-test
168        if !is_disabled(config, "T003")
169            && !is_suppressed(analysis, "T003")
170            && analysis.line_count > config.test_max_lines
171        {
172            diagnostics.push(Diagnostic {
173                rule: RuleId::new("T003"),
174                severity: effective_severity(config, "T003", Severity::Warn),
175                file: func.file.clone(),
176                line: Some(func.line),
177                message: format!(
178                    "giant-test: {} lines, threshold: {}",
179                    analysis.line_count, config.test_max_lines,
180                ),
181                details: None,
182            });
183        }
184
185        // T102: fixture-sprawl
186        if !is_disabled(config, "T102")
187            && !is_suppressed(analysis, "T102")
188            && analysis.fixture_count > config.fixture_max
189        {
190            diagnostics.push(Diagnostic {
191                rule: RuleId::new("T102"),
192                severity: effective_severity(config, "T102", Severity::Info),
193                file: func.file.clone(),
194                line: Some(func.line),
195                message: format!(
196                    "fixture-sprawl: {} fixtures, threshold: {}",
197                    analysis.fixture_count, config.fixture_max,
198                ),
199                details: None,
200            });
201        }
202
203        // T108: wait-and-see
204        if !is_disabled(config, "T108") && !is_suppressed(analysis, "T108") && analysis.has_wait {
205            diagnostics.push(Diagnostic {
206                rule: RuleId::new("T108"),
207                severity: effective_severity(config, "T108", Severity::Info),
208                file: func.file.clone(),
209                line: Some(func.line),
210                message: "wait-and-see: test uses sleep/delay (causes flaky tests, consider async/mock alternatives)".to_string(),
211                details: None,
212            });
213        }
214
215        // T110: skip-only-test
216        if !is_disabled(config, "T110")
217            && !is_suppressed(analysis, "T110")
218            && analysis.has_skip_call
219            && analysis.assertion_count == 0
220        {
221            diagnostics.push(Diagnostic {
222                rule: RuleId::new("T110"),
223                severity: effective_severity(config, "T110", Severity::Info),
224                file: func.file.clone(),
225                line: Some(func.line),
226                message: format!(
227                    "skip-only-test: {} contains skip/incomplete without assertions",
228                    func.name
229                ),
230                details: None,
231            });
232        }
233
234        // T106: duplicate-literal-assertion
235        if !is_disabled(config, "T106")
236            && !is_suppressed(analysis, "T106")
237            && analysis.duplicate_literal_count >= config.min_duplicate_count
238        {
239            diagnostics.push(Diagnostic {
240                rule: RuleId::new("T106"),
241                severity: effective_severity(config, "T106", Severity::Info),
242                file: func.file.clone(),
243                line: Some(func.line),
244                message: format!(
245                    "duplicate-literal-assertion: literal appears {} times in assertions (consider extracting to constant or parameter)",
246                    analysis.duplicate_literal_count,
247                ),
248                details: None,
249            });
250        }
251
252        // T107: assertion-roulette
253        if !is_disabled(config, "T107")
254            && !is_suppressed(analysis, "T107")
255            && analysis.assertion_count >= 2
256            && analysis.assertion_message_count == 0
257        {
258            diagnostics.push(Diagnostic {
259                rule: RuleId::new("T107"),
260                severity: effective_severity(config, "T107", Severity::Info),
261                file: func.file.clone(),
262                line: Some(func.line),
263                message: format!(
264                    "assertion-roulette: {} assertions without messages (add failure messages for readability)",
265                    analysis.assertion_count,
266                ),
267                details: None,
268            });
269        }
270
271        // T109: undescriptive-test-name
272        if !is_disabled(config, "T109")
273            && !is_suppressed(analysis, "T109")
274            && is_undescriptive_test_name(&func.name)
275        {
276            diagnostics.push(Diagnostic {
277                rule: RuleId::new("T109"),
278                severity: effective_severity(config, "T109", Severity::Info),
279                file: func.file.clone(),
280                line: Some(func.line),
281                message: format!(
282                    "undescriptive-test-name: \"{}\" does not describe behavior (use descriptive names like \"test_user_creation_returns_valid_id\")",
283                    func.name,
284                ),
285                details: None,
286            });
287        }
288
289        // T101: how-not-what
290        if !is_disabled(config, "T101")
291            && !is_suppressed(analysis, "T101")
292            && analysis.how_not_what_count > 0
293        {
294            diagnostics.push(Diagnostic {
295                rule: RuleId::new("T101"),
296                severity: effective_severity(config, "T101", Severity::Info),
297                file: func.file.clone(),
298                line: Some(func.line),
299                message: format!(
300                    "how-not-what: {} implementation-testing pattern(s) detected",
301                    analysis.how_not_what_count,
302                ),
303                details: None,
304            });
305        }
306    }
307
308    diagnostics
309}
310
311use crate::extractor::FileAnalysis;
312
313pub fn evaluate_file_rules(analyses: &[FileAnalysis], config: &Config) -> Vec<Diagnostic> {
314    let mut diagnostics = Vec::new();
315
316    for analysis in analyses {
317        if analysis.functions.is_empty() {
318            continue;
319        }
320
321        // T006: low-assertion-density
322        // Total assertions / total functions < 1.0 → WARN
323        // Skip if ALL functions are assertion-free (T001 handles those entirely)
324        if !is_disabled(config, "T006") {
325            let has_any_asserting = analysis
326                .functions
327                .iter()
328                .any(|f| f.analysis.assertion_count > 0);
329
330            if has_any_asserting {
331                let total_assertions: usize = analysis
332                    .functions
333                    .iter()
334                    .map(|f| f.analysis.assertion_count)
335                    .sum();
336                let density = total_assertions as f64 / analysis.functions.len() as f64;
337
338                if density < 1.0 {
339                    diagnostics.push(Diagnostic {
340                        rule: RuleId::new("T006"),
341                        severity: effective_severity(config, "T006", Severity::Warn),
342                        file: analysis.file.clone(),
343                        line: None,
344                        message: format!(
345                            "low-assertion-density: {density:.2} assertions/test (threshold: 1.0)",
346                        ),
347                        details: None,
348                    });
349                }
350            }
351        }
352
353        // T004: no-parameterized
354        if !is_disabled(config, "T004") {
355            let total = analysis.functions.len();
356            let ratio = analysis.parameterized_count as f64 / total as f64;
357            if ratio < config.parameterized_min_ratio {
358                diagnostics.push(Diagnostic {
359                    rule: RuleId::new("T004"),
360                    severity: effective_severity(config, "T004", Severity::Info),
361                    file: analysis.file.clone(),
362                    line: None,
363                    message: format!(
364                        "no-parameterized: {}/{} ({:.0}%) parameterized, threshold: {:.0}%",
365                        analysis.parameterized_count,
366                        total,
367                        ratio * 100.0,
368                        config.parameterized_min_ratio * 100.0,
369                    ),
370                    details: None,
371                });
372            }
373        }
374
375        // T005: pbt-missing
376        if !is_disabled(config, "T005") && !analysis.has_pbt_import {
377            diagnostics.push(Diagnostic {
378                rule: RuleId::new("T005"),
379                severity: effective_severity(config, "T005", Severity::Info),
380                file: analysis.file.clone(),
381                line: None,
382                message: "pbt-missing: no property-based testing library imported".to_string(),
383                details: None,
384            });
385        }
386
387        // T008: no-contract
388        if !is_disabled(config, "T008") && !analysis.has_contract_import {
389            diagnostics.push(Diagnostic {
390                rule: RuleId::new("T008"),
391                severity: effective_severity(config, "T008", Severity::Info),
392                file: analysis.file.clone(),
393                line: None,
394                message: "no-contract: no contract/schema library imported".to_string(),
395                details: None,
396            });
397        }
398
399        // T105: deterministic-no-metamorphic
400        if !is_disabled(config, "T105") {
401            let total_assertions: usize = analysis
402                .functions
403                .iter()
404                .map(|f| f.analysis.assertion_count)
405                .sum();
406            if total_assertions >= config.min_assertions_for_t105
407                && !analysis.has_relational_assertion
408            {
409                diagnostics.push(Diagnostic {
410                    rule: RuleId::new("T105"),
411                    severity: effective_severity(config, "T105", Severity::Info),
412                    file: analysis.file.clone(),
413                    line: None,
414                    message: format!(
415                        "deterministic-no-metamorphic: {total_assertions} assertions, all exact equality",
416                    ),
417                    details: None,
418                });
419            }
420        }
421
422        // T103: missing-error-test
423        if !is_disabled(config, "T103") && !analysis.has_error_test {
424            diagnostics.push(Diagnostic {
425                rule: RuleId::new("T103"),
426                severity: effective_severity(config, "T103", Severity::Info),
427                file: analysis.file.clone(),
428                line: None,
429                message: "missing-error-test: no error/exception test found in file".to_string(),
430                details: None,
431            });
432        }
433    }
434
435    diagnostics
436}
437
438pub fn evaluate_project_rules(
439    test_file_count: usize,
440    source_file_count: usize,
441    config: &Config,
442) -> Vec<Diagnostic> {
443    let mut diagnostics = Vec::new();
444
445    // T007: test-source-ratio
446    if !is_disabled(config, "T007") && source_file_count > 0 {
447        let ratio = test_file_count as f64 / source_file_count as f64;
448        diagnostics.push(Diagnostic {
449            rule: RuleId::new("T007"),
450            severity: effective_severity(config, "T007", Severity::Info),
451            file: "<project>".to_string(),
452            line: None,
453            message: format!(
454                "test-source-ratio: {test_file_count}/{source_file_count} ({ratio:.2})",
455            ),
456            details: None,
457        });
458    }
459
460    diagnostics
461}
462
463/// Blacklist of generic test name suffixes (after stripping test_ prefix).
464const GENERIC_TEST_NAMES: &[&str] = &[
465    "case", "example", "sample", "basic", "data", "check", "func", "method",
466];
467
468/// Check if a test name is undescriptive.
469///
470/// Violation patterns:
471/// - `test_` + digits only: `test_1`, `test_123`
472/// - `test` + digits only (camelCase): `test1`, `testCase1`
473/// - `test_` + single short word (4 chars or less): `test_it`, `test_foo`
474/// - Generic blacklist: `test_case`, `test_example`, etc.
475/// - Short string names (TypeScript): `"test 1"`, `"works"`, `"it"`
476pub fn is_undescriptive_test_name(name: &str) -> bool {
477    // Handle TypeScript string-style test names (may contain spaces)
478    let trimmed = name.trim_matches(|c: char| c == '"' || c == '\'');
479    let normalized = if trimmed != name {
480        // String-style name: normalize spaces to underscores for uniform checks
481        trimmed.to_lowercase().replace(' ', "_")
482    } else {
483        name.to_lowercase()
484    };
485
486    // Strip test_ or test prefix
487    let suffix = if let Some(s) = normalized.strip_prefix("test_") {
488        s
489    } else if let Some(s) = normalized.strip_prefix("test") {
490        if s.is_empty() {
491            return true; // just "test"
492        }
493        s
494    } else {
495        // No test prefix - check if the whole name is very short/generic
496        // e.g. TypeScript `it('works', ...)` -> name="works"
497        // Non-ASCII names (CJK, Arabic, Devanagari, etc.) skip single-word heuristic:
498        // CJK languages don't use spaces/underscores between words.
499        let has_non_ascii = !normalized.is_ascii();
500        let is_single_word =
501            !has_non_ascii && !normalized.contains('_') && !normalized.contains(' ');
502        return is_single_word || GENERIC_TEST_NAMES.contains(&normalized.as_str());
503    };
504
505    // Digits only after prefix
506    if suffix.chars().all(|c| c.is_ascii_digit() || c == '_')
507        && suffix.chars().any(|c| c.is_ascii_digit())
508    {
509        return true;
510    }
511
512    // Single short word (4 chars or less, no underscores = single word)
513    // Non-ASCII suffixes skip this check (CJK chars are multi-byte, and even
514    // a single CJK character carries semantic meaning).
515    let has_non_ascii_suffix = !suffix.is_ascii();
516    if !has_non_ascii_suffix && !suffix.contains('_') && suffix.chars().count() <= 4 {
517        return true;
518    }
519
520    // Generic blacklist
521    GENERIC_TEST_NAMES.contains(&suffix)
522}
523
524fn effective_severity(config: &Config, rule_id: &str, default: Severity) -> Severity {
525    config
526        .severity_overrides
527        .get(rule_id)
528        .copied()
529        .unwrap_or(default)
530}
531
532fn is_disabled(config: &Config, rule_id: &str) -> bool {
533    config.disabled_rules.iter().any(|r| r.0 == rule_id)
534}
535
536fn is_suppressed(analysis: &crate::extractor::TestAnalysis, rule_id: &str) -> bool {
537    analysis.suppressed_rules.iter().any(|r| r.0 == rule_id)
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::extractor::{TestAnalysis, TestFunction};
544
545    fn make_func(name: &str, analysis: TestAnalysis) -> TestFunction {
546        TestFunction {
547            name: name.to_string(),
548            file: "test.py".to_string(),
549            line: 1,
550            end_line: 10,
551            analysis,
552        }
553    }
554
555    // --- Severity tests (from Phase 1) ---
556
557    #[test]
558    fn severity_ordering() {
559        assert!(Severity::Block > Severity::Warn);
560        assert!(Severity::Warn > Severity::Info);
561    }
562
563    #[test]
564    fn severity_as_str_roundtrip() {
565        for severity in [Severity::Block, Severity::Warn, Severity::Info] {
566            let s = severity.as_str();
567            let parsed = Severity::from_str(s).unwrap();
568            assert_eq!(parsed, severity);
569        }
570    }
571
572    #[test]
573    fn severity_to_exit_code() {
574        assert_eq!(Severity::Block.exit_code(), 1);
575        assert_eq!(Severity::Warn.exit_code(), 0);
576        assert_eq!(Severity::Info.exit_code(), 0);
577    }
578
579    #[test]
580    fn severity_from_str_invalid() {
581        assert!(Severity::from_str("UNKNOWN").is_err());
582    }
583
584    // --- #59: case-insensitive FromStr ---
585
586    #[test]
587    fn severity_from_str_lowercase_block() {
588        assert_eq!(Severity::from_str("block").unwrap(), Severity::Block);
589    }
590
591    #[test]
592    fn severity_from_str_mixed_case_block() {
593        assert_eq!(Severity::from_str("Block").unwrap(), Severity::Block);
594    }
595
596    #[test]
597    fn severity_from_str_lowercase_warn() {
598        assert_eq!(Severity::from_str("warn").unwrap(), Severity::Warn);
599    }
600
601    #[test]
602    fn severity_from_str_lowercase_info() {
603        assert_eq!(Severity::from_str("info").unwrap(), Severity::Info);
604    }
605
606    #[test]
607    fn severity_from_str_invalid_word() {
608        assert!(Severity::from_str("invalid").is_err());
609    }
610
611    #[test]
612    fn rule_id_display() {
613        let id = RuleId::new("T001");
614        assert_eq!(id.to_string(), "T001");
615    }
616
617    // --- T001: assertion-free ---
618
619    #[test]
620    fn t001_assertion_count_zero_produces_block() {
621        let funcs = vec![make_func(
622            "test_no_assert",
623            TestAnalysis {
624                assertion_count: 0,
625                ..Default::default()
626            },
627        )];
628        let diags = evaluate_rules(&funcs, &Config::default());
629        assert_eq!(diags.len(), 1);
630        assert_eq!(diags[0].rule, RuleId::new("T001"));
631        assert_eq!(diags[0].severity, Severity::Block);
632    }
633
634    #[test]
635    fn t001_assertion_count_positive_no_diagnostic() {
636        let funcs = vec![make_func(
637            "test_with_assert",
638            TestAnalysis {
639                assertion_count: 1,
640                ..Default::default()
641            },
642        )];
643        let diags = evaluate_rules(&funcs, &Config::default());
644        assert!(diags.is_empty());
645    }
646
647    #[test]
648    fn t001_has_skip_call_suppresses_t001() {
649        let funcs = vec![make_func(
650            "test_skipped",
651            TestAnalysis {
652                assertion_count: 0,
653                has_skip_call: true,
654                ..Default::default()
655            },
656        )];
657        let diags = evaluate_rules(&funcs, &Config::default());
658        let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
659        assert!(
660            t001_diags.is_empty(),
661            "T001 should not fire when has_skip_call=true"
662        );
663    }
664
665    #[test]
666    fn t110_skip_only_produces_info() {
667        let funcs = vec![make_func(
668            "test_skipped",
669            TestAnalysis {
670                assertion_count: 0,
671                has_skip_call: true,
672                ..Default::default()
673            },
674        )];
675        let diags = evaluate_rules(&funcs, &Config::default());
676        let t110: Vec<_> = diags
677            .iter()
678            .filter(|d| d.rule == RuleId::new("T110"))
679            .collect();
680        assert_eq!(t110.len(), 1);
681        assert_eq!(t110[0].severity, Severity::Info);
682    }
683
684    #[test]
685    fn t110_skip_with_assertion_no_diagnostic() {
686        let funcs = vec![make_func(
687            "test_skipped_after_assert",
688            TestAnalysis {
689                assertion_count: 1,
690                has_skip_call: true,
691                ..Default::default()
692            },
693        )];
694        let diags = evaluate_rules(&funcs, &Config::default());
695        assert!(!diags.iter().any(|d| d.rule == RuleId::new("T110")));
696    }
697
698    #[test]
699    fn t110_assertion_free_non_skip_keeps_t001_exclusive() {
700        let funcs = vec![make_func(
701            "test_no_assert",
702            TestAnalysis {
703                assertion_count: 0,
704                has_skip_call: false,
705                ..Default::default()
706            },
707        )];
708        let diags = evaluate_rules(&funcs, &Config::default());
709        assert!(diags.iter().any(|d| d.rule == RuleId::new("T001")));
710        assert!(!diags.iter().any(|d| d.rule == RuleId::new("T110")));
711    }
712
713    #[test]
714    fn t110_disabled_no_diagnostic() {
715        let funcs = vec![make_func(
716            "test_skipped",
717            TestAnalysis {
718                assertion_count: 0,
719                has_skip_call: true,
720                ..Default::default()
721            },
722        )];
723        let config = Config {
724            disabled_rules: vec![RuleId::new("T110")],
725            ..Config::default()
726        };
727        let diags = evaluate_rules(&funcs, &config);
728        assert!(!diags.iter().any(|d| d.rule == RuleId::new("T110")));
729    }
730
731    #[test]
732    fn t110_suppressed_no_diagnostic() {
733        let funcs = vec![make_func(
734            "test_skipped",
735            TestAnalysis {
736                assertion_count: 0,
737                has_skip_call: true,
738                suppressed_rules: vec![RuleId::new("T110")],
739                ..Default::default()
740            },
741        )];
742        let diags = evaluate_rules(&funcs, &Config::default());
743        assert!(!diags.iter().any(|d| d.rule == RuleId::new("T110")));
744    }
745
746    #[test]
747    fn t110_skip_only_participates_in_t006_density() {
748        let funcs = vec![
749            make_func(
750                "test_skipped",
751                TestAnalysis {
752                    assertion_count: 0,
753                    has_skip_call: true,
754                    ..Default::default()
755                },
756            ),
757            make_func(
758                "test_with_one_assert",
759                TestAnalysis {
760                    assertion_count: 1,
761                    ..Default::default()
762                },
763            ),
764        ];
765        let function_diags = evaluate_rules(&funcs, &Config::default());
766        let file_diags = evaluate_file_rules(
767            &[make_file_analysis("test.py", funcs, false, false, 0)],
768            &Config::default(),
769        );
770        assert!(function_diags.iter().any(|d| d.rule == RuleId::new("T110")));
771        assert!(file_diags.iter().any(|d| d.rule == RuleId::new("T006")));
772    }
773
774    // --- T002: mock-overuse ---
775
776    #[test]
777    fn t002_mock_count_exceeds_threshold_produces_warn() {
778        let funcs = vec![make_func(
779            "test_many_mocks",
780            TestAnalysis {
781                assertion_count: 1,
782                mock_count: 6,
783                mock_classes: vec![
784                    "a".into(),
785                    "b".into(),
786                    "c".into(),
787                    "d".into(),
788                    "e".into(),
789                    "f".into(),
790                ],
791                ..Default::default()
792            },
793        )];
794        let diags = evaluate_rules(&funcs, &Config::default());
795        assert_eq!(diags.len(), 1);
796        assert_eq!(diags[0].rule, RuleId::new("T002"));
797        assert_eq!(diags[0].severity, Severity::Warn);
798    }
799
800    #[test]
801    fn t002_mock_count_within_threshold_no_diagnostic() {
802        let funcs = vec![make_func(
803            "test_few_mocks",
804            TestAnalysis {
805                assertion_count: 1,
806                mock_count: 2,
807                mock_classes: vec!["db".into()],
808                ..Default::default()
809            },
810        )];
811        let diags = evaluate_rules(&funcs, &Config::default());
812        assert!(diags.is_empty());
813    }
814
815    #[test]
816    fn t002_mock_class_count_exceeds_threshold_alone_produces_warn() {
817        let funcs = vec![make_func(
818            "test_many_classes",
819            TestAnalysis {
820                assertion_count: 1,
821                mock_count: 4, // within mock_max=5
822                mock_classes: vec!["a".into(), "b".into(), "c".into(), "d".into()], // > mock_class_max=3
823                ..Default::default()
824            },
825        )];
826        let diags = evaluate_rules(&funcs, &Config::default());
827        assert_eq!(diags.len(), 1);
828        assert_eq!(diags[0].rule, RuleId::new("T002"));
829    }
830
831    // --- T003: giant-test ---
832
833    #[test]
834    fn t003_line_count_exceeds_threshold_produces_warn() {
835        let funcs = vec![make_func(
836            "test_giant",
837            TestAnalysis {
838                assertion_count: 1,
839                line_count: 73,
840                ..Default::default()
841            },
842        )];
843        let diags = evaluate_rules(&funcs, &Config::default());
844        assert_eq!(diags.len(), 1);
845        assert_eq!(diags[0].rule, RuleId::new("T003"));
846        assert_eq!(diags[0].severity, Severity::Warn);
847    }
848
849    #[test]
850    fn t003_line_count_at_threshold_no_diagnostic() {
851        let funcs = vec![make_func(
852            "test_boundary",
853            TestAnalysis {
854                assertion_count: 1,
855                line_count: 50, // exactly at threshold, strict >
856                ..Default::default()
857            },
858        )];
859        let diags = evaluate_rules(&funcs, &Config::default());
860        assert!(diags.is_empty());
861    }
862
863    // --- Config disabled ---
864
865    #[test]
866    fn disabled_rule_not_reported() {
867        let funcs = vec![make_func(
868            "test_no_assert",
869            TestAnalysis {
870                assertion_count: 0,
871                ..Default::default()
872            },
873        )];
874        let config = Config {
875            disabled_rules: vec![RuleId::new("T001")],
876            ..Config::default()
877        };
878        let diags = evaluate_rules(&funcs, &config);
879        assert!(diags.is_empty());
880    }
881
882    // --- Suppression ---
883
884    #[test]
885    fn suppressed_rule_not_reported() {
886        let funcs = vec![make_func(
887            "test_many_mocks",
888            TestAnalysis {
889                assertion_count: 1,
890                mock_count: 6,
891                mock_classes: vec![
892                    "a".into(),
893                    "b".into(),
894                    "c".into(),
895                    "d".into(),
896                    "e".into(),
897                    "f".into(),
898                ],
899                suppressed_rules: vec![RuleId::new("T002")],
900                ..Default::default()
901            },
902        )];
903        let diags = evaluate_rules(&funcs, &Config::default());
904        assert!(diags.is_empty());
905    }
906
907    // --- T101: how-not-what ---
908
909    #[test]
910    fn t101_how_not_what_produces_info() {
911        let funcs = vec![make_func(
912            "test_calls_repo",
913            TestAnalysis {
914                assertion_count: 1,
915                how_not_what_count: 2,
916                ..Default::default()
917            },
918        )];
919        let diags = evaluate_rules(&funcs, &Config::default());
920        assert_eq!(diags.len(), 1);
921        assert_eq!(diags[0].rule, RuleId::new("T101"));
922        assert_eq!(diags[0].severity, Severity::Info);
923        assert!(diags[0]
924            .message
925            .contains("2 implementation-testing pattern(s)"));
926    }
927
928    #[test]
929    fn t101_zero_how_not_what_no_diagnostic() {
930        let funcs = vec![make_func(
931            "test_behavior",
932            TestAnalysis {
933                assertion_count: 1,
934                how_not_what_count: 0,
935                ..Default::default()
936            },
937        )];
938        let diags = evaluate_rules(&funcs, &Config::default());
939        assert!(diags.is_empty());
940    }
941
942    #[test]
943    fn t101_disabled_no_diagnostic() {
944        let funcs = vec![make_func(
945            "test_calls_repo",
946            TestAnalysis {
947                assertion_count: 1,
948                how_not_what_count: 2,
949                ..Default::default()
950            },
951        )];
952        let config = Config {
953            disabled_rules: vec![RuleId::new("T101")],
954            ..Config::default()
955        };
956        let diags = evaluate_rules(&funcs, &config);
957        assert!(diags.is_empty());
958    }
959
960    #[test]
961    fn t101_suppressed_no_diagnostic() {
962        let funcs = vec![make_func(
963            "test_calls_repo",
964            TestAnalysis {
965                assertion_count: 1,
966                how_not_what_count: 2,
967                suppressed_rules: vec![RuleId::new("T101")],
968                ..Default::default()
969            },
970        )];
971        let diags = evaluate_rules(&funcs, &Config::default());
972        assert!(diags.is_empty());
973    }
974
975    // --- T102: fixture-sprawl ---
976
977    #[test]
978    fn t102_fixture_count_exceeds_threshold_produces_info() {
979        let funcs = vec![make_func(
980            "test_sprawl",
981            TestAnalysis {
982                assertion_count: 1,
983                fixture_count: 7,
984                ..Default::default()
985            },
986        )];
987        let diags = evaluate_rules(&funcs, &Config::default());
988        assert_eq!(diags.len(), 1);
989        assert_eq!(diags[0].rule, RuleId::new("T102"));
990        assert_eq!(diags[0].severity, Severity::Info);
991        assert!(diags[0].message.contains("7 fixtures"));
992    }
993
994    #[test]
995    fn t102_fixture_count_at_threshold_no_diagnostic() {
996        let funcs = vec![make_func(
997            "test_fixtures_at_threshold",
998            TestAnalysis {
999                assertion_count: 1,
1000                fixture_count: 5, // exactly at threshold, strict >
1001                ..Default::default()
1002            },
1003        )];
1004        let diags = evaluate_rules(&funcs, &Config::default());
1005        assert!(diags.is_empty());
1006    }
1007
1008    #[test]
1009    fn t102_zero_fixtures_no_diagnostic() {
1010        let funcs = vec![make_func(
1011            "test_no_fixtures",
1012            TestAnalysis {
1013                assertion_count: 1,
1014                fixture_count: 0,
1015                ..Default::default()
1016            },
1017        )];
1018        let diags = evaluate_rules(&funcs, &Config::default());
1019        assert!(diags.is_empty());
1020    }
1021
1022    #[test]
1023    fn t102_disabled_no_diagnostic() {
1024        let funcs = vec![make_func(
1025            "test_sprawl",
1026            TestAnalysis {
1027                assertion_count: 1,
1028                fixture_count: 7,
1029                ..Default::default()
1030            },
1031        )];
1032        let config = Config {
1033            disabled_rules: vec![RuleId::new("T102")],
1034            ..Config::default()
1035        };
1036        let diags = evaluate_rules(&funcs, &config);
1037        assert!(diags.is_empty());
1038    }
1039
1040    #[test]
1041    fn t102_suppressed_no_diagnostic() {
1042        let funcs = vec![make_func(
1043            "test_sprawl",
1044            TestAnalysis {
1045                assertion_count: 1,
1046                fixture_count: 7,
1047                suppressed_rules: vec![RuleId::new("T102")],
1048                ..Default::default()
1049            },
1050        )];
1051        let diags = evaluate_rules(&funcs, &Config::default());
1052        assert!(diags.is_empty());
1053    }
1054
1055    #[test]
1056    fn t102_custom_threshold() {
1057        let funcs = vec![make_func(
1058            "test_sprawl",
1059            TestAnalysis {
1060                assertion_count: 1,
1061                fixture_count: 4,
1062                ..Default::default()
1063            },
1064        )];
1065        let config = Config {
1066            fixture_max: 3,
1067            ..Config::default()
1068        };
1069        let diags = evaluate_rules(&funcs, &config);
1070        assert_eq!(diags.len(), 1);
1071        assert_eq!(diags[0].rule, RuleId::new("T102"));
1072    }
1073
1074    // --- Multiple violations ---
1075
1076    #[test]
1077    fn multiple_violations_reported() {
1078        let funcs = vec![make_func(
1079            "test_assertion_free_and_giant",
1080            TestAnalysis {
1081                assertion_count: 0,
1082                line_count: 73,
1083                ..Default::default()
1084            },
1085        )];
1086        let diags = evaluate_rules(&funcs, &Config::default());
1087        assert_eq!(diags.len(), 2);
1088        let rule_ids: Vec<&str> = diags.iter().map(|d| d.rule.0.as_str()).collect();
1089        assert!(rule_ids.contains(&"T001"));
1090        assert!(rule_ids.contains(&"T003"));
1091    }
1092
1093    // === File-level rules ===
1094
1095    fn make_file_analysis(
1096        file: &str,
1097        functions: Vec<TestFunction>,
1098        has_pbt_import: bool,
1099        has_contract_import: bool,
1100        parameterized_count: usize,
1101    ) -> FileAnalysis {
1102        make_file_analysis_full(
1103            file,
1104            functions,
1105            has_pbt_import,
1106            has_contract_import,
1107            false,
1108            parameterized_count,
1109        )
1110    }
1111
1112    fn make_file_analysis_full(
1113        file: &str,
1114        functions: Vec<TestFunction>,
1115        has_pbt_import: bool,
1116        has_contract_import: bool,
1117        has_error_test: bool,
1118        parameterized_count: usize,
1119    ) -> FileAnalysis {
1120        FileAnalysis {
1121            file: file.to_string(),
1122            functions,
1123            has_pbt_import,
1124            has_contract_import,
1125            has_error_test,
1126            has_relational_assertion: false,
1127            parameterized_count,
1128        }
1129    }
1130
1131    // --- T006: low-assertion-density ---
1132
1133    #[test]
1134    fn t006_low_density_produces_warn() {
1135        // density = total_assertions / total_functions (all functions, including assertion-free).
1136        // Fires when density < 1.0 and at least one function has assertions.
1137        // When ALL functions are assertion-free, T006 does not fire (T001 handles those).
1138        let funcs = vec![
1139            make_func(
1140                "test_a",
1141                TestAnalysis {
1142                    assertion_count: 1,
1143                    ..Default::default()
1144                },
1145            ),
1146            make_func(
1147                "test_b",
1148                TestAnalysis {
1149                    assertion_count: 0,
1150                    ..Default::default()
1151                },
1152            ),
1153            make_func(
1154                "test_c",
1155                TestAnalysis {
1156                    assertion_count: 0,
1157                    ..Default::default()
1158                },
1159            ),
1160        ];
1161        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1162        let diags = evaluate_file_rules(&analyses, &Config::default());
1163        assert!(diags.iter().any(|d| d.rule.0 == "T006"));
1164    }
1165
1166    #[test]
1167    fn t006_high_density_no_diagnostic() {
1168        let funcs = vec![
1169            make_func(
1170                "test_a",
1171                TestAnalysis {
1172                    assertion_count: 2,
1173                    ..Default::default()
1174                },
1175            ),
1176            make_func(
1177                "test_b",
1178                TestAnalysis {
1179                    assertion_count: 1,
1180                    ..Default::default()
1181                },
1182            ),
1183        ];
1184        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1185        let diags = evaluate_file_rules(&analyses, &Config::default());
1186        assert!(!diags.iter().any(|d| d.rule.0 == "T006"));
1187    }
1188
1189    #[test]
1190    fn t006_all_assertion_free_no_diagnostic() {
1191        let funcs = vec![
1192            make_func(
1193                "test_a",
1194                TestAnalysis {
1195                    assertion_count: 0,
1196                    ..Default::default()
1197                },
1198            ),
1199            make_func(
1200                "test_b",
1201                TestAnalysis {
1202                    assertion_count: 0,
1203                    ..Default::default()
1204                },
1205            ),
1206        ];
1207        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1208        let diags = evaluate_file_rules(&analyses, &Config::default());
1209        assert!(
1210            !diags.iter().any(|d| d.rule.0 == "T006"),
1211            "T006 should not fire when all functions are assertion-free (T001 handles)"
1212        );
1213    }
1214
1215    #[test]
1216    fn t006_empty_file_no_diagnostic() {
1217        let analyses = vec![make_file_analysis("test.py", vec![], false, false, 0)];
1218        let diags = evaluate_file_rules(&analyses, &Config::default());
1219        assert!(!diags.iter().any(|d| d.rule.0 == "T006"));
1220    }
1221
1222    #[test]
1223    fn t006_disabled_no_diagnostic() {
1224        let funcs = vec![
1225            make_func(
1226                "test_a",
1227                TestAnalysis {
1228                    assertion_count: 1,
1229                    ..Default::default()
1230                },
1231            ),
1232            make_func(
1233                "test_b",
1234                TestAnalysis {
1235                    assertion_count: 0,
1236                    ..Default::default()
1237                },
1238            ),
1239        ];
1240        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1241        let config = Config {
1242            disabled_rules: vec![RuleId::new("T006")],
1243            ..Config::default()
1244        };
1245        let diags = evaluate_file_rules(&analyses, &config);
1246        assert!(!diags.iter().any(|d| d.rule.0 == "T006"));
1247    }
1248
1249    // --- T004: no-parameterized ---
1250
1251    #[test]
1252    fn t004_no_parameterized_produces_info() {
1253        let funcs = vec![make_func(
1254            "test_a",
1255            TestAnalysis {
1256                assertion_count: 1,
1257                ..Default::default()
1258            },
1259        )];
1260        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1261        let diags = evaluate_file_rules(&analyses, &Config::default());
1262        assert!(diags.iter().any(|d| d.rule.0 == "T004"));
1263        let t004 = diags.iter().find(|d| d.rule.0 == "T004").unwrap();
1264        assert_eq!(t004.severity, Severity::Info);
1265    }
1266
1267    #[test]
1268    fn t004_sufficient_parameterized_no_diagnostic() {
1269        let funcs = vec![
1270            make_func(
1271                "test_a",
1272                TestAnalysis {
1273                    assertion_count: 1,
1274                    ..Default::default()
1275                },
1276            ),
1277            make_func(
1278                "test_b",
1279                TestAnalysis {
1280                    assertion_count: 1,
1281                    ..Default::default()
1282                },
1283            ),
1284        ];
1285        // parameterized_count=1 out of 2 → ratio 0.5 >= 0.1
1286        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 1)];
1287        let diags = evaluate_file_rules(&analyses, &Config::default());
1288        assert!(!diags.iter().any(|d| d.rule.0 == "T004"));
1289    }
1290
1291    #[test]
1292    fn t004_custom_threshold() {
1293        let funcs = vec![
1294            make_func(
1295                "test_a",
1296                TestAnalysis {
1297                    assertion_count: 1,
1298                    ..Default::default()
1299                },
1300            ),
1301            make_func(
1302                "test_b",
1303                TestAnalysis {
1304                    assertion_count: 1,
1305                    ..Default::default()
1306                },
1307            ),
1308        ];
1309        // 1/2 = 0.5, threshold 0.6 → should fire
1310        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 1)];
1311        let config = Config {
1312            parameterized_min_ratio: 0.6,
1313            ..Config::default()
1314        };
1315        let diags = evaluate_file_rules(&analyses, &config);
1316        assert!(diags.iter().any(|d| d.rule.0 == "T004"));
1317    }
1318
1319    // --- T005: pbt-missing ---
1320
1321    #[test]
1322    fn t005_no_pbt_import_produces_info() {
1323        let funcs = vec![make_func(
1324            "test_a",
1325            TestAnalysis {
1326                assertion_count: 1,
1327                ..Default::default()
1328            },
1329        )];
1330        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1331        let diags = evaluate_file_rules(&analyses, &Config::default());
1332        assert!(diags.iter().any(|d| d.rule.0 == "T005"));
1333    }
1334
1335    #[test]
1336    fn t005_has_pbt_import_no_diagnostic() {
1337        let funcs = vec![make_func(
1338            "test_a",
1339            TestAnalysis {
1340                assertion_count: 1,
1341                ..Default::default()
1342            },
1343        )];
1344        let analyses = vec![make_file_analysis("test.py", funcs, true, false, 0)];
1345        let diags = evaluate_file_rules(&analyses, &Config::default());
1346        assert!(!diags.iter().any(|d| d.rule.0 == "T005"));
1347    }
1348
1349    #[test]
1350    fn t005_empty_file_no_diagnostic() {
1351        let analyses = vec![make_file_analysis("test.py", vec![], false, false, 0)];
1352        let diags = evaluate_file_rules(&analyses, &Config::default());
1353        assert!(!diags.iter().any(|d| d.rule.0 == "T005"));
1354    }
1355
1356    // --- T008: no-contract ---
1357
1358    #[test]
1359    fn t008_no_contract_import_produces_info() {
1360        let funcs = vec![make_func(
1361            "test_a",
1362            TestAnalysis {
1363                assertion_count: 1,
1364                ..Default::default()
1365            },
1366        )];
1367        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1368        let diags = evaluate_file_rules(&analyses, &Config::default());
1369        assert!(diags.iter().any(|d| d.rule.0 == "T008"));
1370    }
1371
1372    #[test]
1373    fn t008_has_contract_import_no_diagnostic() {
1374        let funcs = vec![make_func(
1375            "test_a",
1376            TestAnalysis {
1377                assertion_count: 1,
1378                ..Default::default()
1379            },
1380        )];
1381        let analyses = vec![make_file_analysis("test.py", funcs, false, true, 0)];
1382        let diags = evaluate_file_rules(&analyses, &Config::default());
1383        assert!(!diags.iter().any(|d| d.rule.0 == "T008"));
1384    }
1385
1386    #[test]
1387    fn t008_empty_file_no_diagnostic() {
1388        let analyses = vec![make_file_analysis("test.py", vec![], false, false, 0)];
1389        let diags = evaluate_file_rules(&analyses, &Config::default());
1390        assert!(!diags.iter().any(|d| d.rule.0 == "T008"));
1391    }
1392
1393    // --- T103: missing-error-test ---
1394
1395    #[test]
1396    fn t103_no_error_test_produces_info() {
1397        let funcs = vec![make_func(
1398            "test_a",
1399            TestAnalysis {
1400                assertion_count: 1,
1401                ..Default::default()
1402            },
1403        )];
1404        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1405        let diags = evaluate_file_rules(&analyses, &Config::default());
1406        assert!(diags.iter().any(|d| d.rule.0 == "T103"));
1407        let t103 = diags.iter().find(|d| d.rule.0 == "T103").unwrap();
1408        assert_eq!(t103.severity, Severity::Info);
1409    }
1410
1411    #[test]
1412    fn t103_has_error_test_no_diagnostic() {
1413        let funcs = vec![make_func(
1414            "test_a",
1415            TestAnalysis {
1416                assertion_count: 1,
1417                ..Default::default()
1418            },
1419        )];
1420        let analyses = vec![make_file_analysis_full(
1421            "test.py", funcs, false, false, true, 0,
1422        )];
1423        let diags = evaluate_file_rules(&analyses, &Config::default());
1424        assert!(!diags.iter().any(|d| d.rule.0 == "T103"));
1425    }
1426
1427    #[test]
1428    fn t103_empty_file_no_diagnostic() {
1429        let analyses = vec![make_file_analysis("test.py", vec![], false, false, 0)];
1430        let diags = evaluate_file_rules(&analyses, &Config::default());
1431        assert!(!diags.iter().any(|d| d.rule.0 == "T103"));
1432    }
1433
1434    // --- T105: deterministic-no-metamorphic ---
1435
1436    #[test]
1437    fn t105_all_equality_above_threshold_produces_info() {
1438        let funcs: Vec<TestFunction> = (0..5)
1439            .map(|i| {
1440                make_func(
1441                    &format!("test_{i}"),
1442                    TestAnalysis {
1443                        assertion_count: 1,
1444                        ..Default::default()
1445                    },
1446                )
1447            })
1448            .collect();
1449        let analyses = vec![make_file_analysis_full(
1450            "test.py", funcs, false, false, false, 0,
1451        )];
1452        let diags = evaluate_file_rules(&analyses, &Config::default());
1453        assert!(diags.iter().any(|d| d.rule.0 == "T105"));
1454        let t105 = diags.iter().find(|d| d.rule.0 == "T105").unwrap();
1455        assert_eq!(t105.severity, Severity::Info);
1456        assert!(t105.message.contains("5 assertions"));
1457    }
1458
1459    #[test]
1460    fn t105_has_relational_no_diagnostic() {
1461        let funcs: Vec<TestFunction> = (0..5)
1462            .map(|i| {
1463                make_func(
1464                    &format!("test_{i}"),
1465                    TestAnalysis {
1466                        assertion_count: 1,
1467                        ..Default::default()
1468                    },
1469                )
1470            })
1471            .collect();
1472        let mut analysis = make_file_analysis_full("test.py", funcs, false, false, false, 0);
1473        analysis.has_relational_assertion = true;
1474        let diags = evaluate_file_rules(&[analysis], &Config::default());
1475        assert!(!diags.iter().any(|d| d.rule.0 == "T105"));
1476    }
1477
1478    #[test]
1479    fn t105_below_threshold_no_diagnostic() {
1480        let funcs: Vec<TestFunction> = (0..2)
1481            .map(|i| {
1482                make_func(
1483                    &format!("test_{i}"),
1484                    TestAnalysis {
1485                        assertion_count: 1,
1486                        ..Default::default()
1487                    },
1488                )
1489            })
1490            .collect();
1491        let analyses = vec![make_file_analysis_full(
1492            "test.py", funcs, false, false, false, 0,
1493        )];
1494        let diags = evaluate_file_rules(&analyses, &Config::default());
1495        assert!(!diags.iter().any(|d| d.rule.0 == "T105"));
1496    }
1497
1498    #[test]
1499    fn t105_disabled_no_diagnostic() {
1500        let funcs: Vec<TestFunction> = (0..5)
1501            .map(|i| {
1502                make_func(
1503                    &format!("test_{i}"),
1504                    TestAnalysis {
1505                        assertion_count: 1,
1506                        ..Default::default()
1507                    },
1508                )
1509            })
1510            .collect();
1511        let analyses = vec![make_file_analysis_full(
1512            "test.py", funcs, false, false, false, 0,
1513        )];
1514        let config = Config {
1515            disabled_rules: vec![RuleId::new("T105")],
1516            ..Config::default()
1517        };
1518        let diags = evaluate_file_rules(&analyses, &config);
1519        assert!(!diags.iter().any(|d| d.rule.0 == "T105"));
1520    }
1521
1522    #[test]
1523    fn t105_custom_threshold() {
1524        let funcs: Vec<TestFunction> = (0..3)
1525            .map(|i| {
1526                make_func(
1527                    &format!("test_{i}"),
1528                    TestAnalysis {
1529                        assertion_count: 1,
1530                        ..Default::default()
1531                    },
1532                )
1533            })
1534            .collect();
1535        let analyses = vec![make_file_analysis_full(
1536            "test.py", funcs, false, false, false, 0,
1537        )];
1538        let config = Config {
1539            min_assertions_for_t105: 3,
1540            ..Config::default()
1541        };
1542        let diags = evaluate_file_rules(&analyses, &config);
1543        assert!(diags.iter().any(|d| d.rule.0 == "T105"));
1544    }
1545
1546    #[test]
1547    fn t103_disabled_no_diagnostic() {
1548        let funcs = vec![make_func(
1549            "test_a",
1550            TestAnalysis {
1551                assertion_count: 1,
1552                ..Default::default()
1553            },
1554        )];
1555        let analyses = vec![make_file_analysis("test.py", funcs, false, false, 0)];
1556        let config = Config {
1557            disabled_rules: vec![RuleId::new("T103")],
1558            ..Config::default()
1559        };
1560        let diags = evaluate_file_rules(&analyses, &config);
1561        assert!(!diags.iter().any(|d| d.rule.0 == "T103"));
1562    }
1563
1564    // === Project-level rules ===
1565
1566    // --- T007: test-source-ratio ---
1567
1568    #[test]
1569    fn t007_produces_info_with_ratio() {
1570        let diags = evaluate_project_rules(5, 10, &Config::default());
1571        assert_eq!(diags.len(), 1);
1572        assert_eq!(diags[0].rule, RuleId::new("T007"));
1573        assert_eq!(diags[0].severity, Severity::Info);
1574        assert!(diags[0].message.contains("5/10"));
1575    }
1576
1577    #[test]
1578    fn t007_zero_source_files_no_diagnostic() {
1579        let diags = evaluate_project_rules(5, 0, &Config::default());
1580        assert!(diags.is_empty());
1581    }
1582
1583    #[test]
1584    fn t007_disabled_no_diagnostic() {
1585        let config = Config {
1586            disabled_rules: vec![RuleId::new("T007")],
1587            ..Config::default()
1588        };
1589        let diags = evaluate_project_rules(5, 10, &config);
1590        assert!(diags.is_empty());
1591    }
1592
1593    // --- T108: wait-and-see ---
1594
1595    #[test]
1596    fn t108_has_wait_produces_info() {
1597        let funcs = vec![make_func(
1598            "test_sleepy",
1599            TestAnalysis {
1600                assertion_count: 1,
1601                has_wait: true,
1602                ..Default::default()
1603            },
1604        )];
1605        let diags = evaluate_rules(&funcs, &Config::default());
1606        let t108: Vec<_> = diags
1607            .iter()
1608            .filter(|d| d.rule == RuleId::new("T108"))
1609            .collect();
1610        assert_eq!(t108.len(), 1);
1611        assert_eq!(t108[0].severity, Severity::Info);
1612        assert!(t108[0].message.contains("wait-and-see"));
1613    }
1614
1615    #[test]
1616    fn t108_no_wait_no_diagnostic() {
1617        let funcs = vec![make_func(
1618            "test_fast",
1619            TestAnalysis {
1620                assertion_count: 1,
1621                has_wait: false,
1622                ..Default::default()
1623            },
1624        )];
1625        let diags = evaluate_rules(&funcs, &Config::default());
1626        let t108: Vec<_> = diags
1627            .iter()
1628            .filter(|d| d.rule == RuleId::new("T108"))
1629            .collect();
1630        assert!(t108.is_empty());
1631    }
1632
1633    #[test]
1634    fn t108_disabled_no_diagnostic() {
1635        let funcs = vec![make_func(
1636            "test_sleepy",
1637            TestAnalysis {
1638                assertion_count: 1,
1639                has_wait: true,
1640                ..Default::default()
1641            },
1642        )];
1643        let config = Config {
1644            disabled_rules: vec![RuleId::new("T108")],
1645            ..Config::default()
1646        };
1647        let diags = evaluate_rules(&funcs, &config);
1648        let t108: Vec<_> = diags
1649            .iter()
1650            .filter(|d| d.rule == RuleId::new("T108"))
1651            .collect();
1652        assert!(t108.is_empty());
1653    }
1654
1655    #[test]
1656    fn t108_suppressed_no_diagnostic() {
1657        let funcs = vec![make_func(
1658            "test_sleepy",
1659            TestAnalysis {
1660                assertion_count: 1,
1661                has_wait: true,
1662                suppressed_rules: vec![RuleId::new("T108")],
1663                ..Default::default()
1664            },
1665        )];
1666        let diags = evaluate_rules(&funcs, &Config::default());
1667        let t108: Vec<_> = diags
1668            .iter()
1669            .filter(|d| d.rule == RuleId::new("T108"))
1670            .collect();
1671        assert!(t108.is_empty());
1672    }
1673
1674    // --- T109: undescriptive-test-name ---
1675
1676    #[test]
1677    fn t109_is_undescriptive_digits_only() {
1678        assert!(is_undescriptive_test_name("test_1"));
1679        assert!(is_undescriptive_test_name("test_123"));
1680        assert!(is_undescriptive_test_name("test1"));
1681    }
1682
1683    #[test]
1684    fn t109_is_undescriptive_short_word() {
1685        assert!(is_undescriptive_test_name("test_it"));
1686        assert!(is_undescriptive_test_name("test_foo"));
1687        assert!(is_undescriptive_test_name("test_run"));
1688        // 4-char suffix = boundary (chars().count() <= 4)
1689        assert!(is_undescriptive_test_name("test_main"));
1690        // 5-char suffix = descriptive
1691        assert!(!is_undescriptive_test_name("test_login"));
1692    }
1693
1694    #[test]
1695    fn t109_is_undescriptive_blacklist() {
1696        assert!(is_undescriptive_test_name("test_case"));
1697        assert!(is_undescriptive_test_name("test_example"));
1698        assert!(is_undescriptive_test_name("test_sample"));
1699        assert!(is_undescriptive_test_name("test_basic"));
1700        assert!(is_undescriptive_test_name("test_data"));
1701        assert!(is_undescriptive_test_name("test_check"));
1702        assert!(is_undescriptive_test_name("test_func"));
1703        assert!(is_undescriptive_test_name("test_method"));
1704    }
1705
1706    #[test]
1707    fn t109_is_undescriptive_just_test() {
1708        assert!(is_undescriptive_test_name("test"));
1709    }
1710
1711    #[test]
1712    fn t109_is_descriptive_pass() {
1713        assert!(!is_undescriptive_test_name(
1714            "test_user_creation_returns_valid_id"
1715        ));
1716        assert!(!is_undescriptive_test_name("test_empty_input_raises_error"));
1717        assert!(!is_undescriptive_test_name("test_calculate_total_price"));
1718        assert!(!is_undescriptive_test_name("test_login"));
1719    }
1720
1721    // TC-01: CJK Japanese test name without prefix → descriptive
1722    #[test]
1723    fn t109_cjk_japanese_no_prefix_is_descriptive() {
1724        assert!(!is_undescriptive_test_name(
1725            "ローディング中にスケルトンが表示される"
1726        ));
1727    }
1728
1729    // TC-02: CJK Japanese test name with prefix → descriptive
1730    #[test]
1731    fn t109_cjk_japanese_with_prefix_is_descriptive() {
1732        assert!(!is_undescriptive_test_name(
1733            "test_ユーザー作成が有効なIDを返す"
1734        ));
1735    }
1736
1737    // TC-03: CJK single character with prefix → descriptive (FP削減トレードオフ)
1738    #[test]
1739    fn t109_cjk_single_char_with_prefix_is_descriptive() {
1740        assert!(!is_undescriptive_test_name("test_中"));
1741    }
1742
1743    // TC-04: CJK Chinese test name without prefix → descriptive
1744    #[test]
1745    fn t109_cjk_chinese_no_prefix_is_descriptive() {
1746        assert!(!is_undescriptive_test_name("测试用户创建返回有效ID"));
1747    }
1748
1749    // TC-05: CJK Korean test name with underscores → descriptive
1750    #[test]
1751    fn t109_cjk_korean_with_underscores_is_descriptive() {
1752        assert!(!is_undescriptive_test_name(
1753            "사용자_생성이_유효한_ID를_반환한다"
1754        ));
1755    }
1756
1757    // TC-08: TypeScript string-style CJK name → descriptive
1758    #[test]
1759    fn t109_cjk_typescript_string_style_is_descriptive() {
1760        assert!(!is_undescriptive_test_name(
1761            "'ローディング中にスケルトンが表示される'"
1762        ));
1763    }
1764
1765    #[test]
1766    fn t109_typescript_string_names() {
1767        // Undescriptive
1768        assert!(is_undescriptive_test_name("works"));
1769        assert!(is_undescriptive_test_name("test"));
1770        assert!(is_undescriptive_test_name("it"));
1771        // Descriptive
1772        assert!(!is_undescriptive_test_name(
1773            "should calculate total price correctly"
1774        ));
1775        assert!(!is_undescriptive_test_name(
1776            "returns valid user when given valid credentials"
1777        ));
1778    }
1779
1780    #[test]
1781    fn t109_produces_info_diagnostic() {
1782        let funcs = vec![make_func(
1783            "test_1",
1784            TestAnalysis {
1785                assertion_count: 1,
1786                ..Default::default()
1787            },
1788        )];
1789        let diags = evaluate_rules(&funcs, &Config::default());
1790        let t109: Vec<_> = diags
1791            .iter()
1792            .filter(|d| d.rule == RuleId::new("T109"))
1793            .collect();
1794        assert_eq!(t109.len(), 1);
1795        assert_eq!(t109[0].severity, Severity::Info);
1796        assert!(t109[0].message.contains("undescriptive-test-name"));
1797    }
1798
1799    #[test]
1800    fn t109_descriptive_name_no_diagnostic() {
1801        let funcs = vec![make_func(
1802            "test_user_creation_returns_valid_id",
1803            TestAnalysis {
1804                assertion_count: 1,
1805                ..Default::default()
1806            },
1807        )];
1808        let diags = evaluate_rules(&funcs, &Config::default());
1809        let t109: Vec<_> = diags
1810            .iter()
1811            .filter(|d| d.rule == RuleId::new("T109"))
1812            .collect();
1813        assert!(t109.is_empty());
1814    }
1815
1816    #[test]
1817    fn t109_disabled_no_diagnostic() {
1818        let funcs = vec![make_func(
1819            "test_1",
1820            TestAnalysis {
1821                assertion_count: 1,
1822                ..Default::default()
1823            },
1824        )];
1825        let config = Config {
1826            disabled_rules: vec![RuleId::new("T109")],
1827            ..Config::default()
1828        };
1829        let diags = evaluate_rules(&funcs, &config);
1830        let t109: Vec<_> = diags
1831            .iter()
1832            .filter(|d| d.rule == RuleId::new("T109"))
1833            .collect();
1834        assert!(t109.is_empty());
1835    }
1836
1837    // --- T107: assertion-roulette ---
1838
1839    #[test]
1840    fn t107_multiple_assertions_no_messages_produces_info() {
1841        let funcs = vec![make_func(
1842            "test_multiple_asserts_no_messages",
1843            TestAnalysis {
1844                assertion_count: 3,
1845                assertion_message_count: 0,
1846                ..Default::default()
1847            },
1848        )];
1849        let diags = evaluate_rules(&funcs, &Config::default());
1850        let t107: Vec<_> = diags
1851            .iter()
1852            .filter(|d| d.rule == RuleId::new("T107"))
1853            .collect();
1854        assert_eq!(t107.len(), 1);
1855        assert_eq!(t107[0].severity, Severity::Info);
1856        assert!(t107[0].message.contains("assertion-roulette"));
1857    }
1858
1859    #[test]
1860    fn t107_single_assertion_no_diagnostic() {
1861        let funcs = vec![make_func(
1862            "test_single_assert_passes",
1863            TestAnalysis {
1864                assertion_count: 1,
1865                assertion_message_count: 0,
1866                ..Default::default()
1867            },
1868        )];
1869        let diags = evaluate_rules(&funcs, &Config::default());
1870        let t107: Vec<_> = diags
1871            .iter()
1872            .filter(|d| d.rule == RuleId::new("T107"))
1873            .collect();
1874        assert!(t107.is_empty());
1875    }
1876
1877    #[test]
1878    fn t107_assertions_with_messages_no_diagnostic() {
1879        let funcs = vec![make_func(
1880            "test_asserts_with_messages_pass",
1881            TestAnalysis {
1882                assertion_count: 3,
1883                assertion_message_count: 3,
1884                ..Default::default()
1885            },
1886        )];
1887        let diags = evaluate_rules(&funcs, &Config::default());
1888        let t107: Vec<_> = diags
1889            .iter()
1890            .filter(|d| d.rule == RuleId::new("T107"))
1891            .collect();
1892        assert!(t107.is_empty());
1893    }
1894
1895    #[test]
1896    fn t107_partial_messages_no_diagnostic() {
1897        let funcs = vec![make_func(
1898            "test_partial_messages_still_pass",
1899            TestAnalysis {
1900                assertion_count: 3,
1901                assertion_message_count: 1, // at least some have messages
1902                ..Default::default()
1903            },
1904        )];
1905        let diags = evaluate_rules(&funcs, &Config::default());
1906        let t107: Vec<_> = diags
1907            .iter()
1908            .filter(|d| d.rule == RuleId::new("T107"))
1909            .collect();
1910        assert!(t107.is_empty(), "partial messages should not trigger T107");
1911    }
1912
1913    #[test]
1914    fn t107_disabled_no_diagnostic() {
1915        let funcs = vec![make_func(
1916            "test_multiple_asserts_disabled",
1917            TestAnalysis {
1918                assertion_count: 3,
1919                assertion_message_count: 0,
1920                ..Default::default()
1921            },
1922        )];
1923        let config = Config {
1924            disabled_rules: vec![RuleId::new("T107")],
1925            ..Config::default()
1926        };
1927        let diags = evaluate_rules(&funcs, &config);
1928        let t107: Vec<_> = diags
1929            .iter()
1930            .filter(|d| d.rule == RuleId::new("T107"))
1931            .collect();
1932        assert!(t107.is_empty());
1933    }
1934
1935    // --- T106: duplicate-literal-assertion ---
1936
1937    #[test]
1938    fn t106_default_is_disabled() {
1939        let funcs = vec![make_func(
1940            "test_duplicate_literals",
1941            TestAnalysis {
1942                assertion_count: 4,
1943                duplicate_literal_count: 4,
1944                ..Default::default()
1945            },
1946        )];
1947        let config = Config::default(); // min_duplicate_count = 3
1948        let diags = evaluate_rules(&funcs, &config);
1949        let t106: Vec<_> = diags
1950            .iter()
1951            .filter(|d| d.rule == RuleId::new("T106"))
1952            .collect();
1953        assert!(t106.is_empty());
1954    }
1955
1956    #[test]
1957    fn t106_duplicate_literal_can_be_reenabled_via_severity_override() {
1958        let funcs = vec![make_func(
1959            "test_duplicate_literals",
1960            TestAnalysis {
1961                assertion_count: 4,
1962                duplicate_literal_count: 4,
1963                ..Default::default()
1964            },
1965        )];
1966        let mut overrides = HashMap::new();
1967        overrides.insert("T106".to_string(), Severity::Info);
1968        let config = Config {
1969            disabled_rules: Vec::new(),
1970            severity_overrides: overrides,
1971            ..Default::default()
1972        };
1973        let diags = evaluate_rules(&funcs, &config);
1974        let t106: Vec<_> = diags
1975            .iter()
1976            .filter(|d| d.rule == RuleId::new("T106"))
1977            .collect();
1978        assert_eq!(t106.len(), 1);
1979        assert_eq!(t106[0].severity, Severity::Info);
1980        assert!(t106[0].message.contains("duplicate-literal-assertion"));
1981    }
1982
1983    #[test]
1984    fn t106_below_threshold_no_diagnostic() {
1985        let funcs = vec![make_func(
1986            "test_few_duplicates",
1987            TestAnalysis {
1988                assertion_count: 3,
1989                duplicate_literal_count: 2,
1990                ..Default::default()
1991            },
1992        )];
1993        let config = Config::default();
1994        let diags = evaluate_rules(&funcs, &config);
1995        let t106: Vec<_> = diags
1996            .iter()
1997            .filter(|d| d.rule == RuleId::new("T106"))
1998            .collect();
1999        assert!(t106.is_empty());
2000    }
2001
2002    #[test]
2003    fn t106_at_threshold_produces_diagnostic() {
2004        let funcs = vec![make_func(
2005            "test_at_threshold",
2006            TestAnalysis {
2007                assertion_count: 3,
2008                duplicate_literal_count: 3,
2009                ..Default::default()
2010            },
2011        )];
2012        let mut overrides = HashMap::new();
2013        overrides.insert("T106".to_string(), Severity::Info);
2014        let config = Config {
2015            disabled_rules: Vec::new(),
2016            severity_overrides: overrides,
2017            ..Default::default()
2018        };
2019        let diags = evaluate_rules(&funcs, &config);
2020        let t106: Vec<_> = diags
2021            .iter()
2022            .filter(|d| d.rule == RuleId::new("T106"))
2023            .collect();
2024        assert_eq!(t106.len(), 1);
2025    }
2026
2027    #[test]
2028    fn t106_disabled_no_diagnostic() {
2029        let funcs = vec![make_func(
2030            "test_duplicate_literals",
2031            TestAnalysis {
2032                assertion_count: 4,
2033                duplicate_literal_count: 4,
2034                ..Default::default()
2035            },
2036        )];
2037        let config = Config {
2038            disabled_rules: vec![RuleId::new("T106")],
2039            ..Default::default()
2040        };
2041        let diags = evaluate_rules(&funcs, &config);
2042        let t106: Vec<_> = diags
2043            .iter()
2044            .filter(|d| d.rule == RuleId::new("T106"))
2045            .collect();
2046        assert!(t106.is_empty());
2047    }
2048
2049    #[test]
2050    fn t106_custom_threshold() {
2051        let funcs = vec![make_func(
2052            "test_duplicate_literals",
2053            TestAnalysis {
2054                assertion_count: 4,
2055                duplicate_literal_count: 4,
2056                ..Default::default()
2057            },
2058        )];
2059        let config = Config {
2060            min_duplicate_count: 5,
2061            ..Default::default()
2062        };
2063        let diags = evaluate_rules(&funcs, &config);
2064        let t106: Vec<_> = diags
2065            .iter()
2066            .filter(|d| d.rule == RuleId::new("T106"))
2067            .collect();
2068        assert!(t106.is_empty(), "count=4 with threshold=5 should not fire");
2069    }
2070
2071    // --- #60: effective_severity ---
2072
2073    #[test]
2074    fn effective_severity_returns_override_when_present() {
2075        let mut overrides = HashMap::new();
2076        overrides.insert("T001".to_string(), Severity::Info);
2077        let config = Config {
2078            severity_overrides: overrides,
2079            ..Default::default()
2080        };
2081        assert_eq!(
2082            effective_severity(&config, "T001", Severity::Block),
2083            Severity::Info
2084        );
2085    }
2086
2087    #[test]
2088    fn effective_severity_returns_default_when_no_override() {
2089        let config = Config::default();
2090        assert_eq!(
2091            effective_severity(&config, "T001", Severity::Block),
2092            Severity::Block
2093        );
2094    }
2095
2096    #[test]
2097    fn integration_t107_severity_overridden_to_warn() {
2098        let funcs = vec![make_func(
2099            "test_something",
2100            TestAnalysis {
2101                assertion_count: 3,
2102                assertion_message_count: 0,
2103                ..Default::default()
2104            },
2105        )];
2106        let mut overrides = HashMap::new();
2107        overrides.insert("T107".to_string(), Severity::Warn);
2108        let config = Config {
2109            severity_overrides: overrides,
2110            ..Default::default()
2111        };
2112        let diags = evaluate_rules(&funcs, &config);
2113        let t107: Vec<_> = diags
2114            .iter()
2115            .filter(|d| d.rule == RuleId::new("T107"))
2116            .collect();
2117        assert_eq!(t107.len(), 1);
2118        assert_eq!(
2119            t107[0].severity,
2120            Severity::Warn,
2121            "T107 should be overridden to Warn"
2122        );
2123    }
2124
2125    #[test]
2126    fn integration_t001_severity_overridden_to_warn() {
2127        let funcs = vec![make_func(
2128            "test_no_assertions",
2129            TestAnalysis {
2130                assertion_count: 0,
2131                ..Default::default()
2132            },
2133        )];
2134        let mut overrides = HashMap::new();
2135        overrides.insert("T001".to_string(), Severity::Warn);
2136        let config = Config {
2137            severity_overrides: overrides,
2138            ..Default::default()
2139        };
2140        let diags = evaluate_rules(&funcs, &config);
2141        let t001: Vec<_> = diags
2142            .iter()
2143            .filter(|d| d.rule == RuleId::new("T001"))
2144            .collect();
2145        assert_eq!(t001.len(), 1);
2146        assert_eq!(
2147            t001[0].severity,
2148            Severity::Warn,
2149            "T001 should be overridden to Warn"
2150        );
2151    }
2152}