Skip to main content

exspec_lang_typescript/
lib.rs

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