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