Skip to main content

pytest_language_server/providers/
completion.rs

1//! Completion provider for pytest fixtures.
2
3use super::Backend;
4use crate::fixtures::types::FixtureScope;
5use crate::fixtures::CompletionContext;
6use crate::fixtures::FixtureDefinition;
7use std::path::PathBuf;
8use tower_lsp_server::jsonrpc::Result;
9use tower_lsp_server::ls_types::*;
10use tracing::info;
11
12/// Parameter names that should never appear in fixture completions, they should be handled by another lsp.
13const EXCLUDED_PARAM_NAMES: &[&str] = &["self", "cls"];
14
15/// Per-request completion options bundling fixture scope, self-exclusion name, and
16/// trigger-character insert prefix. Passed through the completion pipeline to avoid
17/// threading many individual parameters.
18pub(crate) struct CompletionOpts<'a> {
19    /// When editing a fixture, its scope constrains which other fixtures are eligible.
20    /// `None` for test functions (all scopes allowed).
21    fixture_scope: Option<FixtureScope>,
22    /// Name of the fixture currently being edited, so it is excluded from its own
23    /// suggestions. `None` when editing a test function.
24    current_fixture_name: Option<&'a str>,
25    /// Prefix prepended to each completion's insert text. Set to `" "` when the
26    /// completion was triggered by a comma, otherwise `""`.
27    insert_prefix: &'a str,
28}
29
30/// Check whether a fixture should be excluded from completions based on scope rules.
31/// A fixture with a broader scope cannot depend on a fixture with a narrower scope.
32fn should_exclude_fixture(
33    fixture: &FixtureDefinition,
34    current_scope: Option<FixtureScope>,
35) -> bool {
36    // If current function is a test (None scope), allow everything
37    let Some(scope) = current_scope else {
38        return false;
39    };
40    // FixtureScope ordering: Function(0) < Class(1) < Module(2) < Package(3) < Session(4)
41    // Exclude candidates whose scope is narrower than the current fixture's scope
42    fixture.scope < scope
43}
44
45/// Check whether a fixture should be excluded based on common rules
46/// (excluded param names, already-declared params, scope compatibility,
47/// and self-exclusion).
48fn is_fixture_excluded(
49    fixture: &FixtureDefinition,
50    declared_params: Option<&[String]>,
51    opts: &CompletionOpts<'_>,
52) -> bool {
53    // Skip special parameter names
54    if EXCLUDED_PARAM_NAMES.contains(&fixture.name.as_str()) {
55        return true;
56    }
57
58    // Skip the fixture currently being edited (don't suggest yourself)
59    if let Some(name) = opts.current_fixture_name {
60        if fixture.name == name {
61            return true;
62        }
63    }
64
65    // Skip fixtures that are already declared as parameters
66    if let Some(params) = declared_params {
67        if params.contains(&fixture.name) {
68            return true;
69        }
70    }
71
72    // Skip fixtures with incompatible scope
73    if should_exclude_fixture(fixture, opts.fixture_scope) {
74        return true;
75    }
76
77    false
78}
79
80/// Compute a sort priority for a fixture based on its proximity to the current file.
81/// Lower values = higher priority (shown first in completion list).
82fn fixture_sort_priority(fixture: &FixtureDefinition, current_file: &std::path::Path) -> u8 {
83    if fixture.file_path == current_file {
84        0 // Same file
85    } else if fixture.is_third_party {
86        3 // Third-party (check before is_plugin since some are both)
87    } else if fixture.is_plugin {
88        2 // Plugin
89    } else {
90        1 // Conftest or other project files
91    }
92}
93
94/// Build a sort_text string that groups fixtures by proximity priority,
95/// then sorts alphabetically within each group.
96fn make_sort_text(priority: u8, fixture_name: &str) -> String {
97    format!("{}_{}", priority, fixture_name)
98}
99
100/// Build a detail string for a fixture completion item.
101/// Format: `(scope) [origin]`
102/// - scope is omitted when it's the default "function"
103/// - origin tag is only added for plugin or third-party fixtures
104fn make_fixture_detail(fixture: &FixtureDefinition) -> String {
105    let mut parts = Vec::new();
106
107    // Add scope if not the default "function"
108    if fixture.scope != FixtureScope::Function {
109        parts.push(format!("({})", fixture.scope.as_str()));
110    }
111
112    // Add origin tag
113    if fixture.is_third_party {
114        parts.push("[third-party]".to_string());
115    } else if fixture.is_plugin {
116        parts.push("[plugin]".to_string());
117    }
118
119    parts.join(" ")
120}
121
122/// A filtered and enriched fixture ready for completion item construction.
123struct EnrichedFixture {
124    fixture: FixtureDefinition,
125    detail: String,
126    sort_text: String,
127}
128
129/// Filter available fixtures according to common rules and enrich them with
130/// detail/sort metadata.
131fn filter_and_enrich_fixtures(
132    available: Vec<FixtureDefinition>,
133    file_path: &std::path::Path,
134    declared_params: Option<&[String]>,
135    opts: &CompletionOpts<'_>,
136) -> Vec<EnrichedFixture> {
137    available
138        .into_iter()
139        .filter(|f| !is_fixture_excluded(f, declared_params, opts))
140        .map(|f| {
141            let detail = make_fixture_detail(&f);
142            let priority = fixture_sort_priority(&f, file_path);
143            let sort_text = make_sort_text(priority, &f.name);
144            EnrichedFixture {
145                fixture: f,
146                detail,
147                sort_text,
148            }
149        })
150        .collect()
151}
152
153impl Backend {
154    /// Handle completion request
155    pub async fn handle_completion(
156        &self,
157        params: CompletionParams,
158    ) -> Result<Option<CompletionResponse>> {
159        let uri = params.text_document_position.text_document.uri;
160        let position = params.text_document_position.position;
161
162        // Check if completion was triggered by a comma — if so, prefix insert text
163        // with a space so "fixture1," becomes "fixture1, fixture2"
164        let triggered_by_comma = params
165            .context
166            .as_ref()
167            .and_then(|ctx| ctx.trigger_character.as_deref())
168            == Some(",");
169        let insert_prefix = if triggered_by_comma { " " } else { "" };
170
171        info!(
172            "completion request: uri={:?}, line={}, char={}",
173            uri, position.line, position.character
174        );
175
176        if let Some(file_path) = self.uri_to_path(&uri) {
177            // Get the completion context
178            if let Some(ctx) = self.fixture_db.get_completion_context(
179                &file_path,
180                position.line,
181                position.character,
182            ) {
183                info!("Completion context: {:?}", ctx);
184
185                // Get workspace root for formatting documentation
186                let workspace_root = self.workspace_root.read().await.clone();
187
188                match ctx {
189                    CompletionContext::FunctionSignature {
190                        function_name,
191                        is_fixture,
192                        declared_params,
193                        fixture_scope,
194                        ..
195                    } => {
196                        // In function signature - suggest fixtures as parameters (filter already declared)
197                        // When editing a fixture, exclude itself from suggestions
198                        let opts = CompletionOpts {
199                            fixture_scope,
200                            current_fixture_name: if is_fixture {
201                                Some(function_name.as_str())
202                            } else {
203                                None
204                            },
205                            insert_prefix,
206                        };
207                        return Ok(Some(self.create_fixture_completions(
208                            &file_path,
209                            &declared_params,
210                            workspace_root.as_ref(),
211                            &opts,
212                        )));
213                    }
214                    CompletionContext::FunctionBody {
215                        function_name,
216                        function_line,
217                        is_fixture,
218                        declared_params,
219                        fixture_scope,
220                        ..
221                    } => {
222                        // In function body - suggest fixtures with auto-add to parameters
223                        let opts = CompletionOpts {
224                            fixture_scope,
225                            current_fixture_name: if is_fixture {
226                                Some(function_name.as_str())
227                            } else {
228                                None
229                            },
230                            insert_prefix,
231                        };
232                        return Ok(Some(self.create_fixture_completions_with_auto_add(
233                            &file_path,
234                            &declared_params,
235                            function_line,
236                            workspace_root.as_ref(),
237                            &opts,
238                        )));
239                    }
240                    CompletionContext::UsefixturesDecorator
241                    | CompletionContext::ParametrizeIndirect => {
242                        // In decorator - suggest fixture names as strings
243                        return Ok(Some(self.create_string_fixture_completions(
244                            &file_path,
245                            workspace_root.as_ref(),
246                            insert_prefix,
247                        )));
248                    }
249                }
250            } else {
251                info!("No completion context found");
252            }
253        }
254
255        Ok(None)
256    }
257
258    /// Create completion items for fixtures (for function signature context)
259    /// Filters out already-declared parameters and scope-incompatible fixtures
260    pub(crate) fn create_fixture_completions(
261        &self,
262        file_path: &std::path::Path,
263        declared_params: &[String],
264        workspace_root: Option<&PathBuf>,
265        opts: &CompletionOpts<'_>,
266    ) -> CompletionResponse {
267        let available = self.fixture_db.get_available_fixtures(file_path);
268        let enriched =
269            filter_and_enrich_fixtures(available, file_path, Some(declared_params), opts);
270
271        let items = enriched
272            .into_iter()
273            .map(|ef| {
274                let documentation = Some(Documentation::MarkupContent(MarkupContent {
275                    kind: MarkupKind::Markdown,
276                    value: Self::format_fixture_documentation(&ef.fixture, workspace_root),
277                }));
278
279                CompletionItem {
280                    label: ef.fixture.name.clone(),
281                    kind: Some(CompletionItemKind::VARIABLE),
282                    detail: Some(ef.detail),
283                    documentation,
284                    insert_text: Some(format!("{}{}", opts.insert_prefix, ef.fixture.name)),
285                    insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
286                    sort_text: Some(ef.sort_text),
287                    ..Default::default()
288                }
289            })
290            .collect();
291
292        CompletionResponse::Array(items)
293    }
294
295    /// Create completion items for fixtures with auto-add to function parameters.
296    /// When a completion is confirmed, it also inserts the fixture as a parameter.
297    pub(crate) fn create_fixture_completions_with_auto_add(
298        &self,
299        file_path: &std::path::Path,
300        declared_params: &[String],
301        function_line: usize,
302        workspace_root: Option<&PathBuf>,
303        opts: &CompletionOpts<'_>,
304    ) -> CompletionResponse {
305        let available = self.fixture_db.get_available_fixtures(file_path);
306        let enriched =
307            filter_and_enrich_fixtures(available, file_path, Some(declared_params), opts);
308
309        // Get insertion info for adding new parameters
310        let insertion_info = self
311            .fixture_db
312            .get_function_param_insertion_info(file_path, function_line);
313
314        let items = enriched
315            .into_iter()
316            .map(|ef| {
317                let documentation = Some(Documentation::MarkupContent(MarkupContent {
318                    kind: MarkupKind::Markdown,
319                    value: Self::format_fixture_documentation(&ef.fixture, workspace_root),
320                }));
321
322                // Create additional text edit to add the fixture as a parameter
323                let additional_text_edits = insertion_info.as_ref().map(|info| {
324                    let text = match &info.multiline_indent {
325                        Some(indent) => {
326                            if info.needs_comma {
327                                // No trailing comma — append `,` after last arg,
328                                // then new param on a new indented line.
329                                format!(",\n{}{}", indent, ef.fixture.name)
330                            } else {
331                                // Trailing comma present — new param on a new
332                                // indented line, mirroring the trailing-comma style.
333                                format!("\n{}{},", indent, ef.fixture.name)
334                            }
335                        }
336                        None => {
337                            if info.needs_comma {
338                                format!(", {}", ef.fixture.name)
339                            } else {
340                                ef.fixture.name.clone()
341                            }
342                        }
343                    };
344                    let lsp_line = Self::internal_line_to_lsp(info.line);
345                    vec![TextEdit {
346                        range: Self::create_point_range(lsp_line, info.char_pos as u32),
347                        new_text: text,
348                    }]
349                });
350
351                CompletionItem {
352                    label: ef.fixture.name.clone(),
353                    kind: Some(CompletionItemKind::VARIABLE),
354                    detail: Some(ef.detail),
355                    documentation,
356                    insert_text: Some(format!("{}{}", opts.insert_prefix, ef.fixture.name)),
357                    insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
358                    additional_text_edits,
359                    sort_text: Some(ef.sort_text),
360                    ..Default::default()
361                }
362            })
363            .collect();
364
365        CompletionResponse::Array(items)
366    }
367
368    /// Create completion items for fixture names as strings (for decorators)
369    /// Used in @pytest.mark.usefixtures("...") and @pytest.mark.parametrize(..., indirect=["..."])
370    /// No scope filtering applied here (decision #3).
371    pub(crate) fn create_string_fixture_completions(
372        &self,
373        file_path: &std::path::Path,
374        workspace_root: Option<&PathBuf>,
375        insert_prefix: &str,
376    ) -> CompletionResponse {
377        let available = self.fixture_db.get_available_fixtures(file_path);
378        let no_filter_opts = CompletionOpts {
379            fixture_scope: None,
380            current_fixture_name: None,
381            insert_prefix,
382        };
383        let enriched = filter_and_enrich_fixtures(available, file_path, None, &no_filter_opts);
384
385        let items = enriched
386            .into_iter()
387            .map(|ef| {
388                let documentation = Some(Documentation::MarkupContent(MarkupContent {
389                    kind: MarkupKind::Markdown,
390                    value: Self::format_fixture_documentation(&ef.fixture, workspace_root),
391                }));
392
393                CompletionItem {
394                    label: ef.fixture.name.clone(),
395                    kind: Some(CompletionItemKind::TEXT),
396                    detail: Some(ef.detail),
397                    documentation,
398                    insert_text: Some(format!("{}{}", insert_prefix, ef.fixture.name)),
399                    insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
400                    sort_text: Some(ef.sort_text),
401                    ..Default::default()
402                }
403            })
404            .collect();
405
406        CompletionResponse::Array(items)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::fixtures::types::FixtureScope;
414    use crate::fixtures::FixtureDatabase;
415    use std::path::PathBuf;
416    use std::sync::Arc;
417
418    fn make_fixture(name: &str, scope: FixtureScope) -> FixtureDefinition {
419        FixtureDefinition {
420            name: name.to_string(),
421            file_path: PathBuf::from("/tmp/test/conftest.py"),
422            line: 1,
423            end_line: 5,
424            start_char: 4,
425            end_char: 10,
426            docstring: None,
427            return_type: None,
428            return_type_imports: vec![],
429            is_third_party: false,
430            is_plugin: false,
431            dependencies: vec![],
432            scope,
433            yield_line: None,
434            autouse: false,
435        }
436    }
437
438    // =========================================================================
439    // Unit tests for should_exclude_fixture
440    // =========================================================================
441
442    #[test]
443    fn test_should_exclude_fixture_test_function_allows_all() {
444        // Test functions (None scope) should allow all fixture scopes
445        let func = make_fixture("f", FixtureScope::Function);
446        let class = make_fixture("c", FixtureScope::Class);
447        let module = make_fixture("m", FixtureScope::Module);
448        let package = make_fixture("p", FixtureScope::Package);
449        let session = make_fixture("s", FixtureScope::Session);
450
451        assert!(!should_exclude_fixture(&func, None));
452        assert!(!should_exclude_fixture(&class, None));
453        assert!(!should_exclude_fixture(&module, None));
454        assert!(!should_exclude_fixture(&package, None));
455        assert!(!should_exclude_fixture(&session, None));
456    }
457
458    #[test]
459    fn test_should_exclude_fixture_session_excludes_narrower() {
460        let func = make_fixture("f", FixtureScope::Function);
461        let class = make_fixture("c", FixtureScope::Class);
462        let module = make_fixture("m", FixtureScope::Module);
463        let package = make_fixture("p", FixtureScope::Package);
464        let session = make_fixture("s", FixtureScope::Session);
465
466        let session_scope = Some(FixtureScope::Session);
467        // Session scope should exclude everything narrower
468        assert!(should_exclude_fixture(&func, session_scope));
469        assert!(should_exclude_fixture(&class, session_scope));
470        assert!(should_exclude_fixture(&module, session_scope));
471        assert!(should_exclude_fixture(&package, session_scope));
472        // But not session itself
473        assert!(!should_exclude_fixture(&session, session_scope));
474    }
475
476    #[test]
477    fn test_should_exclude_fixture_module_excludes_narrower() {
478        let func = make_fixture("f", FixtureScope::Function);
479        let class = make_fixture("c", FixtureScope::Class);
480        let module = make_fixture("m", FixtureScope::Module);
481        let package = make_fixture("p", FixtureScope::Package);
482        let session = make_fixture("s", FixtureScope::Session);
483
484        let module_scope = Some(FixtureScope::Module);
485        assert!(should_exclude_fixture(&func, module_scope));
486        assert!(should_exclude_fixture(&class, module_scope));
487        assert!(!should_exclude_fixture(&module, module_scope));
488        assert!(!should_exclude_fixture(&package, module_scope));
489        assert!(!should_exclude_fixture(&session, module_scope));
490    }
491
492    #[test]
493    fn test_should_exclude_fixture_function_allows_all() {
494        let func = make_fixture("f", FixtureScope::Function);
495        let class = make_fixture("c", FixtureScope::Class);
496        let module = make_fixture("m", FixtureScope::Module);
497        let session = make_fixture("s", FixtureScope::Session);
498
499        let function_scope = Some(FixtureScope::Function);
500        assert!(!should_exclude_fixture(&func, function_scope));
501        assert!(!should_exclude_fixture(&class, function_scope));
502        assert!(!should_exclude_fixture(&module, function_scope));
503        assert!(!should_exclude_fixture(&session, function_scope));
504    }
505
506    #[test]
507    fn test_should_exclude_fixture_class_excludes_function() {
508        let func = make_fixture("f", FixtureScope::Function);
509        let class = make_fixture("c", FixtureScope::Class);
510        let module = make_fixture("m", FixtureScope::Module);
511        let session = make_fixture("s", FixtureScope::Session);
512
513        let class_scope = Some(FixtureScope::Class);
514        assert!(should_exclude_fixture(&func, class_scope));
515        assert!(!should_exclude_fixture(&class, class_scope));
516        assert!(!should_exclude_fixture(&module, class_scope));
517        assert!(!should_exclude_fixture(&session, class_scope));
518    }
519
520    // =========================================================================
521    // Unit tests for is_fixture_excluded
522    // =========================================================================
523
524    #[test]
525    fn test_is_fixture_excluded_filters_self_cls() {
526        let self_fixture = make_fixture("self", FixtureScope::Function);
527        let cls_fixture = make_fixture("cls", FixtureScope::Function);
528        let normal_fixture = make_fixture("db", FixtureScope::Function);
529
530        let opts = CompletionOpts {
531            fixture_scope: None,
532            current_fixture_name: None,
533            insert_prefix: "",
534        };
535        assert!(is_fixture_excluded(&self_fixture, None, &opts));
536        assert!(is_fixture_excluded(&cls_fixture, None, &opts));
537        assert!(!is_fixture_excluded(&normal_fixture, None, &opts));
538    }
539
540    #[test]
541    fn test_is_fixture_excluded_filters_declared_params() {
542        let fixture = make_fixture("db", FixtureScope::Function);
543        let declared = vec!["db".to_string()];
544
545        let opts = CompletionOpts {
546            fixture_scope: None,
547            current_fixture_name: None,
548            insert_prefix: "",
549        };
550        assert!(is_fixture_excluded(&fixture, Some(&declared), &opts));
551        assert!(!is_fixture_excluded(&fixture, None, &opts));
552        assert!(!is_fixture_excluded(
553            &fixture,
554            Some(&["other".to_string()]),
555            &opts,
556        ));
557    }
558
559    #[test]
560    fn test_is_fixture_excluded_combines_scope_and_params() {
561        let func_fixture = make_fixture("db", FixtureScope::Function);
562        let declared = vec!["db".to_string()];
563        let session_scope = Some(FixtureScope::Session);
564
565        // Both scope and declared params exclude
566        let opts = CompletionOpts {
567            fixture_scope: session_scope,
568            current_fixture_name: None,
569            insert_prefix: "",
570        };
571        assert!(is_fixture_excluded(&func_fixture, Some(&declared), &opts,));
572
573        // Only scope excludes
574        let undeclared: Vec<String> = vec![];
575        assert!(is_fixture_excluded(&func_fixture, Some(&undeclared), &opts,));
576
577        // Only declared params exclude
578        let session_opts = CompletionOpts {
579            fixture_scope: session_scope,
580            current_fixture_name: None,
581            insert_prefix: "",
582        };
583        assert!(is_fixture_excluded(
584            &make_fixture("db", FixtureScope::Session),
585            Some(&declared),
586            &session_opts,
587        ));
588
589        // Neither excludes
590        assert!(!is_fixture_excluded(
591            &make_fixture("other", FixtureScope::Session),
592            Some(&undeclared),
593            &session_opts,
594        ));
595    }
596
597    // =========================================================================
598    // Unit tests for filter_and_enrich_fixtures
599    // =========================================================================
600
601    #[test]
602    fn test_filter_and_enrich_excludes_current_fixture() {
603        let file = std::path::Path::new("/tmp/test/conftest.py");
604        let fixtures = vec![
605            make_fixture("my_fixture", FixtureScope::Function),
606            make_fixture("other_fixture", FixtureScope::Function),
607        ];
608
609        // When editing my_fixture, it should be excluded
610        let opts = CompletionOpts {
611            fixture_scope: Some(FixtureScope::Function),
612            current_fixture_name: Some("my_fixture"),
613            insert_prefix: "",
614        };
615        let enriched = filter_and_enrich_fixtures(fixtures.clone(), file, None, &opts);
616        assert_eq!(enriched.len(), 1);
617        assert_eq!(enriched[0].fixture.name, "other_fixture");
618
619        // When editing a test (no current_fixture_name), both should be included
620        let test_opts = CompletionOpts {
621            fixture_scope: None,
622            current_fixture_name: None,
623            insert_prefix: "",
624        };
625        let enriched = filter_and_enrich_fixtures(fixtures, file, None, &test_opts);
626        assert_eq!(enriched.len(), 2);
627    }
628
629    #[test]
630    fn test_filter_and_enrich_excludes_scope_incompatible() {
631        let file_path = PathBuf::from("/tmp/test/test_file.py");
632        let fixtures = vec![
633            make_fixture("func_fix", FixtureScope::Function),
634            make_fixture("class_fix", FixtureScope::Class),
635            make_fixture("module_fix", FixtureScope::Module),
636            make_fixture("session_fix", FixtureScope::Session),
637        ];
638
639        // Session scope fixture: only session-scoped fixtures should survive
640        let opts = CompletionOpts {
641            fixture_scope: Some(FixtureScope::Session),
642            current_fixture_name: None,
643            insert_prefix: "",
644        };
645        let enriched = filter_and_enrich_fixtures(fixtures.clone(), &file_path, Some(&[]), &opts);
646        let names: Vec<&str> = enriched.iter().map(|e| e.fixture.name.as_str()).collect();
647        assert_eq!(names, vec!["session_fix"]);
648
649        // Module scope fixture: module and session should survive
650        let opts = CompletionOpts {
651            fixture_scope: Some(FixtureScope::Module),
652            current_fixture_name: None,
653            insert_prefix: "",
654        };
655        let enriched = filter_and_enrich_fixtures(fixtures.clone(), &file_path, Some(&[]), &opts);
656        let names: Vec<&str> = enriched.iter().map(|e| e.fixture.name.as_str()).collect();
657        assert_eq!(names, vec!["module_fix", "session_fix"]);
658
659        // Function scope fixture: all should survive
660        let opts = CompletionOpts {
661            fixture_scope: Some(FixtureScope::Function),
662            current_fixture_name: None,
663            insert_prefix: "",
664        };
665        let enriched = filter_and_enrich_fixtures(fixtures.clone(), &file_path, Some(&[]), &opts);
666        assert_eq!(enriched.len(), 4);
667
668        // Test function context (None scope): all should survive
669        let opts = CompletionOpts {
670            fixture_scope: None,
671            current_fixture_name: None,
672            insert_prefix: "",
673        };
674        let enriched = filter_and_enrich_fixtures(fixtures.clone(), &file_path, Some(&[]), &opts);
675        assert_eq!(enriched.len(), 4);
676    }
677
678    #[test]
679    fn test_filter_and_enrich_excludes_declared_params() {
680        let file_path = PathBuf::from("/tmp/test/test_file.py");
681        let fixtures = vec![
682            make_fixture("db", FixtureScope::Function),
683            make_fixture("client", FixtureScope::Function),
684            make_fixture("app", FixtureScope::Function),
685        ];
686
687        let declared = vec!["db".to_string(), "client".to_string()];
688        let opts = CompletionOpts {
689            fixture_scope: None,
690            current_fixture_name: None,
691            insert_prefix: "",
692        };
693        let enriched = filter_and_enrich_fixtures(fixtures, &file_path, Some(&declared), &opts);
694        let names: Vec<&str> = enriched.iter().map(|e| e.fixture.name.as_str()).collect();
695        assert_eq!(names, vec!["app"]);
696    }
697
698    #[test]
699    fn test_filter_and_enrich_excludes_self_cls() {
700        let file_path = PathBuf::from("/tmp/test/test_file.py");
701        let mut fixtures = vec![
702            make_fixture("self", FixtureScope::Function),
703            make_fixture("cls", FixtureScope::Function),
704            make_fixture("real_fixture", FixtureScope::Function),
705        ];
706        // Override names for the first two
707        fixtures[0].name = "self".to_string();
708        fixtures[1].name = "cls".to_string();
709
710        let opts = CompletionOpts {
711            fixture_scope: None,
712            current_fixture_name: None,
713            insert_prefix: "",
714        };
715        let enriched = filter_and_enrich_fixtures(fixtures, &file_path, None, &opts);
716        let names: Vec<&str> = enriched.iter().map(|e| e.fixture.name.as_str()).collect();
717        assert_eq!(names, vec!["real_fixture"]);
718    }
719
720    // =========================================================================
721    // Unit tests for fixture_sort_priority
722    // =========================================================================
723
724    #[test]
725    fn test_fixture_sort_priority_same_file() {
726        let current = PathBuf::from("/tmp/test/test_file.py");
727        let mut fixture = make_fixture("f", FixtureScope::Function);
728        fixture.file_path = current.clone();
729
730        assert_eq!(fixture_sort_priority(&fixture, &current), 0);
731    }
732
733    #[test]
734    fn test_fixture_sort_priority_conftest() {
735        let current = PathBuf::from("/tmp/test/test_file.py");
736        let mut fixture = make_fixture("f", FixtureScope::Function);
737        fixture.file_path = PathBuf::from("/tmp/test/conftest.py");
738
739        assert_eq!(fixture_sort_priority(&fixture, &current), 1);
740    }
741
742    #[test]
743    fn test_fixture_sort_priority_plugin() {
744        let current = PathBuf::from("/tmp/test/test_file.py");
745        let mut fixture = make_fixture("f", FixtureScope::Function);
746        fixture.file_path = PathBuf::from("/tmp/other/plugin.py");
747        fixture.is_plugin = true;
748
749        assert_eq!(fixture_sort_priority(&fixture, &current), 2);
750    }
751
752    #[test]
753    fn test_fixture_sort_priority_third_party() {
754        let current = PathBuf::from("/tmp/test/test_file.py");
755        let mut fixture = make_fixture("f", FixtureScope::Function);
756        fixture.file_path = PathBuf::from("/tmp/venv/lib/site-packages/pkg/fix.py");
757        fixture.is_third_party = true;
758
759        assert_eq!(fixture_sort_priority(&fixture, &current), 3);
760    }
761
762    #[test]
763    fn test_fixture_sort_priority_third_party_trumps_plugin() {
764        let current = PathBuf::from("/tmp/test/test_file.py");
765        let mut fixture = make_fixture("f", FixtureScope::Function);
766        fixture.file_path = PathBuf::from("/tmp/venv/lib/site-packages/pkg/fix.py");
767        fixture.is_third_party = true;
768        fixture.is_plugin = true;
769
770        // Third-party check comes first, so priority is 3
771        assert_eq!(fixture_sort_priority(&fixture, &current), 3);
772    }
773
774    // =========================================================================
775    // Unit tests for make_fixture_detail
776    // =========================================================================
777
778    #[test]
779    fn test_make_fixture_detail_default_scope() {
780        let fixture = make_fixture("f", FixtureScope::Function);
781        let detail = make_fixture_detail(&fixture);
782        assert_eq!(detail, ""); // default scope not shown
783    }
784
785    #[test]
786    fn test_make_fixture_detail_session_scope() {
787        let fixture = make_fixture("f", FixtureScope::Session);
788        let detail = make_fixture_detail(&fixture);
789        assert_eq!(detail, "(session)");
790    }
791
792    #[test]
793    fn test_make_fixture_detail_third_party() {
794        let mut fixture = make_fixture("f", FixtureScope::Function);
795        fixture.is_third_party = true;
796        let detail = make_fixture_detail(&fixture);
797        assert_eq!(detail, "[third-party]");
798    }
799
800    #[test]
801    fn test_make_fixture_detail_plugin_with_scope() {
802        let mut fixture = make_fixture("f", FixtureScope::Module);
803        fixture.is_plugin = true;
804        let detail = make_fixture_detail(&fixture);
805        assert_eq!(detail, "(module) [plugin]");
806    }
807
808    #[test]
809    fn test_make_fixture_detail_third_party_overrides_plugin() {
810        let mut fixture = make_fixture("f", FixtureScope::Session);
811        fixture.is_third_party = true;
812        fixture.is_plugin = true;
813        let detail = make_fixture_detail(&fixture);
814        // third_party tag takes precedence over plugin tag
815        assert_eq!(detail, "(session) [third-party]");
816    }
817
818    // =========================================================================
819    // Unit tests for make_sort_text
820    // =========================================================================
821
822    #[test]
823    fn test_make_sort_text_ordering() {
824        let same_file = make_sort_text(0, "zzz");
825        let conftest = make_sort_text(1, "aaa");
826        let plugin = make_sort_text(2, "aaa");
827        let third_party = make_sort_text(3, "aaa");
828
829        // Group ordering: same_file < conftest < plugin < third_party
830        assert!(same_file < conftest);
831        assert!(conftest < plugin);
832        assert!(plugin < third_party);
833    }
834
835    #[test]
836    fn test_make_sort_text_alpha_within_group() {
837        let a = make_sort_text(0, "alpha");
838        let b = make_sort_text(0, "beta");
839        assert!(a < b);
840    }
841
842    // =========================================================================
843    // Integration tests with Backend
844    // =========================================================================
845
846    use tower_lsp_server::LspService;
847
848    /// Create a Backend instance for testing by using LspService to obtain a Client.
849    /// We capture a clone of the Backend from inside the LspService::new closure.
850    fn make_backend_with_db(db: Arc<FixtureDatabase>) -> Backend {
851        let backend_slot: Arc<std::sync::Mutex<Option<Backend>>> =
852            Arc::new(std::sync::Mutex::new(None));
853        let slot_clone = backend_slot.clone();
854        let (_svc, _sock) = LspService::new(move |client| {
855            let b = Backend::new(client, db.clone());
856            // Clone all Arc fields to capture a usable Backend outside
857            *slot_clone.lock().unwrap() = Some(Backend {
858                client: b.client.clone(),
859                fixture_db: b.fixture_db.clone(),
860                workspace_root: b.workspace_root.clone(),
861                original_workspace_root: b.original_workspace_root.clone(),
862                scan_task: b.scan_task.clone(),
863                uri_cache: b.uri_cache.clone(),
864                config: b.config.clone(),
865            });
866            b
867        });
868        let result = backend_slot
869            .lock()
870            .unwrap()
871            .take()
872            .expect("Backend should have been created");
873        result
874    }
875
876    /// Helper: build a test Backend with fixtures pre-loaded.
877    fn setup_backend_with_fixtures() -> (Backend, PathBuf) {
878        let db = Arc::new(FixtureDatabase::new());
879
880        let conftest_content = r#"
881import pytest
882
883@pytest.fixture
884def func_fixture():
885    return "func"
886
887@pytest.fixture(scope="session")
888def session_fixture():
889    """A session-scoped fixture."""
890    return "session"
891
892@pytest.fixture(scope="module")
893def module_fixture():
894    return "module"
895"#;
896
897        let test_content = r#"
898import pytest
899
900@pytest.fixture(scope="session")
901def local_session_fixture():
902    pass
903
904def test_something(func_fixture):
905    pass
906"#;
907
908        let conftest_path = PathBuf::from("/tmp/test_backend/conftest.py");
909        let test_path = PathBuf::from("/tmp/test_backend/test_example.py");
910
911        db.analyze_file(conftest_path, conftest_content);
912        db.analyze_file(test_path.clone(), test_content);
913
914        let backend = make_backend_with_db(db);
915        (backend, test_path)
916    }
917
918    fn extract_items(response: &CompletionResponse) -> &Vec<CompletionItem> {
919        match response {
920            CompletionResponse::Array(items) => items,
921            _ => panic!("Expected CompletionResponse::Array"),
922        }
923    }
924
925    // =========================================================================
926    // Tests for create_fixture_completions
927    // =========================================================================
928
929    #[test]
930    fn test_create_fixture_completions_returns_items() {
931        let (backend, test_path) = setup_backend_with_fixtures();
932        let declared = vec![];
933        let opts = CompletionOpts {
934            fixture_scope: None,
935            current_fixture_name: None,
936            insert_prefix: "",
937        };
938        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
939        let items = extract_items(&response);
940        assert!(!items.is_empty(), "Should return completion items");
941        // All items should have VARIABLE kind
942        for item in items {
943            assert_eq!(item.kind, Some(CompletionItemKind::VARIABLE));
944            assert!(item.insert_text.is_some());
945            assert!(item.sort_text.is_some());
946            assert!(item.detail.is_some());
947        }
948    }
949
950    #[test]
951    fn test_create_fixture_completions_filters_declared() {
952        let (backend, test_path) = setup_backend_with_fixtures();
953        let declared = vec!["func_fixture".to_string()];
954        let opts = CompletionOpts {
955            fixture_scope: None,
956            current_fixture_name: None,
957            insert_prefix: "",
958        };
959        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
960        let items = extract_items(&response);
961        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
962        assert!(
963            !labels.contains(&"func_fixture"),
964            "func_fixture should be filtered out"
965        );
966    }
967
968    #[test]
969    fn test_create_fixture_completions_scope_filtering() {
970        let (backend, test_path) = setup_backend_with_fixtures();
971        let declared = vec![];
972        // Session scope: only session-scoped fixtures should appear
973        let opts = CompletionOpts {
974            fixture_scope: Some(FixtureScope::Session),
975            current_fixture_name: None,
976            insert_prefix: "",
977        };
978        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
979        let items = extract_items(&response);
980        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
981        assert!(
982            !labels.contains(&"func_fixture"),
983            "func_fixture should be excluded by session scope filter"
984        );
985        assert!(
986            labels.contains(&"session_fixture"),
987            "session_fixture should be present, got: {:?}",
988            labels
989        );
990    }
991
992    #[test]
993    fn test_create_fixture_completions_detail_and_sort() {
994        let (backend, test_path) = setup_backend_with_fixtures();
995        let declared = vec![];
996        let opts = CompletionOpts {
997            fixture_scope: None,
998            current_fixture_name: None,
999            insert_prefix: "",
1000        };
1001        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
1002        let items = extract_items(&response);
1003
1004        // Find the session_fixture — it should have scope in detail
1005        let session_item = items.iter().find(|i| i.label == "session_fixture");
1006        assert!(session_item.is_some(), "Should find session_fixture");
1007        let session_item = session_item.unwrap();
1008        assert!(
1009            session_item.detail.as_ref().unwrap().contains("session"),
1010            "session_fixture detail should contain scope, got: {:?}",
1011            session_item.detail
1012        );
1013
1014        // Find the func_fixture — default scope should not appear
1015        let func_item = items.iter().find(|i| i.label == "func_fixture");
1016        assert!(func_item.is_some(), "Should find func_fixture");
1017        let func_item = func_item.unwrap();
1018        assert!(
1019            !func_item.detail.as_ref().unwrap().contains("function"),
1020            "func_fixture detail should not contain 'function' (default scope), got: {:?}",
1021            func_item.detail
1022        );
1023    }
1024
1025    #[test]
1026    fn test_create_fixture_completions_documentation() {
1027        let (backend, test_path) = setup_backend_with_fixtures();
1028        let declared = vec![];
1029        let opts = CompletionOpts {
1030            fixture_scope: None,
1031            current_fixture_name: None,
1032            insert_prefix: "",
1033        };
1034        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
1035        let items = extract_items(&response);
1036
1037        // All items should have documentation
1038        for item in items {
1039            assert!(
1040                item.documentation.is_some(),
1041                "Completion item '{}' should have documentation",
1042                item.label
1043            );
1044        }
1045    }
1046
1047    #[test]
1048    fn test_create_fixture_completions_with_workspace_root() {
1049        let (backend, test_path) = setup_backend_with_fixtures();
1050        let declared = vec![];
1051        let workspace_root = PathBuf::from("/tmp/test_backend");
1052        let opts = CompletionOpts {
1053            fixture_scope: None,
1054            current_fixture_name: None,
1055            insert_prefix: "",
1056        };
1057        let response =
1058            backend.create_fixture_completions(&test_path, &declared, Some(&workspace_root), &opts);
1059        let items = extract_items(&response);
1060        assert!(!items.is_empty());
1061    }
1062
1063    // =========================================================================
1064    // Tests for create_fixture_completions_with_auto_add
1065    // =========================================================================
1066
1067    #[test]
1068    fn test_create_fixture_completions_with_auto_add_returns_items() {
1069        let (backend, test_path) = setup_backend_with_fixtures();
1070        let declared = vec![];
1071        let opts = CompletionOpts {
1072            fixture_scope: None,
1073            current_fixture_name: None,
1074            insert_prefix: "",
1075        };
1076        // function_line is 1-based internal line of `def test_something(func_fixture):`
1077        // In test_content, test_something is at line 8 (1-indexed)
1078        let response =
1079            backend.create_fixture_completions_with_auto_add(&test_path, &declared, 8, None, &opts);
1080        let items = extract_items(&response);
1081        assert!(!items.is_empty(), "Should return completion items");
1082        for item in items {
1083            assert_eq!(item.kind, Some(CompletionItemKind::VARIABLE));
1084            assert!(item.sort_text.is_some());
1085            assert!(item.detail.is_some());
1086        }
1087    }
1088
1089    #[test]
1090    fn test_create_fixture_completions_with_auto_add_has_text_edits() {
1091        let (backend, test_path) = setup_backend_with_fixtures();
1092        let declared = vec!["func_fixture".to_string()];
1093        // Line 8 has: def test_something(func_fixture):
1094        let opts = CompletionOpts {
1095            fixture_scope: None,
1096            current_fixture_name: None,
1097            insert_prefix: "",
1098        };
1099        let response =
1100            backend.create_fixture_completions_with_auto_add(&test_path, &declared, 8, None, &opts);
1101        let items = extract_items(&response);
1102        // Items should have additional_text_edits to add parameter
1103        for item in items {
1104            assert!(
1105                item.additional_text_edits.is_some(),
1106                "Item '{}' should have additional_text_edits for auto-add",
1107                item.label
1108            );
1109            let edits = item.additional_text_edits.as_ref().unwrap();
1110            assert_eq!(edits.len(), 1, "Should have exactly one text edit");
1111        }
1112    }
1113
1114    #[test]
1115    fn test_create_fixture_completions_with_auto_add_scope_filter() {
1116        let (backend, test_path) = setup_backend_with_fixtures();
1117        let declared = vec![];
1118        let opts = CompletionOpts {
1119            fixture_scope: Some(FixtureScope::Session),
1120            current_fixture_name: None,
1121            insert_prefix: "",
1122        };
1123        let response =
1124            backend.create_fixture_completions_with_auto_add(&test_path, &declared, 8, None, &opts);
1125        let items = extract_items(&response);
1126        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1127        assert!(
1128            !labels.contains(&"func_fixture"),
1129            "func_fixture should be excluded by session scope"
1130        );
1131    }
1132
1133    #[test]
1134    fn test_create_fixture_completions_with_auto_add_filters_declared() {
1135        let (backend, test_path) = setup_backend_with_fixtures();
1136        let declared = vec!["session_fixture".to_string(), "func_fixture".to_string()];
1137        let opts = CompletionOpts {
1138            fixture_scope: None,
1139            current_fixture_name: None,
1140            insert_prefix: "",
1141        };
1142        let response =
1143            backend.create_fixture_completions_with_auto_add(&test_path, &declared, 8, None, &opts);
1144        let items = extract_items(&response);
1145        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1146        assert!(
1147            !labels.contains(&"func_fixture"),
1148            "func_fixture should be filtered"
1149        );
1150        assert!(
1151            !labels.contains(&"session_fixture"),
1152            "session_fixture should be filtered"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_create_fixture_completions_with_auto_add_filters_current_fixture() {
1158        let (backend, file_path) = setup_backend_with_fixtures();
1159        // When editing func_fixture, it should not appear in completions
1160        let opts = CompletionOpts {
1161            fixture_scope: Some(FixtureScope::Function),
1162            current_fixture_name: Some("func_fixture"),
1163            insert_prefix: "",
1164        };
1165        let response = backend.create_fixture_completions(&file_path, &[], None, &opts);
1166        let items = extract_items(&response);
1167        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1168        assert!(
1169            !labels.contains(&"func_fixture"),
1170            "Current fixture should be excluded from completions, got: {:?}",
1171            labels
1172        );
1173        assert!(
1174            labels.contains(&"session_fixture"),
1175            "Other fixtures should still appear"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_create_fixture_completions_comma_trigger_adds_space() {
1181        let (backend, test_path) = setup_backend_with_fixtures();
1182        let declared = vec![];
1183        // Comma trigger — space prefix
1184        let opts = CompletionOpts {
1185            fixture_scope: None,
1186            current_fixture_name: None,
1187            insert_prefix: " ",
1188        };
1189        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
1190        let items = extract_items(&response);
1191        assert!(!items.is_empty());
1192        for item in items {
1193            let text = item.insert_text.as_ref().unwrap();
1194            assert!(
1195                text.starts_with(' '),
1196                "insert_text should start with space for comma trigger, got: {:?}",
1197                text
1198            );
1199        }
1200    }
1201
1202    #[test]
1203    fn test_create_fixture_completions_no_trigger_no_space() {
1204        let (backend, test_path) = setup_backend_with_fixtures();
1205        let declared = vec![];
1206        // No trigger character — empty prefix
1207        let opts = CompletionOpts {
1208            fixture_scope: None,
1209            current_fixture_name: None,
1210            insert_prefix: "",
1211        };
1212        let response = backend.create_fixture_completions(&test_path, &declared, None, &opts);
1213        let items = extract_items(&response);
1214        assert!(!items.is_empty());
1215        for item in items {
1216            let text = item.insert_text.as_ref().unwrap();
1217            assert!(
1218                !text.starts_with(' '),
1219                "insert_text should NOT start with space without comma trigger, got: {:?}",
1220                text
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn test_create_fixture_completions_with_auto_add_no_existing_params() {
1227        // Test the needs_comma = false branch: function with no existing parameters
1228        let db = Arc::new(FixtureDatabase::new());
1229
1230        let conftest_content = r#"
1231import pytest
1232
1233@pytest.fixture
1234def db_fixture():
1235    return "db"
1236"#;
1237
1238        let test_content = r#"
1239def test_empty_params():
1240    pass
1241"#;
1242
1243        let conftest_path = PathBuf::from("/tmp/test_no_params/conftest.py");
1244        let test_path = PathBuf::from("/tmp/test_no_params/test_file.py");
1245
1246        db.analyze_file(conftest_path, conftest_content);
1247        db.analyze_file(test_path.clone(), test_content);
1248
1249        let backend = make_backend_with_db(db);
1250        let declared: Vec<String> = vec![];
1251        // Line 2 (1-indexed) is `def test_empty_params():`
1252        let opts = CompletionOpts {
1253            fixture_scope: None,
1254            current_fixture_name: None,
1255            insert_prefix: "",
1256        };
1257        let response =
1258            backend.create_fixture_completions_with_auto_add(&test_path, &declared, 2, None, &opts);
1259        let items = extract_items(&response);
1260        assert!(!items.is_empty(), "Should return completion items");
1261
1262        // The text edit should NOT have a comma since there are no existing params
1263        let item = items.iter().find(|i| i.label == "db_fixture");
1264        assert!(item.is_some(), "Should find db_fixture");
1265        let item = item.unwrap();
1266        let edits = item.additional_text_edits.as_ref().unwrap();
1267        assert_eq!(edits.len(), 1);
1268        // The new_text should be just the fixture name (no comma prefix)
1269        assert_eq!(
1270            edits[0].new_text, "db_fixture",
1271            "Should insert fixture name without comma for empty params"
1272        );
1273    }
1274
1275    // =========================================================================
1276    // Tests for create_string_fixture_completions
1277    // =========================================================================
1278
1279    #[test]
1280    fn test_create_string_fixture_completions_returns_items() {
1281        let (backend, test_path) = setup_backend_with_fixtures();
1282        let response = backend.create_string_fixture_completions(&test_path, None, "");
1283        let items = extract_items(&response);
1284        assert!(!items.is_empty(), "Should return string completion items");
1285        // String completions use TEXT kind
1286        for item in items {
1287            assert_eq!(
1288                item.kind,
1289                Some(CompletionItemKind::TEXT),
1290                "String completions should use TEXT kind"
1291            );
1292            assert!(item.sort_text.is_some());
1293            assert!(item.detail.is_some());
1294            assert!(item.documentation.is_some());
1295        }
1296    }
1297
1298    #[test]
1299    fn test_create_string_fixture_completions_no_scope_filtering() {
1300        let (backend, test_path) = setup_backend_with_fixtures();
1301        // String completions should NOT filter by scope
1302        let response = backend.create_string_fixture_completions(&test_path, None, "");
1303        let items = extract_items(&response);
1304        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1305        // Both function and session scoped fixtures should be present
1306        assert!(
1307            labels.contains(&"func_fixture"),
1308            "func_fixture should be in string completions, got: {:?}",
1309            labels
1310        );
1311        assert!(
1312            labels.contains(&"session_fixture"),
1313            "session_fixture should be in string completions, got: {:?}",
1314            labels
1315        );
1316    }
1317
1318    #[test]
1319    fn test_create_string_fixture_completions_with_workspace_root() {
1320        let (backend, test_path) = setup_backend_with_fixtures();
1321        let workspace_root = PathBuf::from("/tmp/test_backend");
1322        let response =
1323            backend.create_string_fixture_completions(&test_path, Some(&workspace_root), "");
1324        let items = extract_items(&response);
1325        assert!(!items.is_empty());
1326    }
1327
1328    #[test]
1329    fn test_create_string_fixture_completions_has_detail_and_sort() {
1330        let (backend, test_path) = setup_backend_with_fixtures();
1331        let response = backend.create_string_fixture_completions(&test_path, None, "");
1332        let items = extract_items(&response);
1333
1334        let session_item = items.iter().find(|i| i.label == "session_fixture");
1335        assert!(session_item.is_some());
1336        let session_item = session_item.unwrap();
1337        assert!(
1338            session_item.detail.as_ref().unwrap().contains("session"),
1339            "session_fixture should have scope in detail"
1340        );
1341        // sort_text should be present and start with priority digit
1342        let sort = session_item.sort_text.as_ref().unwrap();
1343        assert!(
1344            sort.starts_with('1') || sort.starts_with('0'),
1345            "Sort text should start with priority digit, got: {}",
1346            sort
1347        );
1348    }
1349
1350    // =========================================================================
1351    // Edge case tests
1352    // =========================================================================
1353
1354    #[test]
1355    fn test_create_fixture_completions_empty_db() {
1356        let db = Arc::new(FixtureDatabase::new());
1357        let backend = make_backend_with_db(db);
1358        let path = PathBuf::from("/tmp/empty/test_file.py");
1359        let opts = CompletionOpts {
1360            fixture_scope: None,
1361            current_fixture_name: None,
1362            insert_prefix: "",
1363        };
1364        let response = backend.create_fixture_completions(&path, &[], None, &opts);
1365        let items = extract_items(&response);
1366        assert!(items.is_empty(), "Empty DB should return no completions");
1367    }
1368
1369    #[test]
1370    fn test_create_fixture_completions_with_auto_add_empty_db() {
1371        let db = Arc::new(FixtureDatabase::new());
1372        let backend = make_backend_with_db(db);
1373        let path = PathBuf::from("/tmp/empty/test_file.py");
1374        let opts = CompletionOpts {
1375            fixture_scope: None,
1376            current_fixture_name: None,
1377            insert_prefix: "",
1378        };
1379        let response = backend.create_fixture_completions_with_auto_add(&path, &[], 1, None, &opts);
1380        let items = extract_items(&response);
1381        assert!(items.is_empty(), "Empty DB should return no completions");
1382    }
1383
1384    #[test]
1385    fn test_create_string_fixture_completions_empty_db() {
1386        let db = Arc::new(FixtureDatabase::new());
1387        let backend = make_backend_with_db(db);
1388        let path = PathBuf::from("/tmp/empty/test_file.py");
1389        let response = backend.create_string_fixture_completions(&path, None, "");
1390        let items = extract_items(&response);
1391        assert!(items.is_empty(), "Empty DB should return no completions");
1392    }
1393}