Skip to main content

exspec_lang_typescript/
lib.rs

1use std::sync::OnceLock;
2
3use exspec_core::extractor::{FileAnalysis, LanguageExtractor, TestAnalysis, TestFunction};
4use exspec_core::query_utils::{
5    collect_mock_class_names, count_captures, count_captures_within_context,
6    count_duplicate_literals, extract_suppression_from_previous_line, has_any_match,
7};
8use streaming_iterator::StreamingIterator;
9use tree_sitter::{Node, Parser, Query, QueryCursor};
10
11const TEST_FUNCTION_QUERY: &str = include_str!("../queries/test_function.scm");
12const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
13const MOCK_USAGE_QUERY: &str = include_str!("../queries/mock_usage.scm");
14const MOCK_ASSIGNMENT_QUERY: &str = include_str!("../queries/mock_assignment.scm");
15const PARAMETERIZED_QUERY: &str = include_str!("../queries/parameterized.scm");
16const IMPORT_PBT_QUERY: &str = include_str!("../queries/import_pbt.scm");
17const IMPORT_CONTRACT_QUERY: &str = include_str!("../queries/import_contract.scm");
18const HOW_NOT_WHAT_QUERY: &str = include_str!("../queries/how_not_what.scm");
19const PRIVATE_IN_ASSERTION_QUERY: &str = include_str!("../queries/private_in_assertion.scm");
20const ERROR_TEST_QUERY: &str = include_str!("../queries/error_test.scm");
21const RELATIONAL_ASSERTION_QUERY: &str = include_str!("../queries/relational_assertion.scm");
22const WAIT_AND_SEE_QUERY: &str = include_str!("../queries/wait_and_see.scm");
23
24fn ts_language() -> tree_sitter::Language {
25    tree_sitter_typescript::LANGUAGE_TSX.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29    lock.get_or_init(|| Query::new(&ts_language(), source).expect("invalid query"))
30}
31
32static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
33static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
34static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
36static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
37static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
39static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
40static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
41static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
42static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
43static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
44
45pub struct TypeScriptExtractor;
46
47impl TypeScriptExtractor {
48    pub fn new() -> Self {
49        Self
50    }
51
52    pub fn parser() -> Parser {
53        let mut parser = Parser::new();
54        let language = tree_sitter_typescript::LANGUAGE_TSX;
55        parser
56            .set_language(&language.into())
57            .expect("failed to load TypeScript grammar");
58        parser
59    }
60}
61
62impl Default for TypeScriptExtractor {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// Count fixture variables from enclosing describe() scopes.
69/// Walk up the AST from the test function, find describe callback bodies,
70/// and count `lexical_declaration` / `variable_declaration` direct children.
71/// Accumulates across all enclosing describes (handles nesting).
72fn count_enclosing_describe_fixtures(root: Node, test_start_byte: usize, source: &[u8]) -> usize {
73    let Some(start_node) = root.descendant_for_byte_range(test_start_byte, test_start_byte) else {
74        return 0;
75    };
76
77    let mut count = 0;
78    let mut current = start_node.parent();
79    while let Some(node) = current {
80        // Look for statement_block that is a describe callback body
81        if node.kind() == "statement_block" && is_describe_callback_body(node, source) {
82            // Count direct children that are variable declarations
83            let child_count = node.named_child_count();
84            for i in 0..child_count {
85                if let Some(child) = node.named_child(i) {
86                    let kind = child.kind();
87                    if kind == "lexical_declaration" || kind == "variable_declaration" {
88                        // Count the number of variable declarators in this declaration
89                        // e.g., `let a, b;` has 2 declarators
90                        let declarator_count = (0..child.named_child_count())
91                            .filter_map(|j| child.named_child(j))
92                            .filter(|c| c.kind() == "variable_declarator")
93                            .count();
94                        count += declarator_count;
95                    }
96                }
97            }
98        }
99        current = node.parent();
100    }
101
102    count
103}
104
105/// Check if a statement_block is the body of a describe() callback.
106/// Pattern: statement_block → arrow_function/function_expression → arguments → call_expression(describe)
107fn is_describe_callback_body(block: Node, source: &[u8]) -> bool {
108    let parent = match block.parent() {
109        Some(p) => p,
110        None => return false,
111    };
112    let kind = parent.kind();
113    if kind != "arrow_function" && kind != "function_expression" {
114        return false;
115    }
116    let args = match parent.parent() {
117        Some(p) if p.kind() == "arguments" => p,
118        _ => return false,
119    };
120    let call = match args.parent() {
121        Some(p) if p.kind() == "call_expression" => p,
122        _ => return false,
123    };
124    // Check if the function being called is "describe"
125    if let Some(func_node) = call.child_by_field_name("function") {
126        if let Ok(name) = func_node.utf8_text(source) {
127            return name == "describe" || name.starts_with("describe.");
128        }
129    }
130    false
131}
132
133fn extract_mock_class_name(var_name: &str) -> String {
134    // camelCase: strip "mock" prefix and lowercase first char
135    if let Some(stripped) = var_name.strip_prefix("mock") {
136        if !stripped.is_empty() && stripped.starts_with(|c: char| c.is_uppercase()) {
137            return stripped.to_string();
138        }
139    }
140    var_name.to_string()
141}
142
143struct TestMatch {
144    name: String,
145    fn_start_byte: usize,
146    fn_end_byte: usize,
147    fn_start_row: usize,
148    fn_end_row: usize,
149}
150
151fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
152    let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
153    let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
154    let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
155    let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
156    let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
157    let private_query = cached_query(
158        &PRIVATE_IN_ASSERTION_QUERY_CACHE,
159        PRIVATE_IN_ASSERTION_QUERY,
160    );
161    let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
162
163    let name_idx = test_query
164        .capture_index_for_name("name")
165        .expect("no @name capture");
166    let function_idx = test_query
167        .capture_index_for_name("function")
168        .expect("no @function capture");
169
170    let source_bytes = source.as_bytes();
171
172    let mut test_matches = Vec::new();
173    {
174        let mut cursor = QueryCursor::new();
175        let mut matches = cursor.matches(test_query, root, source_bytes);
176        while let Some(m) = matches.next() {
177            let name_capture = match m.captures.iter().find(|c| c.index == name_idx) {
178                Some(c) => c,
179                None => continue,
180            };
181            let name = match name_capture.node.utf8_text(source_bytes) {
182                Ok(s) => s.to_string(),
183                Err(_) => continue,
184            };
185
186            let fn_capture = match m.captures.iter().find(|c| c.index == function_idx) {
187                Some(c) => c,
188                None => continue,
189            };
190
191            test_matches.push(TestMatch {
192                name,
193                fn_start_byte: fn_capture.node.start_byte(),
194                fn_end_byte: fn_capture.node.end_byte(),
195                fn_start_row: fn_capture.node.start_position().row,
196                fn_end_row: fn_capture.node.end_position().row,
197            });
198        }
199    }
200
201    let mut functions = Vec::new();
202    for tm in &test_matches {
203        let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
204            Some(n) => n,
205            None => continue,
206        };
207
208        let line = tm.fn_start_row + 1;
209        let end_line = tm.fn_end_row + 1;
210        let line_count = end_line - line + 1;
211
212        let assertion_count = count_captures(assertion_query, "assertion", fn_node, source_bytes);
213        let mock_count = count_captures(mock_query, "mock", fn_node, source_bytes);
214        let mock_classes = collect_mock_class_names(
215            mock_assign_query,
216            fn_node,
217            source_bytes,
218            extract_mock_class_name,
219        );
220
221        let how_not_what_count =
222            count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
223
224        let private_in_assertion_count = count_captures_within_context(
225            assertion_query,
226            "assertion",
227            private_query,
228            "private_access",
229            fn_node,
230            source_bytes,
231        );
232
233        let fixture_count = count_enclosing_describe_fixtures(root, tm.fn_start_byte, source_bytes);
234
235        // T108: wait-and-see detection
236        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
237
238        // T106: duplicate literal count
239        let duplicate_literal_count = count_duplicate_literals(
240            assertion_query,
241            fn_node,
242            source_bytes,
243            &["number", "string"],
244        );
245
246        let suppressed_rules = extract_suppression_from_previous_line(source, tm.fn_start_row);
247
248        functions.push(TestFunction {
249            name: tm.name.clone(),
250            file: file_path.to_string(),
251            line,
252            end_line,
253            analysis: TestAnalysis {
254                assertion_count,
255                mock_count,
256                mock_classes,
257                line_count,
258                how_not_what_count: how_not_what_count + private_in_assertion_count,
259                fixture_count,
260                has_wait,
261                assertion_message_count: assertion_count, // T107 skipped for TypeScript: expect() has no msg arg
262                duplicate_literal_count,
263                suppressed_rules,
264            },
265        });
266    }
267
268    functions
269}
270
271impl LanguageExtractor for TypeScriptExtractor {
272    fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
273        let mut parser = Self::parser();
274        let tree = match parser.parse(source, None) {
275            Some(t) => t,
276            None => return Vec::new(),
277        };
278        extract_functions_from_tree(source, file_path, tree.root_node())
279    }
280
281    fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
282        let mut parser = Self::parser();
283        let tree = match parser.parse(source, None) {
284            Some(t) => t,
285            None => {
286                return FileAnalysis {
287                    file: file_path.to_string(),
288                    functions: Vec::new(),
289                    has_pbt_import: false,
290                    has_contract_import: false,
291                    has_error_test: false,
292                    has_relational_assertion: false,
293                    parameterized_count: 0,
294                };
295            }
296        };
297
298        let root = tree.root_node();
299        let source_bytes = source.as_bytes();
300
301        let functions = extract_functions_from_tree(source, file_path, root);
302
303        let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
304        let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
305
306        let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
307        let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
308
309        let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
310        let has_contract_import =
311            has_any_match(contract_query, "contract_import", root, source_bytes);
312
313        let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
314        let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
315
316        let relational_query = cached_query(
317            &RELATIONAL_ASSERTION_QUERY_CACHE,
318            RELATIONAL_ASSERTION_QUERY,
319        );
320        let has_relational_assertion =
321            has_any_match(relational_query, "relational", root, source_bytes);
322
323        FileAnalysis {
324            file: file_path.to_string(),
325            functions,
326            has_pbt_import,
327            has_contract_import,
328            has_error_test,
329            has_relational_assertion,
330            parameterized_count,
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn fixture(name: &str) -> String {
340        let path = format!(
341            "{}/tests/fixtures/typescript/{}",
342            env!("CARGO_MANIFEST_DIR").replace("/crates/lang-typescript", ""),
343            name
344        );
345        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
346    }
347
348    // --- Phase 1 preserved tests ---
349
350    #[test]
351    fn parse_typescript_source() {
352        let source = "const x: number = 42;\n";
353        let mut parser = TypeScriptExtractor::parser();
354        let tree = parser.parse(source, None).unwrap();
355        assert_eq!(tree.root_node().kind(), "program");
356    }
357
358    #[test]
359    fn typescript_extractor_implements_language_extractor() {
360        let extractor = TypeScriptExtractor::new();
361        let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
362    }
363
364    // --- Cycle 1: Test function extraction ---
365
366    #[test]
367    fn extract_single_test_function() {
368        let source = fixture("t001_pass.test.ts");
369        let extractor = TypeScriptExtractor::new();
370        let funcs = extractor.extract_test_functions(&source, "t001_pass.test.ts");
371        assert_eq!(funcs.len(), 1);
372        assert_eq!(funcs[0].name, "create user");
373        assert_eq!(funcs[0].line, 1);
374    }
375
376    #[test]
377    fn extract_multiple_tests_excludes_helpers_and_describe() {
378        let source = fixture("multiple_tests.test.ts");
379        let extractor = TypeScriptExtractor::new();
380        let funcs = extractor.extract_test_functions(&source, "multiple_tests.test.ts");
381        assert_eq!(funcs.len(), 3);
382        let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
383        assert_eq!(
384            names,
385            vec!["adds numbers", "subtracts numbers", "multiplies numbers"]
386        );
387    }
388
389    #[test]
390    fn line_count_calculation() {
391        let source = fixture("t001_pass.test.ts");
392        let extractor = TypeScriptExtractor::new();
393        let funcs = extractor.extract_test_functions(&source, "t001_pass.test.ts");
394        assert_eq!(
395            funcs[0].analysis.line_count,
396            funcs[0].end_line - funcs[0].line + 1
397        );
398    }
399
400    #[test]
401    fn violation_file_extracts_function() {
402        let source = fixture("t001_violation.test.ts");
403        let extractor = TypeScriptExtractor::new();
404        let funcs = extractor.extract_test_functions(&source, "t001_violation.test.ts");
405        assert_eq!(funcs.len(), 1);
406        assert_eq!(funcs[0].name, "create user");
407    }
408
409    // --- Cycle 2: Assertion detection ---
410
411    #[test]
412    fn assertion_count_zero_for_violation() {
413        let source = fixture("t001_violation.test.ts");
414        let extractor = TypeScriptExtractor::new();
415        let funcs = extractor.extract_test_functions(&source, "t001_violation.test.ts");
416        assert_eq!(funcs[0].analysis.assertion_count, 0);
417    }
418
419    #[test]
420    fn assertion_count_positive_for_pass() {
421        let source = fixture("t001_pass.test.ts");
422        let extractor = TypeScriptExtractor::new();
423        let funcs = extractor.extract_test_functions(&source, "t001_pass.test.ts");
424        assert!(funcs[0].analysis.assertion_count >= 1);
425    }
426
427    // --- TSX support (#53) ---
428
429    #[test]
430    fn tsx_file_detects_assertions() {
431        let source = fixture("t001_tsx_assertion.test.tsx");
432        let extractor = TypeScriptExtractor::new();
433        let funcs = extractor.extract_test_functions(&source, "t001_tsx_assertion.test.tsx");
434        assert_eq!(
435            funcs.len(),
436            2,
437            "should extract 2 test functions from TSX file"
438        );
439        for f in &funcs {
440            assert!(
441                f.analysis.assertion_count >= 1,
442                "test '{}' should have assertions detected in TSX file, got {}",
443                f.name,
444                f.analysis.assertion_count
445            );
446        }
447    }
448
449    // --- Cycle 2: Mock detection ---
450
451    #[test]
452    fn mock_count_for_violation() {
453        let source = fixture("t002_violation.test.ts");
454        let extractor = TypeScriptExtractor::new();
455        let funcs = extractor.extract_test_functions(&source, "t002_violation.test.ts");
456        assert_eq!(funcs.len(), 1);
457        assert_eq!(funcs[0].analysis.mock_count, 6);
458    }
459
460    #[test]
461    fn mock_count_for_pass() {
462        let source = fixture("t002_pass.test.ts");
463        let extractor = TypeScriptExtractor::new();
464        let funcs = extractor.extract_test_functions(&source, "t002_pass.test.ts");
465        assert_eq!(funcs.len(), 1);
466        assert_eq!(funcs[0].analysis.mock_count, 1);
467        assert_eq!(funcs[0].analysis.mock_classes, vec!["Db"]);
468    }
469
470    #[test]
471    fn mock_class_name_extraction() {
472        assert_eq!(extract_mock_class_name("mockDb"), "Db");
473        assert_eq!(
474            extract_mock_class_name("mockPaymentService"),
475            "PaymentService"
476        );
477        assert_eq!(extract_mock_class_name("myMock"), "myMock");
478    }
479
480    // --- Inline suppression ---
481
482    #[test]
483    fn suppressed_test_has_suppressed_rules() {
484        let source = fixture("suppressed.test.ts");
485        let extractor = TypeScriptExtractor::new();
486        let funcs = extractor.extract_test_functions(&source, "suppressed.test.ts");
487        assert_eq!(funcs.len(), 1);
488        assert_eq!(funcs[0].analysis.mock_count, 6);
489        assert!(funcs[0]
490            .analysis
491            .suppressed_rules
492            .iter()
493            .any(|r| r.0 == "T002"));
494    }
495
496    #[test]
497    fn non_suppressed_test_has_empty_suppressed_rules() {
498        let source = fixture("t002_violation.test.ts");
499        let extractor = TypeScriptExtractor::new();
500        let funcs = extractor.extract_test_functions(&source, "t002_violation.test.ts");
501        assert!(funcs[0].analysis.suppressed_rules.is_empty());
502    }
503
504    // --- Giant test ---
505
506    #[test]
507    fn giant_test_line_count() {
508        let source = fixture("t003_violation.test.ts");
509        let extractor = TypeScriptExtractor::new();
510        let funcs = extractor.extract_test_functions(&source, "t003_violation.test.ts");
511        assert_eq!(funcs.len(), 1);
512        assert!(funcs[0].analysis.line_count > 50);
513    }
514
515    #[test]
516    fn short_test_line_count() {
517        let source = fixture("t003_pass.test.ts");
518        let extractor = TypeScriptExtractor::new();
519        let funcs = extractor.extract_test_functions(&source, "t003_pass.test.ts");
520        assert_eq!(funcs.len(), 1);
521        assert!(funcs[0].analysis.line_count <= 50);
522    }
523
524    // --- File analysis: parameterized ---
525
526    #[test]
527    fn file_analysis_detects_parameterized() {
528        let source = fixture("t004_pass.test.ts");
529        let extractor = TypeScriptExtractor::new();
530        let fa = extractor.extract_file_analysis(&source, "t004_pass.test.ts");
531        assert!(
532            fa.parameterized_count >= 1,
533            "expected parameterized_count >= 1, got {}",
534            fa.parameterized_count
535        );
536    }
537
538    #[test]
539    fn file_analysis_no_parameterized() {
540        let source = fixture("t004_violation.test.ts");
541        let extractor = TypeScriptExtractor::new();
542        let fa = extractor.extract_file_analysis(&source, "t004_violation.test.ts");
543        assert_eq!(fa.parameterized_count, 0);
544    }
545
546    // --- File analysis: PBT import ---
547
548    #[test]
549    fn file_analysis_detects_pbt_import() {
550        let source = fixture("t005_pass.test.ts");
551        let extractor = TypeScriptExtractor::new();
552        let fa = extractor.extract_file_analysis(&source, "t005_pass.test.ts");
553        assert!(fa.has_pbt_import);
554    }
555
556    #[test]
557    fn file_analysis_no_pbt_import() {
558        let source = fixture("t005_violation.test.ts");
559        let extractor = TypeScriptExtractor::new();
560        let fa = extractor.extract_file_analysis(&source, "t005_violation.test.ts");
561        assert!(!fa.has_pbt_import);
562    }
563
564    // --- File analysis: contract import ---
565
566    #[test]
567    fn file_analysis_detects_contract_import() {
568        let source = fixture("t008_pass.test.ts");
569        let extractor = TypeScriptExtractor::new();
570        let fa = extractor.extract_file_analysis(&source, "t008_pass.test.ts");
571        assert!(fa.has_contract_import);
572    }
573
574    #[test]
575    fn file_analysis_no_contract_import() {
576        let source = fixture("t008_violation.test.ts");
577        let extractor = TypeScriptExtractor::new();
578        let fa = extractor.extract_file_analysis(&source, "t008_violation.test.ts");
579        assert!(!fa.has_contract_import);
580    }
581
582    // --- Suppression does not propagate from describe to inner tests ---
583
584    #[test]
585    fn suppression_on_describe_does_not_apply_to_inner_tests() {
586        let source = fixture("describe_suppression.test.ts");
587        let extractor = TypeScriptExtractor::new();
588        let funcs = extractor.extract_test_functions(&source, "describe_suppression.test.ts");
589        assert_eq!(funcs.len(), 2, "expected 2 test functions inside describe");
590        for f in &funcs {
591            assert!(
592                f.analysis.suppressed_rules.is_empty(),
593                "test '{}' should NOT have suppressed rules (suppression on describe does not propagate)",
594                f.name
595            );
596            assert_eq!(
597                f.analysis.assertion_count, 0,
598                "test '{}' should have 0 assertions (T001 violation expected)",
599                f.name
600            );
601        }
602    }
603
604    // --- File analysis preserves functions ---
605
606    #[test]
607    fn file_analysis_preserves_test_functions() {
608        let source = fixture("t001_pass.test.ts");
609        let extractor = TypeScriptExtractor::new();
610        let fa = extractor.extract_file_analysis(&source, "t001_pass.test.ts");
611        assert_eq!(fa.functions.len(), 1);
612        assert_eq!(fa.functions[0].name, "create user");
613    }
614
615    // --- T101: how-not-what ---
616
617    #[test]
618    fn how_not_what_count_for_violation() {
619        let source = fixture("t101_violation.test.ts");
620        let extractor = TypeScriptExtractor::new();
621        let funcs = extractor.extract_test_functions(&source, "t101_violation.test.ts");
622        assert_eq!(funcs.len(), 2);
623        assert!(
624            funcs[0].analysis.how_not_what_count > 0,
625            "expected how_not_what_count > 0 for first test, got {}",
626            funcs[0].analysis.how_not_what_count
627        );
628        assert!(
629            funcs[1].analysis.how_not_what_count > 0,
630            "expected how_not_what_count > 0 for second test, got {}",
631            funcs[1].analysis.how_not_what_count
632        );
633    }
634
635    #[test]
636    fn how_not_what_count_zero_for_pass() {
637        let source = fixture("t101_pass.test.ts");
638        let extractor = TypeScriptExtractor::new();
639        let funcs = extractor.extract_test_functions(&source, "t101_pass.test.ts");
640        assert_eq!(funcs.len(), 1);
641        assert_eq!(funcs[0].analysis.how_not_what_count, 0);
642    }
643
644    #[test]
645    fn how_not_what_coexists_with_assertions() {
646        let source = fixture("t101_violation.test.ts");
647        let extractor = TypeScriptExtractor::new();
648        let funcs = extractor.extract_test_functions(&source, "t101_violation.test.ts");
649        assert!(
650            funcs[0].analysis.assertion_count > 0,
651            "should also count as assertions"
652        );
653        assert!(
654            funcs[0].analysis.how_not_what_count > 0,
655            "should count as how-not-what"
656        );
657    }
658
659    // --- Query capture name verification (#14) ---
660
661    fn make_query(scm: &str) -> Query {
662        Query::new(&ts_language(), scm).unwrap()
663    }
664
665    #[test]
666    fn query_capture_names_test_function() {
667        let q = make_query(include_str!("../queries/test_function.scm"));
668        assert!(
669            q.capture_index_for_name("name").is_some(),
670            "test_function.scm must define @name capture"
671        );
672        assert!(
673            q.capture_index_for_name("function").is_some(),
674            "test_function.scm must define @function capture"
675        );
676    }
677
678    #[test]
679    fn query_capture_names_assertion() {
680        let q = make_query(include_str!("../queries/assertion.scm"));
681        assert!(
682            q.capture_index_for_name("assertion").is_some(),
683            "assertion.scm must define @assertion capture"
684        );
685    }
686
687    #[test]
688    fn query_capture_names_mock_usage() {
689        let q = make_query(include_str!("../queries/mock_usage.scm"));
690        assert!(
691            q.capture_index_for_name("mock").is_some(),
692            "mock_usage.scm must define @mock capture"
693        );
694    }
695
696    #[test]
697    fn query_capture_names_mock_assignment() {
698        let q = make_query(include_str!("../queries/mock_assignment.scm"));
699        assert!(
700            q.capture_index_for_name("var_name").is_some(),
701            "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
702        );
703    }
704
705    #[test]
706    fn query_capture_names_parameterized() {
707        let q = make_query(include_str!("../queries/parameterized.scm"));
708        assert!(
709            q.capture_index_for_name("parameterized").is_some(),
710            "parameterized.scm must define @parameterized capture"
711        );
712    }
713
714    #[test]
715    fn query_capture_names_import_pbt() {
716        let q = make_query(include_str!("../queries/import_pbt.scm"));
717        assert!(
718            q.capture_index_for_name("pbt_import").is_some(),
719            "import_pbt.scm must define @pbt_import capture"
720        );
721    }
722
723    #[test]
724    fn query_capture_names_import_contract() {
725        let q = make_query(include_str!("../queries/import_contract.scm"));
726        assert!(
727            q.capture_index_for_name("contract_import").is_some(),
728            "import_contract.scm must define @contract_import capture"
729        );
730    }
731
732    #[test]
733    fn query_capture_names_how_not_what() {
734        let q = make_query(include_str!("../queries/how_not_what.scm"));
735        assert!(
736            q.capture_index_for_name("how_pattern").is_some(),
737            "how_not_what.scm must define @how_pattern capture"
738        );
739    }
740
741    // --- T102: fixture-sprawl ---
742
743    #[test]
744    fn fixture_count_for_violation() {
745        let source = fixture("t102_violation.test.ts");
746        let extractor = TypeScriptExtractor::new();
747        let funcs = extractor.extract_test_functions(&source, "t102_violation.test.ts");
748        assert_eq!(funcs.len(), 1);
749        assert_eq!(
750            funcs[0].analysis.fixture_count, 6,
751            "expected 6 describe-level let declarations"
752        );
753    }
754
755    #[test]
756    fn fixture_count_for_pass() {
757        let source = fixture("t102_pass.test.ts");
758        let extractor = TypeScriptExtractor::new();
759        let funcs = extractor.extract_test_functions(&source, "t102_pass.test.ts");
760        assert_eq!(funcs.len(), 1);
761        assert_eq!(
762            funcs[0].analysis.fixture_count, 2,
763            "expected 2 describe-level let declarations"
764        );
765    }
766
767    #[test]
768    fn fixture_count_nested_describe() {
769        let source = fixture("t102_nested.test.ts");
770        let extractor = TypeScriptExtractor::new();
771        let funcs = extractor.extract_test_functions(&source, "t102_nested.test.ts");
772        assert_eq!(funcs.len(), 2);
773        // Inner test sees outer (3) + inner (3) = 6
774        let inner = funcs
775            .iter()
776            .find(|f| f.name == "test in nested describe inherits all fixtures")
777            .unwrap();
778        assert_eq!(
779            inner.analysis.fixture_count, 6,
780            "inner test should see outer + inner fixtures"
781        );
782        // Outer test sees only outer (3)
783        let outer = funcs
784            .iter()
785            .find(|f| f.name == "test in outer describe only sees outer fixtures")
786            .unwrap();
787        assert_eq!(
788            outer.analysis.fixture_count, 3,
789            "outer test should see only outer fixtures"
790        );
791    }
792
793    #[test]
794    fn fixture_count_describe_each() {
795        let source = fixture("t102_describe_each.test.ts");
796        let extractor = TypeScriptExtractor::new();
797        let funcs = extractor.extract_test_functions(&source, "t102_describe_each.test.ts");
798        assert_eq!(funcs.len(), 1);
799        assert_eq!(
800            funcs[0].analysis.fixture_count, 2,
801            "describe.each should be recognized as describe scope"
802        );
803    }
804
805    #[test]
806    fn fixture_count_top_level_test_zero() {
807        // A test outside describe should have 0 fixtures
808        let source = "it('standalone test', () => { expect(1).toBe(1); });";
809        let extractor = TypeScriptExtractor::new();
810        let funcs = extractor.extract_test_functions(source, "top_level.test.ts");
811        assert_eq!(funcs.len(), 1);
812        assert_eq!(
813            funcs[0].analysis.fixture_count, 0,
814            "top-level test should have 0 fixtures"
815        );
816    }
817
818    // --- T101: private attribute access in assertions (#13) ---
819
820    #[test]
821    fn private_dot_notation_detected() {
822        let source = fixture("t101_private_violation.test.ts");
823        let extractor = TypeScriptExtractor::new();
824        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.test.ts");
825        // "checks internal count via dot notation" has expect(service._count) and expect(service._processed)
826        let func = funcs
827            .iter()
828            .find(|f| f.name == "checks internal count via dot notation")
829            .unwrap();
830        assert!(
831            func.analysis.how_not_what_count >= 2,
832            "expected >= 2 private access in assertions (dot), got {}",
833            func.analysis.how_not_what_count
834        );
835    }
836
837    #[test]
838    fn private_bracket_notation_detected() {
839        let source = fixture("t101_private_violation.test.ts");
840        let extractor = TypeScriptExtractor::new();
841        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.test.ts");
842        // "checks internal via bracket notation" has expect(service['_count']) and expect(service['_processed'])
843        let func = funcs
844            .iter()
845            .find(|f| f.name == "checks internal via bracket notation")
846            .unwrap();
847        assert!(
848            func.analysis.how_not_what_count >= 2,
849            "expected >= 2 private access in assertions (bracket), got {}",
850            func.analysis.how_not_what_count
851        );
852    }
853
854    #[test]
855    fn private_outside_expect_not_counted() {
856        let source = fixture("t101_private_violation.test.ts");
857        let extractor = TypeScriptExtractor::new();
858        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.test.ts");
859        // "private outside expect not counted": service._internal is outside expect()
860        let func = funcs
861            .iter()
862            .find(|f| f.name == "private outside expect not counted")
863            .unwrap();
864        assert_eq!(
865            func.analysis.how_not_what_count, 0,
866            "private access outside expect should not count"
867        );
868    }
869
870    #[test]
871    fn private_adds_to_how_not_what() {
872        let source = fixture("t101_private_violation.test.ts");
873        let extractor = TypeScriptExtractor::new();
874        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.test.ts");
875        // "mixed private and mock verification" has toHaveBeenCalledWith (mock) + expect(service._lastCreated) (private)
876        let func = funcs
877            .iter()
878            .find(|f| f.name == "mixed private and mock verification")
879            .unwrap();
880        assert!(
881            func.analysis.how_not_what_count >= 2,
882            "expected mock (1) + private (1) = >= 2, got {}",
883            func.analysis.how_not_what_count
884        );
885    }
886
887    #[test]
888    fn query_capture_names_private_in_assertion() {
889        let q = make_query(include_str!("../queries/private_in_assertion.scm"));
890        assert!(
891            q.capture_index_for_name("private_access").is_some(),
892            "private_in_assertion.scm must define @private_access capture"
893        );
894    }
895
896    // --- T103: missing-error-test ---
897
898    #[test]
899    fn error_test_to_throw() {
900        let source = fixture("t103_pass_toThrow.test.ts");
901        let extractor = TypeScriptExtractor::new();
902        let fa = extractor.extract_file_analysis(&source, "t103_pass_toThrow.test.ts");
903        assert!(fa.has_error_test, ".toThrow() should set has_error_test");
904    }
905
906    #[test]
907    fn error_test_to_throw_error() {
908        let source = fixture("t103_pass_toThrowError.test.ts");
909        let extractor = TypeScriptExtractor::new();
910        let fa = extractor.extract_file_analysis(&source, "t103_pass_toThrowError.test.ts");
911        assert!(
912            fa.has_error_test,
913            ".toThrowError() should set has_error_test"
914        );
915    }
916
917    #[test]
918    fn error_test_rejects() {
919        let source = fixture("t103_pass_rejects.test.ts");
920        let extractor = TypeScriptExtractor::new();
921        let fa = extractor.extract_file_analysis(&source, "t103_pass_rejects.test.ts");
922        assert!(fa.has_error_test, ".rejects should set has_error_test");
923    }
924
925    #[test]
926    fn error_test_false_positive_rejects_property() {
927        let source = fixture("t103_false_positive_rejects_property.test.ts");
928        let extractor = TypeScriptExtractor::new();
929        let fa = extractor
930            .extract_file_analysis(&source, "t103_false_positive_rejects_property.test.ts");
931        assert!(
932            !fa.has_error_test,
933            "service.rejects should NOT set has_error_test"
934        );
935    }
936
937    #[test]
938    fn error_test_no_patterns() {
939        let source = fixture("t103_violation.test.ts");
940        let extractor = TypeScriptExtractor::new();
941        let fa = extractor.extract_file_analysis(&source, "t103_violation.test.ts");
942        assert!(
943            !fa.has_error_test,
944            "no error patterns should set has_error_test=false"
945        );
946    }
947
948    #[test]
949    fn query_capture_names_error_test() {
950        let q = make_query(include_str!("../queries/error_test.scm"));
951        assert!(
952            q.capture_index_for_name("error_test").is_some(),
953            "error_test.scm must define @error_test capture"
954        );
955    }
956
957    // --- T105: deterministic-no-metamorphic ---
958
959    #[test]
960    fn relational_assertion_violation() {
961        let source = fixture("t105_violation.test.ts");
962        let extractor = TypeScriptExtractor::new();
963        let fa = extractor.extract_file_analysis(&source, "t105_violation.test.ts");
964        assert!(
965            !fa.has_relational_assertion,
966            "all toBe/toEqual file should not have relational"
967        );
968    }
969
970    #[test]
971    fn relational_assertion_pass_greater_than() {
972        let source = fixture("t105_pass_relational.test.ts");
973        let extractor = TypeScriptExtractor::new();
974        let fa = extractor.extract_file_analysis(&source, "t105_pass_relational.test.ts");
975        assert!(
976            fa.has_relational_assertion,
977            "toBeGreaterThan should set has_relational_assertion"
978        );
979    }
980
981    #[test]
982    fn relational_assertion_pass_truthy() {
983        let source = fixture("t105_pass_truthy.test.ts");
984        let extractor = TypeScriptExtractor::new();
985        let fa = extractor.extract_file_analysis(&source, "t105_pass_truthy.test.ts");
986        assert!(
987            fa.has_relational_assertion,
988            "toBeTruthy should set has_relational_assertion"
989        );
990    }
991
992    #[test]
993    fn query_capture_names_relational_assertion() {
994        let q = make_query(include_str!("../queries/relational_assertion.scm"));
995        assert!(
996            q.capture_index_for_name("relational").is_some(),
997            "relational_assertion.scm must define @relational capture"
998        );
999    }
1000
1001    // --- T108: wait-and-see ---
1002
1003    #[test]
1004    fn wait_and_see_violation_sleep() {
1005        let source = fixture("t108_violation_sleep.test.ts");
1006        let extractor = TypeScriptExtractor::new();
1007        let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.test.ts");
1008        assert!(!funcs.is_empty());
1009        for func in &funcs {
1010            assert!(
1011                func.analysis.has_wait,
1012                "test '{}' should have has_wait=true",
1013                func.name
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn wait_and_see_pass_no_sleep() {
1020        let source = fixture("t108_pass_no_sleep.test.ts");
1021        let extractor = TypeScriptExtractor::new();
1022        let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.test.ts");
1023        assert_eq!(funcs.len(), 1);
1024        assert!(
1025            !funcs[0].analysis.has_wait,
1026            "test without sleep should have has_wait=false"
1027        );
1028    }
1029
1030    #[test]
1031    fn query_capture_names_wait_and_see() {
1032        let q = make_query(include_str!("../queries/wait_and_see.scm"));
1033        assert!(
1034            q.capture_index_for_name("wait").is_some(),
1035            "wait_and_see.scm must define @wait capture"
1036        );
1037    }
1038
1039    // --- T109: undescriptive-test-name ---
1040
1041    #[test]
1042    fn t109_violation_names_detected() {
1043        let source = fixture("t109_violation.test.ts");
1044        let extractor = TypeScriptExtractor::new();
1045        let funcs = extractor.extract_test_functions(&source, "t109_violation.test.ts");
1046        assert!(!funcs.is_empty());
1047        for func in &funcs {
1048            assert!(
1049                exspec_core::rules::is_undescriptive_test_name(&func.name),
1050                "test '{}' should be undescriptive",
1051                func.name
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn t109_pass_descriptive_names() {
1058        let source = fixture("t109_pass.test.ts");
1059        let extractor = TypeScriptExtractor::new();
1060        let funcs = extractor.extract_test_functions(&source, "t109_pass.test.ts");
1061        assert!(!funcs.is_empty());
1062        for func in &funcs {
1063            assert!(
1064                !exspec_core::rules::is_undescriptive_test_name(&func.name),
1065                "test '{}' should be descriptive",
1066                func.name
1067            );
1068        }
1069    }
1070
1071    // TC-09: CJK fixture → all names should be descriptive (no T109 FP)
1072    #[test]
1073    fn t109_cjk_pass_descriptive_names() {
1074        let source = fixture("t109_cjk_pass.test.ts");
1075        let extractor = TypeScriptExtractor::new();
1076        let funcs = extractor.extract_test_functions(&source, "t109_cjk_pass.test.ts");
1077        assert!(!funcs.is_empty());
1078        for func in &funcs {
1079            assert!(
1080                !exspec_core::rules::is_undescriptive_test_name(&func.name),
1081                "CJK test '{}' should be descriptive",
1082                func.name
1083            );
1084        }
1085    }
1086
1087    // --- T106: duplicate-literal-assertion ---
1088
1089    #[test]
1090    fn t106_violation_duplicate_literal() {
1091        let source = fixture("t106_violation.test.ts");
1092        let extractor = TypeScriptExtractor::new();
1093        let funcs = extractor.extract_test_functions(&source, "t106_violation.test.ts");
1094        assert_eq!(funcs.len(), 1);
1095        assert!(
1096            funcs[0].analysis.duplicate_literal_count >= 3,
1097            "42 appears 3 times, should be >= 3: got {}",
1098            funcs[0].analysis.duplicate_literal_count
1099        );
1100    }
1101
1102    #[test]
1103    fn t106_pass_no_duplicates() {
1104        let source = fixture("t106_pass_no_duplicates.test.ts");
1105        let extractor = TypeScriptExtractor::new();
1106        let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.test.ts");
1107        assert_eq!(funcs.len(), 1);
1108        assert!(
1109            funcs[0].analysis.duplicate_literal_count < 3,
1110            "each literal appears once: got {}",
1111            funcs[0].analysis.duplicate_literal_count
1112        );
1113    }
1114
1115    // --- T001 FP fix: rejects chain + expectTypeOf (#25) ---
1116
1117    #[test]
1118    fn t001_expect_to_throw_already_covered() {
1119        // TC-04: expect(fn).toThrow() -> already matched
1120        let source = "import { it, expect } from 'vitest';\nit('throws', () => { expect(() => fn()).toThrow(); });";
1121        let extractor = TypeScriptExtractor::new();
1122        let funcs = extractor.extract_test_functions(&source, "test_throw.test.ts");
1123        assert_eq!(funcs.len(), 1);
1124        assert!(
1125            funcs[0].analysis.assertion_count >= 1,
1126            "expect().toThrow() should already be covered, got {}",
1127            funcs[0].analysis.assertion_count
1128        );
1129    }
1130
1131    #[test]
1132    fn t001_rejects_to_throw_counts_as_assertion() {
1133        // TC-05: expect(promise).rejects.toThrow() -> T001 should NOT fire
1134        let source = fixture("t001_rejects_to_throw.test.ts");
1135        let extractor = TypeScriptExtractor::new();
1136        let funcs = extractor.extract_test_functions(&source, "t001_rejects_to_throw.test.ts");
1137        assert_eq!(funcs.len(), 1);
1138        assert!(
1139            funcs[0].analysis.assertion_count >= 1,
1140            "expect().rejects.toThrow() should count as assertion, got {}",
1141            funcs[0].analysis.assertion_count
1142        );
1143    }
1144
1145    #[test]
1146    fn t001_expect_type_of_counts_as_assertion() {
1147        // TC-06: expectTypeOf() -> T001 should NOT fire
1148        let source = fixture("t001_expect_type_of.test.ts");
1149        let extractor = TypeScriptExtractor::new();
1150        let funcs = extractor.extract_test_functions(&source, "t001_expect_type_of.test.ts");
1151        assert_eq!(funcs.len(), 1);
1152        assert!(
1153            funcs[0].analysis.assertion_count >= 1,
1154            "expectTypeOf() should count as assertion, got {}",
1155            funcs[0].analysis.assertion_count
1156        );
1157    }
1158
1159    // --- T001 FP fix: expect.soft/element/poll (#31) ---
1160
1161    #[test]
1162    fn t001_expect_soft_counts_as_assertion() {
1163        // TC-01: expect.soft(x).toBe(y) -> T001 should NOT fire
1164        let source = fixture("t001_expect_soft.test.ts");
1165        let extractor = TypeScriptExtractor::new();
1166        let funcs = extractor.extract_test_functions(&source, "t001_expect_soft.test.ts");
1167        assert_eq!(funcs.len(), 1);
1168        assert!(
1169            funcs[0].analysis.assertion_count >= 1,
1170            "expect.soft() should count as assertion, got {}",
1171            funcs[0].analysis.assertion_count
1172        );
1173    }
1174
1175    #[test]
1176    fn t001_expect_element_counts_as_assertion() {
1177        // TC-02: expect.element(locator).toHaveText(text) -> T001 should NOT fire
1178        let source = fixture("t001_expect_element.test.ts");
1179        let extractor = TypeScriptExtractor::new();
1180        let funcs = extractor.extract_test_functions(&source, "t001_expect_element.test.ts");
1181        assert_eq!(funcs.len(), 1);
1182        assert!(
1183            funcs[0].analysis.assertion_count >= 1,
1184            "expect.element() should count as assertion, got {}",
1185            funcs[0].analysis.assertion_count
1186        );
1187    }
1188
1189    #[test]
1190    fn t001_expect_poll_counts_as_assertion() {
1191        // TC-03: expect.poll(() => value).toBe(expected) -> T001 should NOT fire
1192        let source = fixture("t001_expect_poll.test.ts");
1193        let extractor = TypeScriptExtractor::new();
1194        let funcs = extractor.extract_test_functions(&source, "t001_expect_poll.test.ts");
1195        assert_eq!(funcs.len(), 1);
1196        assert!(
1197            funcs[0].analysis.assertion_count >= 1,
1198            "expect.poll() should count as assertion, got {}",
1199            funcs[0].analysis.assertion_count
1200        );
1201    }
1202
1203    // --- T001 FP fix: Chai property-style assertions (#32) ---
1204
1205    #[test]
1206    fn t001_chai_property_fixture_all_detected() {
1207        // All 6 test functions in chai property fixture should have assertions
1208        let source = fixture("t001_chai_property.test.ts");
1209        let extractor = TypeScriptExtractor::new();
1210        let funcs = extractor.extract_test_functions(&source, "t001_chai_property.test.ts");
1211        assert_eq!(funcs.len(), 6);
1212        for f in &funcs {
1213            assert!(
1214                f.analysis.assertion_count >= 1,
1215                "test '{}' should have assertion_count >= 1, got {}",
1216                f.name,
1217                f.analysis.assertion_count
1218            );
1219        }
1220    }
1221
1222    #[test]
1223    fn t001_chai_property_depth1_no_double_count() {
1224        // TC-02: expect(x).ok (depth 1) -> assertion_count == 1
1225        let source = r#"
1226import { expect } from 'chai';
1227describe('d', () => {
1228  it('t', () => {
1229    expect(x).ok;
1230  });
1231});
1232"#;
1233        let extractor = TypeScriptExtractor::new();
1234        let funcs = extractor.extract_test_functions(source, "test.ts");
1235        assert_eq!(funcs.len(), 1);
1236        assert_eq!(
1237            funcs[0].analysis.assertion_count, 1,
1238            "depth 1 property should count exactly 1, got {}",
1239            funcs[0].analysis.assertion_count
1240        );
1241    }
1242
1243    #[test]
1244    fn t001_chai_property_depth3_no_double_count() {
1245        // TC-03: expect(x).to.be.true (depth 3) -> assertion_count == 1
1246        let source = r#"
1247import { expect } from 'chai';
1248describe('d', () => {
1249  it('t', () => {
1250    expect(x).to.be.true;
1251  });
1252});
1253"#;
1254        let extractor = TypeScriptExtractor::new();
1255        let funcs = extractor.extract_test_functions(source, "test.ts");
1256        assert_eq!(funcs.len(), 1);
1257        assert_eq!(
1258            funcs[0].analysis.assertion_count, 1,
1259            "depth 3 property should count exactly 1, got {}",
1260            funcs[0].analysis.assertion_count
1261        );
1262    }
1263
1264    #[test]
1265    fn t001_chai_property_depth4_no_double_count() {
1266        // TC-04: expect(spy).to.have.been.calledOnce (depth 4) -> assertion_count == 1
1267        let source = r#"
1268import { expect } from 'chai';
1269describe('d', () => {
1270  it('t', () => {
1271    expect(spy).to.have.been.calledOnce;
1272  });
1273});
1274"#;
1275        let extractor = TypeScriptExtractor::new();
1276        let funcs = extractor.extract_test_functions(source, "test.ts");
1277        assert_eq!(funcs.len(), 1);
1278        assert_eq!(
1279            funcs[0].analysis.assertion_count, 1,
1280            "depth 4 property should count exactly 1, got {}",
1281            funcs[0].analysis.assertion_count
1282        );
1283    }
1284
1285    #[test]
1286    fn t001_chai_property_intermediate_not_counted() {
1287        // TC-05: expect(x).to; as bare expression_statement -> assertion_count == 0
1288        // .to is NOT in the terminal allowlist, so the allowlist filter rejects it.
1289        let source = r#"
1290import { expect } from 'chai';
1291describe('d', () => {
1292  it('t', () => {
1293    expect(x).to;
1294  });
1295});
1296"#;
1297        let extractor = TypeScriptExtractor::new();
1298        let funcs = extractor.extract_test_functions(source, "test.ts");
1299        assert_eq!(funcs.len(), 1);
1300        assert_eq!(
1301            funcs[0].analysis.assertion_count, 0,
1302            "intermediate property .to should NOT count as assertion, got {}",
1303            funcs[0].analysis.assertion_count
1304        );
1305    }
1306
1307    // --- T001 FP fix: expect modifier chains .not/.resolves (#37) ---
1308
1309    #[test]
1310    fn t001_not_modifier_all_detected() {
1311        // TC-01..03: expect(x).not.toBe/toEqual/toContain -> assertion_count == 1 each
1312        let source = fixture("t001_not_modifier.test.ts");
1313        let extractor = TypeScriptExtractor::new();
1314        let funcs = extractor.extract_test_functions(&source, "t001_not_modifier.test.ts");
1315        assert_eq!(funcs.len(), 3);
1316        for f in &funcs {
1317            assert_eq!(
1318                f.analysis.assertion_count, 1,
1319                "test '{}' with .not modifier should have assertion_count == 1, got {}",
1320                f.name, f.analysis.assertion_count
1321            );
1322        }
1323    }
1324
1325    #[test]
1326    fn t001_resolves_rejects_chain_all_detected() {
1327        // TC-04..06: resolves.toBe, resolves.not.toThrow, rejects.not.toThrow -> assertion_count == 1 each
1328        let source = fixture("t001_resolves_rejects_chain.test.ts");
1329        let extractor = TypeScriptExtractor::new();
1330        let funcs =
1331            extractor.extract_test_functions(&source, "t001_resolves_rejects_chain.test.ts");
1332        assert_eq!(funcs.len(), 3);
1333        for f in &funcs {
1334            assert_eq!(
1335                f.analysis.assertion_count, 1,
1336                "test '{}' with modifier chain should have assertion_count == 1, got {}",
1337                f.name, f.analysis.assertion_count
1338            );
1339        }
1340    }
1341
1342    // --- T001 FP fix: Chai method-call chain assertions (#40) ---
1343
1344    #[test]
1345    fn t001_chai_method_call_fixture_all_detected() {
1346        // TC-01~07 should have assertions, TC-08~09 should not, TC-10~18 should have assertions
1347        let source = fixture("t001_chai_method_call.test.ts");
1348        let extractor = TypeScriptExtractor::new();
1349        let funcs = extractor.extract_test_functions(&source, "t001_chai_method_call.test.ts");
1350        assert_eq!(funcs.len(), 18);
1351
1352        // TC-01: expect(x).to.equal(y) — depth 2, exactly 1 (no double-count)
1353        assert_eq!(
1354            funcs[0].analysis.assertion_count, 1,
1355            "TC-01 to.equal should count exactly 1, got {}",
1356            funcs[0].analysis.assertion_count
1357        );
1358
1359        // TC-02: expect(x).to.be.a('string') — depth 3
1360        assert!(
1361            funcs[1].analysis.assertion_count >= 1,
1362            "TC-02 to.be.a should have assertion_count >= 1, got {}",
1363            funcs[1].analysis.assertion_count
1364        );
1365
1366        // TC-03: expect(spy).to.have.callCount(3) — depth 3
1367        assert!(
1368            funcs[2].analysis.assertion_count >= 1,
1369            "TC-03 to.have.callCount should have assertion_count >= 1, got {}",
1370            funcs[2].analysis.assertion_count
1371        );
1372
1373        // TC-04: expect(spy).to.have.been.calledWith(arg) — depth 4
1374        assert!(
1375            funcs[3].analysis.assertion_count >= 1,
1376            "TC-04 to.have.been.calledWith should have assertion_count >= 1, got {}",
1377            funcs[3].analysis.assertion_count
1378        );
1379
1380        // TC-05: expect(spy).to.not.have.been.calledWith(arg) — depth 5
1381        assert!(
1382            funcs[4].analysis.assertion_count >= 1,
1383            "TC-05 to.not.have.been.calledWith should have assertion_count >= 1, got {}",
1384            funcs[4].analysis.assertion_count
1385        );
1386
1387        // TC-06: mixed property (.to.be.true) + method (.to.equal) — both counted
1388        assert!(
1389            funcs[5].analysis.assertion_count >= 2,
1390            "TC-06 mixed property+method should have assertion_count >= 2, got {}",
1391            funcs[5].analysis.assertion_count
1392        );
1393
1394        // TC-07: multiple method assertions
1395        assert!(
1396            funcs[6].analysis.assertion_count >= 2,
1397            "TC-07 multiple methods should have assertion_count >= 2, got {}",
1398            funcs[6].analysis.assertion_count
1399        );
1400
1401        // TC-08: no assertion (regression guard)
1402        assert_eq!(
1403            funcs[7].analysis.assertion_count, 0,
1404            "TC-08 no assertion should have assertion_count == 0, got {}",
1405            funcs[7].analysis.assertion_count
1406        );
1407
1408        // TC-09: expect(x).to.customHelper() — NOT in terminal vocabulary
1409        assert_eq!(
1410            funcs[8].analysis.assertion_count, 0,
1411            "TC-09 customHelper should have assertion_count == 0, got {}",
1412            funcs[8].analysis.assertion_count
1413        );
1414
1415        // TC-10: expect(x).not.to.equal(y) — depth 3, not at position 1
1416        assert!(
1417            funcs[9].analysis.assertion_count >= 1,
1418            "TC-10 not.to.equal should have assertion_count >= 1, got {}",
1419            funcs[9].analysis.assertion_count
1420        );
1421
1422        // TC-11 (regression): expect(x).to.equal(y) — exact count 1
1423        assert_eq!(
1424            funcs[10].analysis.assertion_count, 1,
1425            "TC-11 to.equal regression should count exactly 1, got {}",
1426            funcs[10].analysis.assertion_count
1427        );
1428
1429        // TC-12: expect(obj).to.have.deep.equal({a:1}) — deep intermediate
1430        assert!(
1431            funcs[11].analysis.assertion_count >= 1,
1432            "TC-12 deep intermediate should have assertion_count >= 1, got {}",
1433            funcs[11].analysis.assertion_count
1434        );
1435
1436        // TC-13: expect(obj).to.have.nested.property('a.b') — nested intermediate
1437        assert!(
1438            funcs[12].analysis.assertion_count >= 1,
1439            "TC-13 nested intermediate should have assertion_count >= 1, got {}",
1440            funcs[12].analysis.assertion_count
1441        );
1442
1443        // TC-14: expect(obj).to.have.own.property('x') — own intermediate
1444        assert!(
1445            funcs[13].analysis.assertion_count >= 1,
1446            "TC-14 own intermediate should have assertion_count >= 1, got {}",
1447            funcs[13].analysis.assertion_count
1448        );
1449
1450        // TC-15: expect(arr).to.have.ordered.members([1,2]) — ordered intermediate
1451        assert!(
1452            funcs[14].analysis.assertion_count >= 1,
1453            "TC-15 ordered intermediate should have assertion_count >= 1, got {}",
1454            funcs[14].analysis.assertion_count
1455        );
1456
1457        // TC-16: expect(obj).to.have.any.keys('x') — any intermediate
1458        assert!(
1459            funcs[15].analysis.assertion_count >= 1,
1460            "TC-16 any intermediate should have assertion_count >= 1, got {}",
1461            funcs[15].analysis.assertion_count
1462        );
1463
1464        // TC-17: expect(obj).to.have.all.keys('x','y') — all intermediate
1465        assert!(
1466            funcs[16].analysis.assertion_count >= 1,
1467            "TC-17 all intermediate should have assertion_count >= 1, got {}",
1468            funcs[16].analysis.assertion_count
1469        );
1470
1471        // TC-18: expect(obj).itself.to.respondTo('bar') — itself intermediate
1472        assert!(
1473            funcs[17].analysis.assertion_count >= 1,
1474            "TC-18 itself intermediate should have assertion_count >= 1, got {}",
1475            funcs[17].analysis.assertion_count
1476        );
1477    }
1478
1479    #[test]
1480    fn t001_chai_method_call_depth2_no_double_count() {
1481        // expect(x).to.equal(y) should count exactly 1 (not double-counted with depth-1)
1482        let source = r#"
1483import { expect } from 'chai';
1484describe('d', () => {
1485  it('t', () => {
1486    expect(x).to.equal(y);
1487  });
1488});
1489"#;
1490        let extractor = TypeScriptExtractor::new();
1491        let funcs = extractor.extract_test_functions(source, "test.ts");
1492        assert_eq!(funcs.len(), 1);
1493        assert_eq!(
1494            funcs[0].analysis.assertion_count, 1,
1495            "depth 2 method-call should count exactly 1, got {}",
1496            funcs[0].analysis.assertion_count
1497        );
1498    }
1499
1500    #[test]
1501    fn t001_chai_deep_intermediate_no_double_count() {
1502        // expect(obj).to.have.deep.equal({a:1}) should count exactly 1
1503        let source = r#"
1504import { expect } from 'chai';
1505describe('d', () => {
1506  it('t', () => {
1507    expect(obj).to.have.deep.equal({a: 1});
1508  });
1509});
1510"#;
1511        let extractor = TypeScriptExtractor::new();
1512        let funcs = extractor.extract_test_functions(source, "test.ts");
1513        assert_eq!(funcs.len(), 1);
1514        assert_eq!(
1515            funcs[0].analysis.assertion_count, 1,
1516            "deep intermediate should count exactly 1, got {}",
1517            funcs[0].analysis.assertion_count
1518        );
1519    }
1520
1521    #[test]
1522    fn t001_expect_soft_chain_fixture() {
1523        // B1-B10: expect.soft/element/poll modifier chain tests
1524        let source = fixture("t001_expect_soft_chain.test.ts");
1525        let extractor = TypeScriptExtractor::new();
1526        let funcs = extractor.extract_test_functions(&source, "t001_expect_soft_chain.test.ts");
1527        assert_eq!(funcs.len(), 10);
1528
1529        // B1 (regression): expect.soft(x).toBe(y) — depth-2 existing
1530        assert!(
1531            funcs[0].analysis.assertion_count >= 1,
1532            "B1 expect.soft depth-2 should have assertion_count >= 1, got {}",
1533            funcs[0].analysis.assertion_count
1534        );
1535
1536        // B2: expect.soft(x).not.toBe(y) — depth-3
1537        assert!(
1538            funcs[1].analysis.assertion_count >= 1,
1539            "B2 expect.soft.not depth-3 should have assertion_count >= 1, got {}",
1540            funcs[1].analysis.assertion_count
1541        );
1542
1543        // B3: expect.soft(x).resolves.toBe(y) — depth-3
1544        assert!(
1545            funcs[2].analysis.assertion_count >= 1,
1546            "B3 expect.soft.resolves depth-3 should have assertion_count >= 1, got {}",
1547            funcs[2].analysis.assertion_count
1548        );
1549
1550        // B4: expect.soft(x).rejects.toThrow() — depth-3
1551        assert!(
1552            funcs[3].analysis.assertion_count >= 1,
1553            "B4 expect.soft.rejects depth-3 should have assertion_count >= 1, got {}",
1554            funcs[3].analysis.assertion_count
1555        );
1556
1557        // B5: expect.soft(x).resolves.not.toBe(y) — depth-4
1558        assert!(
1559            funcs[4].analysis.assertion_count >= 1,
1560            "B5 expect.soft.resolves.not depth-4 should have assertion_count >= 1, got {}",
1561            funcs[4].analysis.assertion_count
1562        );
1563
1564        // B6: expect.soft(x).rejects.not.toThrow(TypeError) — depth-4
1565        assert!(
1566            funcs[5].analysis.assertion_count >= 1,
1567            "B6 expect.soft.rejects.not depth-4 should have assertion_count >= 1, got {}",
1568            funcs[5].analysis.assertion_count
1569        );
1570
1571        // B7 (negative): expect.soft(x).resolves.customHelper() — NOT a toX terminal
1572        assert_eq!(
1573            funcs[6].analysis.assertion_count, 0,
1574            "B7 customHelper should have assertion_count == 0, got {}",
1575            funcs[6].analysis.assertion_count
1576        );
1577
1578        // B8 (negative): no assertions
1579        assert_eq!(
1580            funcs[7].analysis.assertion_count, 0,
1581            "B8 no assertion should have assertion_count == 0, got {}",
1582            funcs[7].analysis.assertion_count
1583        );
1584
1585        // B9: expect.element(loc).not.toHaveText('x') — depth-3
1586        assert!(
1587            funcs[8].analysis.assertion_count >= 1,
1588            "B9 expect.element.not depth-3 should have assertion_count >= 1, got {}",
1589            funcs[8].analysis.assertion_count
1590        );
1591
1592        // B10: expect.poll(fn).not.toBe(0) — depth-3
1593        assert!(
1594            funcs[9].analysis.assertion_count >= 1,
1595            "B10 expect.poll.not depth-3 should have assertion_count >= 1, got {}",
1596            funcs[9].analysis.assertion_count
1597        );
1598    }
1599
1600    // --- T001 FP fix: supertest .expect() method-call oracle (#47) ---
1601
1602    #[test]
1603    fn t001_supertest_expect_method_call() {
1604        let source = fixture("t001_supertest.test.ts");
1605        let extractor = TypeScriptExtractor::new();
1606        let funcs = extractor.extract_test_functions(&source, "t001_supertest.test.ts");
1607        assert_eq!(funcs.len(), 6);
1608
1609        // TC-01: single .expect(200) on chain — assertion_count == 1
1610        assert_eq!(
1611            funcs[0].analysis.assertion_count, 1,
1612            "TC-01 single .expect(200) should have assertion_count == 1, got {}",
1613            funcs[0].analysis.assertion_count
1614        );
1615
1616        // TC-02: two .expect() on same chain — assertion_count == 2
1617        assert_eq!(
1618            funcs[1].analysis.assertion_count, 2,
1619            "TC-02 two .expect() should have assertion_count == 2, got {}",
1620            funcs[1].analysis.assertion_count
1621        );
1622
1623        // TC-03: chain with .set() and two .expect() — assertion_count == 2
1624        assert_eq!(
1625            funcs[2].analysis.assertion_count, 2,
1626            "TC-03 .set() + two .expect() should have assertion_count == 2, got {}",
1627            funcs[2].analysis.assertion_count
1628        );
1629
1630        // TC-04: no assertion (plain request) — assertion_count == 0
1631        assert_eq!(
1632            funcs[3].analysis.assertion_count, 0,
1633            "TC-04 no assertion should have assertion_count == 0, got {}",
1634            funcs[3].analysis.assertion_count
1635        );
1636
1637        // TC-05: standalone expect(x).toBe(y) — assertion_count == 1 (no double-count)
1638        assert_eq!(
1639            funcs[4].analysis.assertion_count, 1,
1640            "TC-05 standalone expect should have assertion_count == 1, got {}",
1641            funcs[4].analysis.assertion_count
1642        );
1643
1644        // TC-06: non-supertest chain: someBuilder().expect('foo') — assertion_count == 1
1645        assert_eq!(
1646            funcs[5].analysis.assertion_count, 1,
1647            "TC-06 non-supertest builder .expect() should have assertion_count == 1, got {}",
1648            funcs[5].analysis.assertion_count
1649        );
1650    }
1651
1652    // --- T001 FP fix: Chai/Sinon vocabulary expansion (#48) ---
1653
1654    #[test]
1655    fn t001_chai_vocab_expansion_fixture_all_detected() {
1656        let source = fixture("t001_chai_vocab_expansion.test.ts");
1657        let extractor = TypeScriptExtractor::new();
1658        let funcs = extractor.extract_test_functions(&source, "t001_chai_vocab_expansion.test.ts");
1659        assert_eq!(funcs.len(), 18);
1660
1661        // TC-01 through TC-16: all should have assertion_count >= 1
1662        for (i, f) in funcs.iter().enumerate().take(16) {
1663            assert!(
1664                f.analysis.assertion_count >= 1,
1665                "TC-{:02} '{}' should have assertion_count >= 1, got {}",
1666                i + 1,
1667                f.name,
1668                f.analysis.assertion_count
1669            );
1670        }
1671
1672        // TC-17: sinon.stub() — NOT an assertion
1673        assert_eq!(
1674            funcs[16].analysis.assertion_count, 0,
1675            "TC-17 sinon.stub should have assertion_count == 0, got {}",
1676            funcs[16].analysis.assertion_count
1677        );
1678
1679        // TC-18: no assertion
1680        assert_eq!(
1681            funcs[17].analysis.assertion_count, 0,
1682            "TC-18 no assertion should have assertion_count == 0, got {}",
1683            funcs[17].analysis.assertion_count
1684        );
1685    }
1686
1687    // --- T001 FP fix: Chai property in arrow body (#49) ---
1688
1689    #[test]
1690    fn t001_chai_property_arrow_fixture_all_detected() {
1691        let source = fixture("t001_chai_property_arrow.test.ts");
1692        let extractor = TypeScriptExtractor::new();
1693        let funcs = extractor.extract_test_functions(&source, "t001_chai_property_arrow.test.ts");
1694        assert_eq!(funcs.len(), 8);
1695
1696        // TC-01 through TC-07: all should have assertion_count >= 1
1697        for (i, f) in funcs.iter().enumerate().take(7) {
1698            assert!(
1699                f.analysis.assertion_count >= 1,
1700                "TC-{:02} '{}' should have assertion_count >= 1, got {}",
1701                i + 1,
1702                f.name,
1703                f.analysis.assertion_count
1704            );
1705        }
1706
1707        // TC-08: negative — no assertion
1708        assert_eq!(
1709            funcs[7].analysis.assertion_count, 0,
1710            "TC-08 no assertion should have assertion_count == 0, got {}",
1711            funcs[7].analysis.assertion_count
1712        );
1713    }
1714
1715    #[test]
1716    fn t107_skipped_for_typescript() {
1717        // TypeScript expect() has no message argument, so T107 should never fire.
1718        // assertion_message_count must equal assertion_count to prevent T107 from triggering.
1719        let source = fixture("t107_pass.test.ts");
1720        let extractor = TypeScriptExtractor::new();
1721        let funcs = extractor.extract_test_functions(&source, "t107_pass.test.ts");
1722        assert_eq!(funcs.len(), 1);
1723        let analysis = &funcs[0].analysis;
1724        assert!(
1725            analysis.assertion_count >= 2,
1726            "fixture should have 2+ assertions: got {}",
1727            analysis.assertion_count
1728        );
1729        assert_eq!(
1730            analysis.assertion_message_count, analysis.assertion_count,
1731            "TS assertion_message_count should equal assertion_count to skip T107"
1732        );
1733    }
1734
1735    // --- TC-14: TS test with myAssert() + config -> T001 does NOT fire ---
1736    #[test]
1737    fn t001_custom_helper_with_config_no_violation() {
1738        use exspec_core::query_utils::apply_custom_assertion_fallback;
1739        use exspec_core::rules::{evaluate_rules, Config};
1740
1741        let source = fixture("t001_custom_helper.test.ts");
1742        let extractor = TypeScriptExtractor::new();
1743        let mut analysis = extractor.extract_file_analysis(&source, "t001_custom_helper.test.ts");
1744        let patterns = vec!["myAssert(".to_string()];
1745        apply_custom_assertion_fallback(&mut analysis, &source, &patterns);
1746
1747        let config = Config::default();
1748        let diags = evaluate_rules(&analysis.functions, &config);
1749        let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
1750        // "uses custom assertion" -> pass (custom pattern)
1751        // "uses standard expect" -> pass (standard detection)
1752        // "has no assertion" -> BLOCK
1753        assert_eq!(
1754            t001_diags.len(),
1755            1,
1756            "only 'has no assertion' test should trigger T001"
1757        );
1758    }
1759
1760    // --- T001 FP fix: expect.assertions / expect.unreachable / expectType (#39) ---
1761
1762    #[test]
1763    fn t001_expect_assertions_counts_as_assertion() {
1764        // TC-01: expect.assertions(1) -> T001 should NOT fire
1765        let source = fixture("t001_expect_assertions.test.ts");
1766        let extractor = TypeScriptExtractor::new();
1767        let funcs = extractor.extract_test_functions(&source, "t001_expect_assertions.test.ts");
1768        // 8 test functions in fixture
1769        assert_eq!(funcs.len(), 8);
1770
1771        // TC-01: expect.assertions(1) + expect(data).toBeDefined()
1772        assert!(
1773            funcs[0].analysis.assertion_count >= 1,
1774            "expect.assertions(N) should count as assertion, got {}",
1775            funcs[0].analysis.assertion_count
1776        );
1777
1778        // TC-02: expect.assertions(0) — single oracle, exact count catches double-counting
1779        assert_eq!(
1780            funcs[1].analysis.assertion_count, 1,
1781            "expect.assertions(0) should count as exactly 1 assertion, got {}",
1782            funcs[1].analysis.assertion_count
1783        );
1784
1785        // TC-03: expect.hasAssertions() + expect(data).toBeTruthy()
1786        assert!(
1787            funcs[2].analysis.assertion_count >= 1,
1788            "expect.hasAssertions() should count as assertion, got {}",
1789            funcs[2].analysis.assertion_count
1790        );
1791
1792        // TC-04: expect.unreachable() — single oracle, exact count catches double-counting
1793        assert_eq!(
1794            funcs[3].analysis.assertion_count, 1,
1795            "expect.unreachable() should count as exactly 1 assertion, got {}",
1796            funcs[3].analysis.assertion_count
1797        );
1798
1799        // TC-05: expectType<User>(user) — single oracle, exact count catches double-counting
1800        assert_eq!(
1801            funcs[4].analysis.assertion_count, 1,
1802            "expectType<T>(value) should count as exactly 1 assertion, got {}",
1803            funcs[4].analysis.assertion_count
1804        );
1805
1806        // TC-06: expect.assertions(2) + expect(a).toBe(1) + expect(b).toBe(2) -> 3+
1807        assert!(
1808            funcs[5].analysis.assertion_count >= 2,
1809            "mixed expect.assertions + expect().toBe() should count 2+, got {}",
1810            funcs[5].analysis.assertion_count
1811        );
1812
1813        // TC-07: expectType + expectTypeOf -> both counted
1814        assert!(
1815            funcs[6].analysis.assertion_count >= 2,
1816            "expectType + expectTypeOf should count 2+, got {}",
1817            funcs[6].analysis.assertion_count
1818        );
1819
1820        // TC-08: no assertions -> BLOCK (regression guard)
1821        assert_eq!(
1822            funcs[7].analysis.assertion_count, 0,
1823            "no-assertion test should have assertion_count == 0"
1824        );
1825    }
1826
1827    // --- T001 FP fix: Chai NestJS alias/property vocabulary expansion (#50) ---
1828
1829    #[test]
1830    fn t001_chai_nestjs_aliases_fixture() {
1831        let source = fixture("t001_chai_nestjs_aliases.test.ts");
1832        let extractor = TypeScriptExtractor::new();
1833        let funcs = extractor.extract_test_functions(&source, "t001_chai_nestjs_aliases.test.ts");
1834        assert_eq!(funcs.len(), 9);
1835
1836        // TC-01: instanceof method alias (depth-3)
1837        assert!(
1838            funcs[0].analysis.assertion_count >= 1,
1839            "TC-01 instanceof alias should have assertion_count >= 1, got {}",
1840            funcs[0].analysis.assertion_count
1841        );
1842
1843        // TC-02: throws method alias (depth-2)
1844        assert!(
1845            funcs[1].analysis.assertion_count >= 1,
1846            "TC-02 throws alias should have assertion_count >= 1, got {}",
1847            funcs[1].analysis.assertion_count
1848        );
1849
1850        // TC-03: contains method alias (depth-2)
1851        assert!(
1852            funcs[2].analysis.assertion_count >= 1,
1853            "TC-03 contains alias should have assertion_count >= 1, got {}",
1854            funcs[2].analysis.assertion_count
1855        );
1856
1857        // TC-04: equals method alias (depth-2)
1858        assert!(
1859            funcs[3].analysis.assertion_count >= 1,
1860            "TC-04 equals alias should have assertion_count >= 1, got {}",
1861            funcs[3].analysis.assertion_count
1862        );
1863
1864        // TC-05: ownProperty method (depth-3)
1865        assert!(
1866            funcs[4].analysis.assertion_count >= 1,
1867            "TC-05 ownProperty should have assertion_count >= 1, got {}",
1868            funcs[4].analysis.assertion_count
1869        );
1870
1871        // TC-06: length method alias (depth-3)
1872        assert!(
1873            funcs[5].analysis.assertion_count >= 1,
1874            "TC-06 length alias should have assertion_count >= 1, got {}",
1875            funcs[5].analysis.assertion_count
1876        );
1877
1878        // TC-07: throw property terminal (depth-3, no parens)
1879        assert!(
1880            funcs[6].analysis.assertion_count >= 1,
1881            "TC-07 throw property should have assertion_count >= 1, got {}",
1882            funcs[6].analysis.assertion_count
1883        );
1884
1885        // TC-08: and intermediate + instanceof alias (deep chain)
1886        assert!(
1887            funcs[7].analysis.assertion_count >= 1,
1888            "TC-08 and+instanceof deep chain should have assertion_count >= 1, got {}",
1889            funcs[7].analysis.assertion_count
1890        );
1891
1892        // TC-09: negative — truly assertion-free (TP control)
1893        assert_eq!(
1894            funcs[8].analysis.assertion_count, 0,
1895            "TC-09 no assertion should have assertion_count == 0, got {}",
1896            funcs[8].analysis.assertion_count
1897        );
1898    }
1899
1900    // --- T001 FP fix: Sinon mock .verify() method-call oracle (#51) ---
1901
1902    #[test]
1903    fn t001_sinon_verify_fixture_all_detected() {
1904        let source = fixture("t001_sinon_verify.test.ts");
1905        let extractor = TypeScriptExtractor::new();
1906        let funcs = extractor.extract_test_functions(&source, "t001_sinon_verify.test.ts");
1907        assert_eq!(funcs.len(), 7);
1908
1909        // TC-01 through TC-05: all should have assertion_count >= 1
1910        for (i, f) in funcs.iter().enumerate().take(5) {
1911            assert!(
1912                f.analysis.assertion_count >= 1,
1913                "TC-{:02} '{}' should have assertion_count >= 1, got {}",
1914                i + 1,
1915                f.name,
1916                f.analysis.assertion_count
1917            );
1918        }
1919
1920        // TC-05 specifically: verify + expect — should have assertion_count >= 2
1921        assert!(
1922            funcs[4].analysis.assertion_count >= 2,
1923            "TC-05 verify + expect should have assertion_count >= 2, got {}",
1924            funcs[4].analysis.assertion_count
1925        );
1926
1927        // TC-06: mock.restore() — NOT an assertion
1928        assert_eq!(
1929            funcs[5].analysis.assertion_count, 0,
1930            "TC-06 mock.restore should have assertion_count == 0, got {}",
1931            funcs[5].analysis.assertion_count
1932        );
1933
1934        // TC-07: no assertion
1935        assert_eq!(
1936            funcs[6].analysis.assertion_count, 0,
1937            "TC-07 no assertion should have assertion_count == 0, got {}",
1938            funcs[6].analysis.assertion_count
1939        );
1940    }
1941}