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