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