Skip to main content

lattice_ui_tui/
picker_sources.rs

1//! TUI-coupled test harness for the renderer-neutral
2//! `lattice_picker::picker_sources` first-party source catalog.
3//! Slice 5.7.B.0 moved the production code to lattice-picker; the
4//! tests stay here because their `app_with(...)` helper builds a
5//! real ui-tui `App` so each source's `init` / `accept` can be
6//! exercised against a live `PickerContext` snapshot. The
7//! `lattice_picker::picker_sources` test module (in-tree, next to
8//! the source impls) covers the pure formatters and grep-line
9//! parser; those don't need an `App`.
10//!
11//! `pub use` re-exports every public item so call sites that
12//! referenced `lattice_ui_tui::picker_sources::*` before the move
13//! keep resolving without source changes.
14
15pub use lattice_picker::picker_sources::*;
16
17#[cfg(test)]
18mod tests {
19    #![allow(clippy::unwrap_used, clippy::panic)]
20
21    use super::*;
22    use crate::app::test_helpers::app_with;
23    // The shim re-exports `picker_sources::*` (source structs), but
24    // the tests below also exercise registry / routing types that
25    // live elsewhere in `lattice-picker` (`source::*`, `outcome::*`).
26    // Pull those into scope explicitly so the test bodies that
27    // were written against the pre-move `use lattice_picker::{...}`
28    // top-level import keep working unchanged.
29    use lattice_completion::{CandidateKind, RawCandidate};
30    use lattice_grammar::Args;
31    use lattice_picker::{
32        PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator,
33        PickerSourceSpec, RoutingPayload, SourceResult,
34    };
35    use std::sync::Arc;
36
37    /// MP.2b: an empty name→chords reverse-lookup for command-
38    /// picker tests that don't exercise the keybinding column.
39    /// The commands source then emits no `Keybinding`
40    /// annotations, keeping those tests focused on args/doc/
41    /// latency.
42    fn empty_reverse() -> Arc<dyn lattice_completion::KeymapReverseLookup> {
43        struct Empty;
44        impl lattice_completion::KeymapReverseLookup for Empty {
45            fn chords_for(&self, _name: &str) -> Vec<lattice_protocol::KeyChord> {
46                Vec::new()
47            }
48        }
49        Arc::new(Empty)
50    }
51
52    /// MP.2b: the real reverse-lookup adapter over the test
53    /// App's keymap registry — the same path boot wires into the
54    /// live commands picker. Proves the end-to-end binding →
55    /// annotation flow (e.g. `ex:help` bound to `<C-h><C-h>`).
56    fn app_reverse(app: &crate::app::App) -> Arc<dyn lattice_completion::KeymapReverseLookup> {
57        lattice_host::keymap_registry::KeymapReverseLookupHandle::new(
58            &app.editor.keymap,
59            app.editor.registry.clone(),
60        )
61    }
62
63    /// Files source emits `OpenFile { path }` routing
64    /// payloads pointing under the supplied root.
65    #[test]
66    fn files_source_inline_init_emits_open_file_routing() {
67        let tmp = std::env::temp_dir().join(format!("lattice-files-src-{}", std::process::id()));
68        let _ = std::fs::remove_dir_all(&tmp);
69        std::fs::create_dir_all(&tmp).unwrap();
70        std::fs::write(tmp.join("a.rs"), "").unwrap();
71        std::fs::write(tmp.join("b.rs"), "").unwrap();
72        let app = app_with("hi\n", 5);
73        let snap = app.ad().snapshot.clone();
74        let ctx = app.build_picker_context(&snap);
75        let source = FilesSource::new();
76        let result = source
77            .init(&ctx, std::slice::from_ref(&tmp.display().to_string()))
78            .expect("inline result");
79        match result {
80            PickerInitResult::Inline(pairs) => {
81                assert_eq!(pairs.len(), 2);
82                for (_cand, routing) in &pairs {
83                    match routing {
84                        RoutingPayload::OpenFile { path } => {
85                            assert!(path.starts_with(std::fs::canonicalize(&tmp).unwrap()));
86                        }
87                        other => panic!("expected OpenFile, got {other:?}"),
88                    }
89                }
90            }
91            other => panic!("expected Inline, got {other:?}"),
92        }
93        let _ = std::fs::remove_dir_all(&tmp);
94    }
95
96    // ── MG.53.e: `file-pick`, the value-supplying peer ──
97
98    /// Same listing as `files`, different accept. The paths come back
99    /// **relative to the walk root** because the consumers are git
100    /// commands and git addresses files repo-relatively; an absolute
101    /// path would work by luck whenever the root happened to be the
102    /// repo and break silently when it was not.
103    #[test]
104    fn file_pick_source_emits_relative_supplied_values() {
105        use lattice_picker::picker_sources::FilePickSource;
106        let tmp = std::env::temp_dir().join(format!("lattice-file-pick-{}", std::process::id()));
107        let _ = std::fs::remove_dir_all(&tmp);
108        std::fs::create_dir_all(tmp.join("src")).unwrap();
109        std::fs::write(tmp.join("src").join("main.rs"), "").unwrap();
110        let app = app_with("hi\n", 5);
111        let snap = app.ad().snapshot.clone();
112        let ctx = app.build_picker_context(&snap);
113        let source = FilePickSource::new();
114        let result = source
115            .init(&ctx, std::slice::from_ref(&tmp.display().to_string()))
116            .expect("inline result");
117        let PickerInitResult::Inline(pairs) = result else {
118            panic!("expected Inline");
119        };
120        assert_eq!(pairs.len(), 1);
121        match &pairs[0].1 {
122            RoutingPayload::SuppliedValue { value } => {
123                assert_eq!(
124                    value,
125                    &std::path::PathBuf::from("src")
126                        .join("main.rs")
127                        .display()
128                        .to_string(),
129                    "relative to the root, not absolute"
130                );
131            }
132            other => panic!("expected SuppliedValue, got {other:?}"),
133        }
134        let _ = std::fs::remove_dir_all(&tmp);
135    }
136
137    /// The candidate must NOT carry an `OpenFile` accept action. It
138    /// would let the completion layer open the file behind the caller's
139    /// back — the precise confusion this source exists to avoid, and the
140    /// reason `FilesSource` could not simply be reused.
141    #[test]
142    fn file_pick_candidates_do_not_open_the_file() {
143        use lattice_picker::picker_sources::FilePickSource;
144        let tmp =
145            std::env::temp_dir().join(format!("lattice-file-pick-noopen-{}", std::process::id()));
146        let _ = std::fs::remove_dir_all(&tmp);
147        std::fs::create_dir_all(&tmp).unwrap();
148        std::fs::write(tmp.join("a.rs"), "").unwrap();
149        let app = app_with("hi\n", 5);
150        let snap = app.ad().snapshot.clone();
151        let ctx = app.build_picker_context(&snap);
152        let source = FilePickSource::new();
153        let PickerInitResult::Inline(pairs) = source
154            .init(&ctx, std::slice::from_ref(&tmp.display().to_string()))
155            .expect("inline")
156        else {
157            panic!("expected Inline");
158        };
159        assert!(
160            pairs[0].0.accept_action.is_none(),
161            "a value-supplying candidate must not also open the file"
162        );
163        let _ = std::fs::remove_dir_all(&tmp);
164    }
165
166    #[test]
167    fn file_pick_accept_fills_the_caller() {
168        use lattice_picker::PickerAcceptOutcome;
169        use lattice_picker::picker_sources::FilePickSource;
170        let app = app_with("hi\n", 5);
171        let snap = app.ad().snapshot.clone();
172        let ctx = app.build_picker_context(&snap);
173        let source = FilePickSource::new();
174        let outcome = source
175            .accept(
176                &ctx,
177                &RoutingPayload::SuppliedValue {
178                    value: "src/main.rs".into(),
179                },
180            )
181            .expect("accept");
182        match outcome {
183            PickerAcceptOutcome::FillCaller { text } => assert_eq!(text, "src/main.rs"),
184            other => panic!("expected FillCaller, got {other:?}"),
185        }
186    }
187
188    // ── YR.4: the yank-ring source ──
189
190    fn ring_ctx_app() -> crate::app::App {
191        app_with("hi\n", 5)
192    }
193
194    /// Ring entries and named registers arrive in ONE list. Splitting
195    /// them would make the user answer "was this a yank or a register?"
196    /// before they can look, which is a question about how they copied
197    /// rather than about what they want.
198    #[test]
199    fn yank_ring_lists_the_ring_and_the_registers_together() {
200        use lattice_picker::picker_sources::YankRingSource;
201        let mut app = ring_ctx_app();
202        app.editor.store_yank(
203            lattice_grammar::Register::Unnamed,
204            "from-the-ring".into(),
205            lattice_grammar::effect::YankKind::Charwise,
206            true,
207        );
208        app.editor.store_yank(
209            lattice_grammar::Register::Named('q'),
210            "in-register-q".into(),
211            lattice_grammar::effect::YankKind::Charwise,
212            true,
213        );
214        let snap = app.ad().snapshot.clone();
215        let ctx = app.build_picker_context(&snap);
216        let PickerInitResult::Inline(pairs) =
217            YankRingSource::new().init(&ctx, &[]).expect("inline")
218        else {
219            panic!("expected Inline");
220        };
221        let shown: Vec<String> = pairs.iter().map(|(c, _)| c.display.clone()).collect();
222        assert!(
223            shown.iter().any(|d| d.contains("from-the-ring")),
224            "ring entries missing: {shown:?}"
225        );
226        assert!(
227            shown.iter().any(|d| d.contains("in-register-q")),
228            "register entries missing: {shown:?}"
229        );
230    }
231
232    /// A register's row must carry the register's REAL contents, not the
233    /// 40-char preview the context used to hand over. Picking a long
234    /// register otherwise inserted a truncation — with `\u{21B5}` in place
235    /// of its newlines, so the result was not merely short but corrupt.
236    #[test]
237    fn a_register_row_carries_its_full_contents() {
238        use lattice_picker::picker_sources::YankRingSource;
239        let long = "x".repeat(200);
240        let mut app = ring_ctx_app();
241        // Disable the ring, or `store_yank` pushes the same text there too
242        // and the assertion below finds the RING row — passing while the
243        // register row is still truncated. It did exactly that first.
244        app.editor
245            .config
246            .parse_and_set_command("yank.ring.size=0")
247            .expect("settable");
248        app.editor.drain_option_changes();
249        app.editor.store_yank(
250            lattice_grammar::Register::Named('a'),
251            long.clone(),
252            lattice_grammar::effect::YankKind::Charwise,
253            true,
254        );
255        let snap = app.ad().snapshot.clone();
256        let ctx = app.build_picker_context(&snap);
257        let PickerInitResult::Inline(pairs) =
258            YankRingSource::new().init(&ctx, &[]).expect("inline")
259        else {
260            panic!("expected Inline");
261        };
262        let full_rows = pairs
263            .iter()
264            .filter(|(_, r)| matches!(r, RoutingPayload::SuppliedValue { value } if value == &long))
265            .count();
266        assert!(
267            full_rows >= 1,
268            "no row carried the register's full 200-char contents; \
269             values were {:?}",
270            pairs
271                .iter()
272                .map(|(_, r)| match r {
273                    RoutingPayload::SuppliedValue { value } => value.chars().count(),
274                    _ => 0,
275                })
276                .collect::<Vec<_>>()
277        );
278    }
279
280    /// ...and a multi-line register keeps its newlines rather than the
281    /// `\u{21B5}` the display substitution used to bake in.
282    #[test]
283    fn a_multi_line_register_keeps_its_newlines() {
284        use lattice_picker::picker_sources::YankRingSource;
285        let mut app = ring_ctx_app();
286        app.editor
287            .config
288            .parse_and_set_command("yank.ring.size=0")
289            .expect("settable");
290        app.editor.drain_option_changes();
291        app.editor.store_yank(
292            lattice_grammar::Register::Named('b'),
293            "one\ntwo".to_string(),
294            lattice_grammar::effect::YankKind::Linewise,
295            true,
296        );
297        let snap = app.ad().snapshot.clone();
298        let ctx = app.build_picker_context(&snap);
299        let PickerInitResult::Inline(pairs) =
300            YankRingSource::new().init(&ctx, &[]).expect("inline")
301        else {
302            panic!("expected Inline");
303        };
304        let has_real_newlines = pairs.iter().any(
305            |(_, r)| matches!(r, RoutingPayload::SuppliedValue { value } if value.contains('\n')),
306        );
307        assert!(
308            has_real_newlines,
309            "a register's newlines must survive into the paste"
310        );
311    }
312
313    /// The routing payload carries the FULL text, not the one-line
314    /// display. Pasting the display would paste a truncation, and the
315    /// truncation is lossy exactly for the multi-line yanks the ring is
316    /// most useful for.
317    #[test]
318    fn a_multi_line_entry_folds_for_display_but_not_for_accept() {
319        use lattice_picker::picker_sources::YankRingSource;
320        let mut app = ring_ctx_app();
321        app.editor.store_yank(
322            lattice_grammar::Register::Unnamed,
323            "first\nsecond\nthird".into(),
324            lattice_grammar::effect::YankKind::Linewise,
325            true,
326        );
327        let snap = app.ad().snapshot.clone();
328        let ctx = app.build_picker_context(&snap);
329        let PickerInitResult::Inline(pairs) =
330            YankRingSource::new().init(&ctx, &[]).expect("inline")
331        else {
332            panic!("expected Inline");
333        };
334        let (cand, routing) = &pairs[0];
335        assert!(
336            !cand.display.contains('\n'),
337            "a picker row is one line; got {:?}",
338            cand.display
339        );
340        match routing {
341            RoutingPayload::SuppliedValue { value } => {
342                assert_eq!(
343                    value, "first\nsecond\nthird",
344                    "accept carries the real text"
345                );
346            }
347            other => panic!("expected SuppliedValue, got {other:?}"),
348        }
349    }
350
351    /// `YankKind` is in the marginalia because linewise and charwise
352    /// paste differently — hiding it makes paste unpredictable at the
353    /// moment the user is choosing between two similar-looking rows.
354    #[test]
355    fn the_yank_kind_reaches_the_marginalia() {
356        use lattice_picker::picker_sources::YankRingSource;
357        let mut app = ring_ctx_app();
358        app.editor.store_yank(
359            lattice_grammar::Register::Unnamed,
360            "a line".into(),
361            lattice_grammar::effect::YankKind::Linewise,
362            true,
363        );
364        let snap = app.ad().snapshot.clone();
365        let ctx = app.build_picker_context(&snap);
366        let PickerInitResult::Inline(pairs) =
367            YankRingSource::new().init(&ctx, &[]).expect("inline")
368        else {
369            panic!("expected Inline");
370        };
371        let has_kind = pairs[0].0.annotations.iter().any(|a| match a {
372            lattice_completion::Annotation::Styled { category, segments } => {
373                category.as_ref() == "yank-kind" && segments.iter().any(|s| s.text.contains("line"))
374            }
375            _ => false,
376        });
377        assert!(has_kind, "expected a yank-kind annotation reading `line`");
378    }
379
380    #[test]
381    fn an_empty_ring_says_so_rather_than_opening_blank() {
382        use lattice_picker::picker_sources::YankRingSource;
383        let app = ring_ctx_app();
384        let snap = app.ad().snapshot.clone();
385        let ctx = app.build_picker_context(&snap);
386        let err = YankRingSource::new().init(&ctx, &[]).unwrap_err();
387        assert!(err.contains("nothing has been yanked"), "got {err:?}");
388    }
389
390    #[test]
391    fn yank_ring_accept_fills_the_caller() {
392        use lattice_picker::PickerAcceptOutcome;
393        use lattice_picker::picker_sources::YankRingSource;
394        let app = ring_ctx_app();
395        let snap = app.ad().snapshot.clone();
396        let ctx = app.build_picker_context(&snap);
397        let outcome = YankRingSource::new()
398            .accept(
399                &ctx,
400                &RoutingPayload::SuppliedValue {
401                    value: "text".into(),
402                },
403            )
404            .expect("accept");
405        match outcome {
406            PickerAcceptOutcome::FillCaller { text } => assert_eq!(text, "text"),
407            other => panic!("expected FillCaller, got {other:?}"),
408        }
409    }
410
411    /// MR.3: the files source carries marginalia as typed `Styled`
412    /// annotations (perm / size / mtime columns), NOT baked into the
413    /// `display` string. `display` is the path so fuzzy matching runs on
414    /// the path; the renderer color-codes each annotation per its theme
415    /// slot. End-to-end through the source's `init` against a live
416    /// `PickerContext`.
417    #[test]
418    fn files_source_emits_metadata_annotations() {
419        let tmp = std::env::temp_dir().join(format!("lattice-files-margin-{}", std::process::id()));
420        let _ = std::fs::remove_dir_all(&tmp);
421        std::fs::create_dir_all(&tmp).unwrap();
422        std::fs::write(tmp.join("readme.md"), b"# hello").unwrap();
423        let app = app_with("hi\n", 5);
424        let snap = app.ad().snapshot.clone();
425        let ctx = app.build_picker_context(&snap);
426        let source = FilesSource::new();
427        let result = source
428            .init(&ctx, std::slice::from_ref(&tmp.display().to_string()))
429            .expect("inline");
430        let PickerInitResult::Inline(pairs) = result else {
431            panic!("expected Inline");
432        };
433        assert_eq!(pairs.len(), 1);
434        let cand = &pairs[0].0;
435        // display is the path only — metadata moved to annotations.
436        assert!(cand.display.contains("readme.md"), "got `{}`", cand.display);
437        assert!(
438            !cand.display.contains("minute") && !cand.display.contains("just now"),
439            "metadata must not leak into display: `{}`",
440            cand.display
441        );
442        // perm → size → mtime, each a Styled cell.
443        let cats: Vec<&str> = cand.annotations.iter().map(|a| a.category()).collect();
444        assert_eq!(cats, vec!["perm", "size", "mtime"]);
445        let size = cand
446            .annotations
447            .iter()
448            .find(|a| a.category() == "size")
449            .unwrap();
450        assert_eq!(size.display_text(), "7", "7-byte file → size `7`");
451        let mtime = cand
452            .annotations
453            .iter()
454            .find(|a| a.category() == "mtime")
455            .unwrap();
456        let mt = mtime.display_text();
457        assert!(
458            mt.contains("just now") || mt.contains("minute"),
459            "expected relative mtime, got `{mt}`"
460        );
461        let _ = std::fs::remove_dir_all(&tmp);
462    }
463
464    /// Empty workspace makes the files source's init return
465    /// `Err("files: no files under ...")` which the host
466    /// echoes verbatim.
467    #[test]
468    fn files_source_empty_root_errors() {
469        let tmp =
470            std::env::temp_dir().join(format!("lattice-files-src-empty-{}", std::process::id()));
471        let _ = std::fs::remove_dir_all(&tmp);
472        std::fs::create_dir_all(&tmp).unwrap();
473        let app = app_with("hi\n", 5);
474        let snap = app.ad().snapshot.clone();
475        let ctx = app.build_picker_context(&snap);
476        let source = FilesSource::new();
477        let err = source
478            .init(&ctx, std::slice::from_ref(&tmp.display().to_string()))
479            .unwrap_err();
480        assert!(err.starts_with("files: no files under"));
481        let _ = std::fs::remove_dir_all(&tmp);
482    }
483
484    /// Recent source: empty MRU returns `Err("no recent files")`.
485    #[test]
486    fn recent_source_empty_mru_errors() {
487        let app = app_with("hi\n", 5);
488        let snap = app.ad().snapshot.clone();
489        let ctx = app.build_picker_context(&snap);
490        let source = RecentFilesSource::new();
491        let err = source.init(&ctx, &[]).unwrap_err();
492        assert_eq!(err, "no recent files");
493    }
494
495    /// MP.3: a recent-files row for a stattable path carries the same
496    /// eza-style perm/size/mtime marginalia as the file picker.
497    #[test]
498    fn recent_source_emits_metadata_annotations() {
499        let mut app = app_with("hi\n", 5);
500        let tmp = std::env::temp_dir().join(format!(
501            "lattice-recent-meta-{}-{:?}",
502            std::process::id(),
503            std::thread::current().id()
504        ));
505        std::fs::write(&tmp, b"hello").unwrap();
506        app.editor.push_recent_file(&tmp);
507        let snap = app.ad().snapshot.clone();
508        let ctx = app.build_picker_context(&snap);
509        let source = RecentFilesSource::new();
510        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
511            panic!("expected Inline");
512        };
513        let row = pairs
514            .iter()
515            .find(|(c, _)| c.display.contains("lattice-recent-meta"))
516            .expect("recent row for the pushed temp file");
517        let cats: Vec<&str> = row.0.annotations.iter().map(|a| a.category()).collect();
518        assert!(
519            cats.contains(&"perm") && cats.contains(&"size") && cats.contains(&"mtime"),
520            "expected perm/size/mtime metadata, got {cats:?}"
521        );
522        let _ = std::fs::remove_file(&tmp);
523    }
524
525    /// MP.3: buffers source emits one row per registry entry; the
526    /// active buffer floats to the bottom and carries an active-status
527    /// marginalia cell (no inline `(current)` in the display). Each row
528    /// carries a buffer-id and a kind cell; the path is the display.
529    #[test]
530    fn buffers_source_inline_init_floats_active_to_bottom() {
531        let app = app_with("hi\n", 5);
532        let snap = app.ad().snapshot.clone();
533        let ctx = app.build_picker_context(&snap);
534        let source = BuffersSource::new();
535        let result = source.init(&ctx, &[]).expect("inline");
536        let PickerInitResult::Inline(pairs) = result else {
537            panic!("expected Inline");
538        };
539        assert!(!pairs.is_empty());
540        // Last row is the active buffer (floated to bottom).
541        let last = pairs.last().unwrap();
542        match &last.1 {
543            RoutingPayload::Buffer { id } => assert_eq!(*id, ctx.active_buffer.buffer_id),
544            other => panic!("expected Buffer routing, got {other:?}"),
545        }
546        let cand = &last.0;
547        // No inline markers leak into the matchable display.
548        assert!(!cand.display.contains("(current)"));
549        assert!(!cand.display.contains("[+]"));
550        let cat = |c: &str| {
551            cand.annotations
552                .iter()
553                .find(|a| a.category() == c)
554                .map(|a| a.display_text().into_owned())
555        };
556        // Buffer-id and kind cells present; active row has an active-status
557        // marker (`•`).
558        assert_eq!(
559            cat("buffer-id"),
560            Some(format!("#{}", ctx.active_buffer.buffer_id))
561        );
562        assert!(cat("kind").is_some(), "kind cell missing");
563        assert!(
564            cat("status").is_some_and(|s| s.contains('•')),
565            "active row missing active-status marker, got {:?}",
566            cat("status")
567        );
568    }
569
570    /// Slice 7b.1 probe: BuffersSource candidates carry the
571    /// typed `accept_action` (SwitchBuffer) — the
572    /// `DefaultAcceptHandler` reads it without the source
573    /// needing a custom handler impl. Parallel to the
574    /// existing RoutingPayload pair (kept alive through 7c).
575    #[test]
576    fn buffers_source_candidates_carry_typed_accept_action() {
577        use lattice_completion::{AcceptAction, AcceptHandler, DefaultAcceptHandler};
578
579        let app = app_with("hi\n", 5);
580        let snap = app.ad().snapshot.clone();
581        let ctx = app.build_picker_context(&snap);
582        let source = BuffersSource::new();
583        let result = source.init(&ctx, &[]).expect("inline");
584        let PickerInitResult::Inline(pairs) = result else {
585            panic!("expected Inline");
586        };
587        for (cand, routing) in &pairs {
588            // The candidate's typed accept must align with the
589            // legacy routing payload (both encode the same id).
590            let action = cand
591                .accept_action
592                .as_deref()
593                .expect("BuffersSource sets accept_action on every candidate");
594            let RoutingPayload::Buffer { id: routing_id } = routing else {
595                panic!("expected Buffer routing");
596            };
597            match action {
598                AcceptAction::SwitchBuffer { id } => assert_eq!(id.0, *routing_id),
599                other => panic!("expected SwitchBuffer, got {other:?}"),
600            }
601            // And DefaultAcceptHandler returns the same action.
602            let resolved = DefaultAcceptHandler.accept(cand).expect("handler ok");
603            assert_eq!(&resolved, action);
604        }
605    }
606
607    #[test]
608    fn first_party_generators_returns_all_built_in_sources() {
609        let app = app_with("hi\n", 5);
610        let generators = first_party_generators(
611            app.editor.registry.clone(),
612            app.editor.config.clone(),
613            empty_reverse(),
614            None,
615        );
616        let ids: Vec<&str> = generators.iter().map(|g| g.spec().id.as_ref()).collect();
617        assert_eq!(
618            ids,
619            vec![
620                "files",
621                // PC.9: `dir-pick` — `file-pick`'s directory peer, listing
622                // one level at a time instead of walking. Fourth entry to
623                // arrive by this test going red, which is the list doing
624                // its job.
625                // MG.53.e: `file-pick` — the same walk as `files`,
626                // accepting to a value instead of to an open buffer.
627                // Third time this list has needed updating for a new
628                // source, and the two notes below say the previous two
629                // were noticed only when it went red. That is the list
630                // working.
631                "file-pick",
632                "dir-pick",
633                // YR.4: the yank ring + named registers in one list.
634                "yank-ring",
635                "recent",
636                "buffers",
637                "lines",
638                "jumps",
639                "commands",
640                "history",
641                // MB.5: `search-history` (`q/` / `q?` / `:history
642                // search`) joined `first_party_generators` alongside
643                // the command-history source; this expectation listed
644                // only the latter and went stale when it landed.
645                "search-history",
646                // PBH.6: `:history pane-buffers`. Same lapse one slice
647                // later — this list is the single piece of bookkeeping
648                // a new source costs, and it went stale again.
649                "pane-buffer-history",
650                "registers",
651                "marks",
652                "grep",
653                "outline",
654            ]
655        );
656    }
657
658    /// P.6: jumps source returns `Err` when the position
659    /// history is empty -- a fresh App has nothing to walk
660    /// yet so the picker stays closed with a clean echo.
661    #[test]
662    fn jumps_source_empty_history_errors() {
663        let app = app_with("hi\n", 5);
664        let snap = app.ad().snapshot.clone();
665        let ctx = app.build_picker_context(&snap);
666        let source = JumpsSource::new();
667        let err = source.init(&ctx, &[]).unwrap_err();
668        assert!(err.contains("position history is empty"), "got {err}");
669    }
670
671    /// P.7: commands source emits one row per registered
672    /// ex-command, sorted, with `InvokeCommand` routing
673    /// payloads that strip the `ex:` registration prefix
674    /// (kept as the canonical id) while displaying the
675    /// user-facing alias the popup matches against.
676    #[test]
677    fn commands_source_emits_ex_commands_only() {
678        let app = app_with("hi\n", 5);
679        let snap = app.ad().snapshot.clone();
680        let ctx = app.build_picker_context(&snap);
681        let source = CommandsSource::new(app.editor.registry.clone(), empty_reverse());
682        let result = source.init(&ctx, &[]).expect("inline");
683        let PickerInitResult::Inline(pairs) = result else {
684            panic!("expected Inline");
685        };
686        assert!(!pairs.is_empty(), "should have at least one command");
687        // Every row routes through InvokeCommand carrying the
688        // canonical `ex:`-prefixed registration name.
689        for (cand, routing) in &pairs {
690            match routing {
691                RoutingPayload::InvokeCommand { id, .. } => {
692                    // Routing payload carries the canonical
693                    // registration name verbatim (with `ex:`
694                    // prefix where the command uses one, bare
695                    // otherwise -- mode toggles like
696                    // `buffer-words-mode` register without).
697                    assert!(!id.is_empty());
698                }
699                other => panic!("expected InvokeCommand, got {other:?}"),
700            }
701            // Display text strips any `ex:` prefix so the popup
702            // matches what the user would type at `:`.
703            assert!(!cand.text.starts_with("ex:"), "got {}", cand.text);
704        }
705        // Sorted: alphabetic by user-facing name.
706        let texts: Vec<&str> = pairs.iter().map(|(c, _)| c.text.as_str()).collect();
707        let mut sorted = texts.clone();
708        sorted.sort();
709        assert_eq!(texts, sorted);
710    }
711
712    /// MP.2: command rows carry args-hint + doc + latency as typed
713    /// marginalia annotations (not a flat display string). The name is
714    /// the matchable `display`. Confirms by finding `write` (a known
715    /// ex-command) and checking its annotation set.
716    #[test]
717    fn commands_source_emits_marginalia_annotations() {
718        use lattice_completion::Annotation;
719        let app = app_with("hi\n", 5);
720        let snap = app.ad().snapshot.clone();
721        let ctx = app.build_picker_context(&snap);
722        let source = CommandsSource::new(app.editor.registry.clone(), empty_reverse());
723        let result = source.init(&ctx, &[]).expect("inline");
724        let PickerInitResult::Inline(pairs) = result else {
725            panic!("expected Inline");
726        };
727        let write_row = pairs
728            .iter()
729            .find(|(c, _)| c.text == "write")
730            .expect("write command row");
731        let cand = &write_row.0;
732        // Name is the matchable display, no hand-padded columns.
733        assert_eq!(cand.display, "write");
734        let by_cat = |cat: &str| {
735            cand.annotations
736                .iter()
737                .find(|a| a.category() == cat)
738                .map(|a| a.display_text().into_owned())
739        };
740        // Args hint for `:write` is `[<path>]` (optional arg).
741        assert_eq!(by_cat("args").as_deref(), Some("[<path>]"));
742        // Latency: `:write` is `Display`-class.
743        assert_eq!(by_cat("latency").as_deref(), Some("[display]"));
744        // Doc cell is present and non-empty.
745        assert!(
746            by_cat("doc").is_some_and(|d| d.contains("Write")),
747            "expected doc annotation containing `Write`, got {:?}",
748            by_cat("doc")
749        );
750        // Every command-picker annotation is the expected typed shape.
751        assert!(
752            cand.annotations
753                .iter()
754                .all(|a| matches!(a, Annotation::Styled { .. } | Annotation::DocSnippet(_)))
755        );
756    }
757
758    /// MP.2b: a command with a bound chord carries a
759    /// `Keybinding` annotation; one without carries none. Uses a
760    /// deterministic stub reverse-lookup so the assertion is
761    /// independent of whatever the default keymap happens to
762    /// bind.
763    #[test]
764    fn commands_source_emits_keybinding_annotation() {
765        use lattice_completion::Annotation;
766        use lattice_protocol::KeyChord;
767
768        struct Stub;
769        impl lattice_completion::KeymapReverseLookup for Stub {
770            fn chords_for(&self, name: &str) -> Vec<KeyChord> {
771                // `:write` registers canonically as `ex:write`.
772                if name == "ex:write" {
773                    vec![KeyChord::ctrl('s')]
774                } else {
775                    Vec::new()
776                }
777            }
778        }
779
780        let app = app_with("hi\n", 5);
781        let snap = app.ad().snapshot.clone();
782        let ctx = app.build_picker_context(&snap);
783        let source = CommandsSource::new(app.editor.registry.clone(), Arc::new(Stub));
784        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
785            panic!("expected Inline");
786        };
787
788        let keybinding_of = |name: &str| -> Option<String> {
789            pairs
790                .iter()
791                .find(|(c, _)| c.text == name)
792                .expect("row present")
793                .0
794                .annotations
795                .iter()
796                .find(|a| matches!(a, Annotation::Keybinding(_)))
797                .map(|a| a.display_text().into_owned())
798        };
799
800        // `write` is bound in the stub → keybinding cell present,
801        // rendering the chord (`<C-s>`).
802        let write_kb = keybinding_of("write").expect("write has a keybinding annotation");
803        assert!(write_kb.contains("C-s"), "got {write_kb:?}");
804
805        // A command the stub does not bind carries no keybinding
806        // annotation (blank cell, not a zero-width span).
807        assert_eq!(
808            keybinding_of("quit"),
809            None,
810            "unbound command must not push an empty keybinding annotation"
811        );
812    }
813
814    /// MP.2b: end-to-end through the real keymap adapter boot
815    /// wires in. `ex:help` is bound to `<C-h><C-h>` (help-prefix
816    /// table), so the live commands picker surfaces that chord on
817    /// the `help` row — proving the reverse-cache → annotation
818    /// path, not just the stub.
819    #[test]
820    fn commands_source_keybinding_from_real_keymap() {
821        use lattice_completion::Annotation;
822
823        let app = app_with("hi\n", 5);
824        let snap = app.ad().snapshot.clone();
825        let ctx = app.build_picker_context(&snap);
826        let source = CommandsSource::new(app.editor.registry.clone(), app_reverse(&app));
827        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
828            panic!("expected Inline");
829        };
830
831        let help_kb = pairs
832            .iter()
833            .find(|(c, _)| c.text == "help")
834            .expect("help row present")
835            .0
836            .annotations
837            .iter()
838            .find_map(|a| match a {
839                Annotation::Keybinding(_) => Some(a.display_text().into_owned()),
840                _ => None,
841            })
842            .expect("help carries a keybinding from the real keymap");
843        assert!(help_kb.contains("C-h"), "got {help_kb:?}");
844    }
845
846    /// P.7: accept on `InvokeCommand` routing returns the
847    /// matching outcome, carrying the canonical id +
848    /// supplied args verbatim.
849    #[test]
850    fn commands_source_accept_translates_invoke_command() {
851        let app = app_with("hi\n", 5);
852        let snap = app.ad().snapshot.clone();
853        let ctx = app.build_picker_context(&snap);
854        let source = CommandsSource::new(app.editor.registry.clone(), empty_reverse());
855        let routing = RoutingPayload::InvokeCommand {
856            id: "ex:write".into(),
857            args: Args::None,
858        };
859        let outcome = source.accept(&ctx, &routing).expect("ok");
860        match outcome {
861            PickerAcceptOutcome::InvokeCommand { id, args } => {
862                assert_eq!(id, "ex:write");
863                assert!(matches!(args, Args::None));
864            }
865            other => panic!("expected InvokeCommand, got {other:?}"),
866        }
867        let bad = RoutingPayload::OpenFile {
868            path: "/tmp/x".into(),
869        };
870        assert!(source.accept(&ctx, &bad).is_err());
871    }
872
873    /// P.4: registers source returns `Err` when the context
874    /// has no registers (empty `ctx.registers`).
875    #[test]
876    fn registers_source_empty_errors() {
877        let app = app_with("hi\n", 5);
878        let snap = app.ad().snapshot.clone();
879        let ctx = app.build_picker_context(&snap);
880        let source = RegistersSource::new();
881        let err = source.init(&ctx, &[]).unwrap_err();
882        assert!(err.contains("no registers set"), "got {err}");
883    }
884
885    /// P.4: synthesise a couple of register entries on the
886    /// context and confirm rows route through
887    /// `PasteRegister`.
888    #[test]
889    fn registers_source_emits_paste_routing() {
890        let app = app_with("hi\n", 5);
891        let snap = app.ad().snapshot.clone();
892        let mut ctx = app.build_picker_context(&snap);
893        ctx.registers = vec![("\"".into(), "hello".into()), ("a".into(), "world".into())];
894        let source = RegistersSource::new();
895        let result = source.init(&ctx, &[]).expect("inline");
896        let PickerInitResult::Inline(pairs) = result else {
897            panic!("expected Inline");
898        };
899        assert_eq!(pairs.len(), 2);
900        match &pairs[0].1 {
901            RoutingPayload::PasteRegister { name } => assert_eq!(*name, '"'),
902            other => panic!("expected PasteRegister, got {other:?}"),
903        }
904        match &pairs[1].1 {
905            RoutingPayload::PasteRegister { name } => assert_eq!(*name, 'a'),
906            other => panic!("expected PasteRegister, got {other:?}"),
907        }
908        // MP.5: register contents are the matchable display; the name
909        // (`"a`) is a `register` marginalia cell.
910        assert_eq!(pairs[1].0.display, "world");
911        let reg = pairs[1]
912            .0
913            .annotations
914            .iter()
915            .find(|a| a.category() == "register")
916            .expect("register cell");
917        assert_eq!(reg.display_text(), "\"a");
918    }
919
920    /// P.4: accept on a `PasteRegister` routing returns the
921    /// matching outcome; mismatched routing errors.
922    #[test]
923    fn registers_source_accept_translates_paste_register() {
924        let app = app_with("hi\n", 5);
925        let snap = app.ad().snapshot.clone();
926        let ctx = app.build_picker_context(&snap);
927        let source = RegistersSource::new();
928        let routing = RoutingPayload::PasteRegister { name: 'a' };
929        match source.accept(&ctx, &routing).expect("ok") {
930            PickerAcceptOutcome::PasteRegister { name } => assert_eq!(name, 'a'),
931            other => panic!("expected PasteRegister outcome, got {other:?}"),
932        }
933        let bad = RoutingPayload::OpenFile {
934            path: "/tmp/x".into(),
935        };
936        assert!(source.accept(&ctx, &bad).is_err());
937    }
938
939    /// MB.3: `history` source errors when the command-line
940    /// history ring is empty (nothing to pick).
941    #[test]
942    fn history_source_empty_errors() {
943        let app = app_with("hi\n", 5);
944        let snap = app.ad().snapshot.clone();
945        let ctx = app.build_picker_context(&snap);
946        let source = CommandHistorySource::new();
947        let err = source.init(&ctx, &[]).unwrap_err();
948        assert!(err.contains("no command-line history"), "got {err}");
949    }
950
951    /// MB.3: the `history` source walks `command_history`
952    /// **newest-first** and emits one `LoadCommandLine` row per
953    /// entry (the display is the raw command text).
954    #[test]
955    fn history_source_emits_load_command_line_newest_first() {
956        let app = app_with("hi\n", 5);
957        let snap = app.ad().snapshot.clone();
958        let mut ctx = app.build_picker_context(&snap);
959        // Stored oldest-first, exactly as the App's ring holds it.
960        ctx.command_history = vec![
961            "set number".into(),
962            "write".into(),
963            "edit src/main.rs".into(),
964        ];
965        let source = CommandHistorySource::new();
966        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
967            panic!("expected Inline");
968        };
969        assert_eq!(pairs.len(), 3);
970        // Newest (last-pushed) floats to the top.
971        assert_eq!(pairs[0].0.display, "edit src/main.rs");
972        assert_eq!(pairs[2].0.display, "set number");
973        match &pairs[0].1 {
974            RoutingPayload::LoadCommandLine { text } => assert_eq!(text, "edit src/main.rs"),
975            other => panic!("expected LoadCommandLine, got {other:?}"),
976        }
977    }
978
979    /// MB.3: accept on a `LoadCommandLine` routing returns the
980    /// matching load-into-`:`-line outcome (no execution);
981    /// a mismatched routing errors.
982    #[test]
983    fn history_source_accept_translates_load_command_line() {
984        let app = app_with("hi\n", 5);
985        let snap = app.ad().snapshot.clone();
986        let ctx = app.build_picker_context(&snap);
987        let source = CommandHistorySource::new();
988        let routing = RoutingPayload::LoadCommandLine {
989            text: "write".into(),
990        };
991        match source.accept(&ctx, &routing).expect("ok") {
992            PickerAcceptOutcome::LoadCommandLine { text } => assert_eq!(text, "write"),
993            other => panic!("expected LoadCommandLine outcome, got {other:?}"),
994        }
995        let bad = RoutingPayload::OpenFile {
996            path: "/tmp/x".into(),
997        };
998        assert!(source.accept(&ctx, &bad).is_err());
999    }
1000
1001    /// Slice 3: `:picker grep` with no pattern (or an empty
1002    /// arg) opens an empty picker -- the user types into the
1003    /// prompt and the live flow fires `on_query_changed` on
1004    /// each debounced keystroke. The pre-slice-3 "pattern
1005    /// required" error is gone; no-arg is the canonical entry
1006    /// point for live grep.
1007    #[test]
1008    fn grep_source_empty_args_returns_empty_inline() {
1009        let app = app_with("hi\n", 5);
1010        let snap = app.ad().snapshot.clone();
1011        let ctx = app.build_picker_context(&snap);
1012        let source = GrepSource::new(app.editor.config.clone(), None);
1013        let result = source
1014            .init(&ctx, &[])
1015            .expect("init must not error on no args");
1016        match result {
1017            lattice_picker::PickerInitResult::Inline(pairs) => assert!(pairs.is_empty()),
1018            other => panic!("expected Inline(empty), got {other:?}"),
1019        }
1020        let result = source
1021            .init(&ctx, &[String::new()])
1022            .expect("init must not error on empty arg");
1023        match result {
1024            lattice_picker::PickerInitResult::Inline(pairs) => assert!(pairs.is_empty()),
1025            other => panic!("expected Inline(empty) for empty arg, got {other:?}"),
1026        }
1027    }
1028
1029    /// Slice 3: empty query through `on_query_changed`
1030    /// short-circuits to `Inline(empty)` -- no grep spawn, no
1031    /// UI block on the spawn-blocking pool.
1032    #[test]
1033    fn grep_source_on_query_changed_empty_short_circuits() {
1034        let app = app_with("hi\n", 5);
1035        let snap = app.ad().snapshot.clone();
1036        let ctx = app.build_picker_context(&snap);
1037        let source = GrepSource::new(app.editor.config.clone(), None);
1038        let result = source
1039            .on_query_changed(&ctx, "")
1040            .expect("live source returns Some")
1041            .expect("no error");
1042        match result {
1043            lattice_picker::PickerInitResult::Inline(pairs) => assert!(pairs.is_empty()),
1044            other => panic!("expected Inline(empty), got {other:?}"),
1045        }
1046        // Whitespace-only is treated the same.
1047        let result = source
1048            .on_query_changed(&ctx, "   ")
1049            .expect("live source returns Some")
1050            .expect("no error");
1051        match result {
1052            lattice_picker::PickerInitResult::Inline(pairs) => assert!(pairs.is_empty()),
1053            other => panic!("expected Inline(empty) for whitespace query, got {other:?}"),
1054        }
1055    }
1056
1057    /// Slice 3: GrepSource is declared live; the picker must
1058    /// see `spec.live == true` so it bypasses fuzzy refilter
1059    /// and the host routes keystrokes through
1060    /// `on_query_changed`.
1061    #[test]
1062    fn grep_source_spec_is_live() {
1063        let app = app_with("hi\n", 5);
1064        let source = GrepSource::new(app.editor.config.clone(), None);
1065        assert!(source.spec().live, "GrepSource must declare live = true");
1066    }
1067
1068    /// P.8: explicit `picker.grep.backend = "definitely-not-a-binary"`
1069    /// surfaces an actionable error before any subprocess
1070    /// is spawned.
1071    #[test]
1072    fn grep_source_unknown_backend_errors() {
1073        let app = app_with("hi\n", 5);
1074        app.editor
1075            .config
1076            .parse_and_set_command("picker.grep.backend=definitely-not-a-binary")
1077            .unwrap();
1078        let snap = app.ad().snapshot.clone();
1079        let ctx = app.build_picker_context(&snap);
1080        let source = GrepSource::new(app.editor.config.clone(), None);
1081        let err = source.init(&ctx, &["TODO".to_string()]).unwrap_err();
1082        assert!(err.contains("definitely-not-a-binary"), "got {err}");
1083        assert!(err.contains("not found on PATH"), "got {err}");
1084    }
1085
1086    /// Slice 14d: a test-only source that returns
1087    /// `PickerInitResult::Future` so the async-init seat
1088    /// path is exercised end-to-end. The future resolves
1089    /// after a single tokio yield; the host's
1090    /// `drain_pending_picker_init` pumps the channel and
1091    /// seats the picker.
1092    struct DelayedFutureSource {
1093        spec: PickerSourceSpec,
1094    }
1095
1096    impl DelayedFutureSource {
1097        fn new() -> Self {
1098            Self {
1099                spec: PickerSourceSpec::no_args(
1100                    "delayed-test",
1101                    "Test-only async source that resolves to one OpenFile candidate.",
1102                ),
1103            }
1104        }
1105    }
1106
1107    impl PickerSourceGenerator for DelayedFutureSource {
1108        fn spec(&self) -> &PickerSourceSpec {
1109            &self.spec
1110        }
1111
1112        fn init(
1113            &self,
1114            _ctx: &PickerContext<'_>,
1115            _args: &[String],
1116        ) -> SourceResult<PickerInitResult> {
1117            let fut = Box::pin(async move {
1118                // One yield so the future genuinely defers
1119                // -- mirrors a real LSP request that resolves
1120                // after a network round-trip.
1121                tokio::task::yield_now().await;
1122                let cand = RawCandidate::plain(String::from("test-result"), CandidateKind::Plain);
1123                Ok(vec![(
1124                    cand,
1125                    RoutingPayload::OpenFile {
1126                        path: "/tmp/lattice-test-future".into(),
1127                    },
1128                )])
1129            });
1130            Ok(PickerInitResult::Future(fut))
1131        }
1132
1133        fn accept(
1134            &self,
1135            _ctx: &PickerContext<'_>,
1136            _routing: &RoutingPayload,
1137        ) -> SourceResult<PickerAcceptOutcome> {
1138            Ok(PickerAcceptOutcome::NoOp)
1139        }
1140    }
1141
1142    /// Slice 14d: `:picker <source>` against a Future-returning
1143    /// source spawns the future, queues the result via mpsc,
1144    /// and seats the picker after the host's drain runs.
1145    /// Confirms the spawn + try_recv + seat_picker_from_pairs
1146    /// path works end-to-end.
1147    #[test]
1148    fn async_init_seat_path_pumps_future_result_into_picker() {
1149        use std::time::Duration;
1150
1151        let mut app = app_with("hi\n", 5);
1152        // Build a fresh registry with the test source. We
1153        // can't mutate the App's shared registry (other Arcs
1154        // exist), so replace it wholesale.
1155        let mut reg = lattice_picker::PickerRegistry::new();
1156        let source: Arc<dyn PickerSourceGenerator> = Arc::new(DelayedFutureSource::new());
1157        reg.register_generator(source);
1158        app.editor.picker_registry.store(Arc::new(reg));
1159        // Fire the picker. Init returns Future; the picker
1160        // should NOT seat synchronously.
1161        app.open_picker("delayed-test".into(), Vec::new(), None, None);
1162        assert!(
1163            app.editor.picker.is_none(),
1164            "picker shouldn't seat sync on Future"
1165        );
1166        assert!(
1167            app.editor.pending_picker_init.is_some(),
1168            "pending should be set"
1169        );
1170        // Pump the drain. The future needs at least one tokio
1171        // poll to resolve -- we give the spawned task a
1172        // chance to land by sleeping briefly.
1173        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1174        while std::time::Instant::now() < deadline {
1175            app.drain_pending_picker_init();
1176            if app.editor.picker.is_some() {
1177                break;
1178            }
1179            std::thread::sleep(Duration::from_millis(10));
1180        }
1181        let p = app
1182            .editor
1183            .picker
1184            .as_ref()
1185            .expect("picker seated after drain");
1186        assert_eq!(p.candidates.len(), 1);
1187        assert_eq!(p.source_id.as_deref(), Some("delayed-test"));
1188    }
1189
1190    /// Reported from real use: `<Esc>` on a picker that was still
1191    /// loading did nothing, and the picker then appeared anyway with its
1192    /// first row selected — indistinguishable from having picked for
1193    /// you.
1194    ///
1195    /// A non-live async source is *parked*: `editor.picker` stays `None`
1196    /// while the future runs, so `picker_open` was false and the key
1197    /// went to the ordinary modal dispatcher instead of the picker
1198    /// router. `do_picker_dismiss` also never cleared
1199    /// `pending_picker_init`, so the dismiss was lost twice over.
1200    #[test]
1201    fn esc_cancels_a_picker_that_has_not_finished_loading() {
1202        use std::time::Duration;
1203
1204        let mut app = app_with("hi\n", 5);
1205        let mut reg = lattice_picker::PickerRegistry::new();
1206        let source: Arc<dyn PickerSourceGenerator> = Arc::new(DelayedFutureSource::new());
1207        reg.register_generator(source);
1208        app.editor.picker_registry.store(Arc::new(reg));
1209
1210        app.open_picker("delayed-test".into(), Vec::new(), None, None);
1211        assert!(app.editor.picker.is_none(), "parked, not seated");
1212        assert!(app.editor.pending_picker_init.is_some(), "future in flight");
1213        // The picker owns the keyboard from the moment it is asked for,
1214        // not from the moment it seats — otherwise keys typed while
1215        // waiting run as Normal-mode commands against the buffer behind.
1216        assert!(
1217            app.ad().picker_open,
1218            "a parked picker must still report open, or `<Esc>` never \
1219             reaches the picker router at all"
1220        );
1221
1222        app.apply(crate::Action::PickerDismiss);
1223        assert!(
1224            app.editor.pending_picker_init.is_none(),
1225            "dismiss must cancel the in-flight init"
1226        );
1227
1228        // And it must STAY cancelled: drain for as long as the seating
1229        // test waits for a successful seat, so this cannot pass merely
1230        // by being checked too early.
1231        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1232        while std::time::Instant::now() < deadline {
1233            app.drain_pending_picker_init();
1234            assert!(
1235                app.editor.picker.is_none(),
1236                "a cancelled picker must never seat"
1237            );
1238            std::thread::sleep(Duration::from_millis(10));
1239        }
1240    }
1241
1242    /// P.9: outline source returns `Err` when the active
1243    /// buffer has no tree-sitter symbols (plain text, or a
1244    /// language without a `symbols.scm` query).
1245    #[test]
1246    fn outline_source_no_symbols_errors() {
1247        let app = app_with("hi\n", 5);
1248        let snap = app.ad().snapshot.clone();
1249        let ctx = app.build_picker_context(&snap);
1250        let source = OutlineSource::new();
1251        let err = source.init(&ctx, &[]).unwrap_err();
1252        assert!(err.starts_with("outline:"), "got {err}");
1253    }
1254
1255    /// P.9: synthesised syntax_symbols on the context produce
1256    /// one row per symbol with `JumpInBuffer` routing carrying
1257    /// the captured buffer id + (line, col) coordinates.
1258    #[test]
1259    fn outline_source_emits_jump_in_buffer_routing() {
1260        let app = app_with("hi\n", 5);
1261        let snap = app.ad().snapshot.clone();
1262        let mut ctx = app.build_picker_context(&snap);
1263        let active_id = ctx.active_buffer.buffer_id;
1264        ctx.active_buffer.syntax_symbols =
1265            vec![("foo".to_string(), 2, 4), ("bar".to_string(), 10, 0)];
1266        let source = OutlineSource::new();
1267        let result = source.init(&ctx, &[]).expect("inline");
1268        let PickerInitResult::Inline(pairs) = result else {
1269            panic!("expected Inline");
1270        };
1271        assert_eq!(pairs.len(), 2);
1272        match &pairs[0].1 {
1273            RoutingPayload::JumpInBuffer {
1274                buffer_id,
1275                line,
1276                col,
1277            } => {
1278                assert_eq!(*buffer_id, active_id);
1279                assert_eq!(*line, 2);
1280                assert_eq!(*col, 4);
1281            }
1282            other => panic!("expected JumpInBuffer, got {other:?}"),
1283        }
1284        // MP.4: symbol name is the matchable display; the 1-based line
1285        // moves to a `location` marginalia cell.
1286        assert_eq!(pairs[0].0.display, "foo");
1287        let loc = pairs[0]
1288            .0
1289            .annotations
1290            .iter()
1291            .find(|a| a.category() == "location")
1292            .expect("location cell");
1293        assert_eq!(loc.display_text(), "3");
1294    }
1295
1296    /// PH.2: a parsed buffer's `:picker lines` rows carry
1297    /// `display_spans` syntax-aligned to the line text — the host
1298    /// pre-collects per-line spans via `highlight_lines` and the
1299    /// source maps them 1:1. Proves the end-to-end wiring (host
1300    /// build_picker_context → DisplaySpan → LinesSource).
1301    #[test]
1302    fn lines_source_emits_display_spans_for_parsed_buffer() {
1303        let src = "fn foo() {}\n";
1304        let mut app = app_with(src, 5);
1305        // Attach a parsed Rust tree (mirrors the `set_rust_syntax`
1306        // app-module helper, inlined here since it's `pub(super)`).
1307        let mut syn = lattice_syntax::Syntax::for_language(lattice_syntax::Lang::Rust)
1308            .unwrap()
1309            .expect("rust syntax");
1310        syn.parse_at(src, 0);
1311        app.editor.syntax = Some(lattice_syntax::SyntaxHandle::seeded(syn));
1312        let snap = app.ad().snapshot.clone();
1313        let ctx = app.build_picker_context(&snap);
1314        let source = LinesSource::new();
1315        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
1316            panic!("expected Inline");
1317        };
1318        let first = &pairs[0].0;
1319        assert_eq!(first.display, "fn foo() {}");
1320        assert!(
1321            !first.display_spans.is_empty(),
1322            "a parsed code line should carry syntax spans"
1323        );
1324        // Every span stays within the displayed byte length.
1325        assert!(
1326            first
1327                .display_spans
1328                .iter()
1329                .all(|s| s.range.end <= first.display.len()),
1330            "spans must be clipped to the display run"
1331        );
1332        // The leading `fn` keyword resolves to the Keyword style.
1333        assert!(
1334            first
1335                .display_spans
1336                .iter()
1337                .any(|s| s.style == lattice_cells::style::Style::Keyword),
1338            "expected a keyword-styled span for `fn`"
1339        );
1340    }
1341
1342    /// PH.2: with no grammar attached, `:picker lines` rows carry
1343    /// no `display_spans` — today's plain preview, no panic.
1344    #[test]
1345    fn lines_source_no_grammar_emits_no_display_spans() {
1346        let app = app_with("fn foo() {}\n", 5); // no syntax handle attached
1347        let snap = app.ad().snapshot.clone();
1348        let ctx = app.build_picker_context(&snap);
1349        let source = LinesSource::new();
1350        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
1351            panic!("expected Inline");
1352        };
1353        assert!(
1354            pairs.iter().all(|(c, _)| c.display_spans.is_empty()),
1355            "no grammar → plain preview"
1356        );
1357    }
1358
1359    /// PH.2: `:picker outline` projects a line's syntax spans
1360    /// onto the symbol-name column — line-relative spans clipped
1361    /// to `[col, col+name_len)` and shifted to be name-relative.
1362    /// Synthesised so the projection math is asserted exactly,
1363    /// independent of the live parse.
1364    #[test]
1365    fn outline_source_projects_display_spans_onto_symbol_name() {
1366        let app = app_with("fn foo() {}\n", 5);
1367        let snap = app.ad().snapshot.clone();
1368        let mut ctx = app.build_picker_context(&snap);
1369        // "foo" symbol at line 0, byte-col 3.
1370        ctx.active_buffer.syntax_symbols = vec![("foo".to_string(), 0, 3)];
1371        // Line 0 spans: "fn" keyword (0..2), "foo" function (3..6).
1372        ctx.active_buffer.syntax_highlights = vec![vec![
1373            lattice_completion::DisplaySpan {
1374                range: 0..2,
1375                style: lattice_cells::style::Style::Keyword,
1376            },
1377            lattice_completion::DisplaySpan {
1378                range: 3..6,
1379                style: lattice_cells::style::Style::Function,
1380            },
1381        ]];
1382        let source = OutlineSource::new();
1383        let PickerInitResult::Inline(pairs) = source.init(&ctx, &[]).expect("inline") else {
1384            panic!("expected Inline");
1385        };
1386        let foo = &pairs[0].0;
1387        assert_eq!(foo.display, "foo");
1388        // Only the function span survives, shifted to name-relative
1389        // 0..3; the keyword span (outside the name column) is dropped.
1390        assert_eq!(foo.display_spans.len(), 1);
1391        assert_eq!(foo.display_spans[0].range, 0..3);
1392        assert_eq!(
1393            foo.display_spans[0].style,
1394            lattice_cells::style::Style::Function
1395        );
1396    }
1397
1398    /// P.5: marks source returns `Err` when no marks set.
1399    #[test]
1400    fn marks_source_empty_errors() {
1401        let app = app_with("hi\n", 5);
1402        let snap = app.ad().snapshot.clone();
1403        let ctx = app.build_picker_context(&snap);
1404        let source = MarksSource::new();
1405        let err = source.init(&ctx, &[]).unwrap_err();
1406        assert!(err.contains("no marks set"), "got {err}");
1407    }
1408
1409    /// P.5: synthesise marks on the context, confirm rows
1410    /// route through `JumpToMark` and display the line:col.
1411    #[test]
1412    fn marks_source_emits_jump_to_mark_routing() {
1413        use lattice_protocol::Position;
1414
1415        let app = app_with("hi\n", 5);
1416        let snap = app.ad().snapshot.clone();
1417        let mut ctx = app.build_picker_context(&snap);
1418        ctx.marks = vec![('a', Position::new(2, 0)), ('b', Position::new(5, 3))];
1419        let source = MarksSource::new();
1420        let result = source.init(&ctx, &[]).expect("inline");
1421        let PickerInitResult::Inline(pairs) = result else {
1422            panic!("expected Inline");
1423        };
1424        assert_eq!(pairs.len(), 2);
1425        match &pairs[0].1 {
1426            RoutingPayload::JumpToMark { name } => assert_eq!(*name, 'a'),
1427            other => panic!("expected JumpToMark, got {other:?}"),
1428        }
1429        // MP.4: mark name is the matchable display; 1-based line:col is a
1430        // `location` marginalia cell.
1431        let loc = |i: usize| {
1432            pairs[i]
1433                .0
1434                .annotations
1435                .iter()
1436                .find(|a| a.category() == "location")
1437                .map(|a| a.display_text().into_owned())
1438        };
1439        assert_eq!(pairs[0].0.display, "'a");
1440        assert_eq!(loc(0).as_deref(), Some("3:1"));
1441        assert_eq!(pairs[1].0.display, "'b");
1442        assert_eq!(loc(1).as_deref(), Some("6:4"));
1443    }
1444
1445    /// P.5: accept on a `JumpToMark` routing returns the
1446    /// matching outcome verbatim.
1447    #[test]
1448    fn marks_source_accept_translates_jump_to_mark() {
1449        let app = app_with("hi\n", 5);
1450        let snap = app.ad().snapshot.clone();
1451        let ctx = app.build_picker_context(&snap);
1452        let source = MarksSource::new();
1453        let routing = RoutingPayload::JumpToMark { name: 'm' };
1454        match source.accept(&ctx, &routing).expect("ok") {
1455            PickerAcceptOutcome::JumpToMark { name } => assert_eq!(name, 'm'),
1456            other => panic!("expected JumpToMark outcome, got {other:?}"),
1457        }
1458        let bad = RoutingPayload::OpenFile {
1459            path: "/tmp/x".into(),
1460        };
1461        assert!(source.accept(&ctx, &bad).is_err());
1462    }
1463
1464    /// P.6: synthesise a couple of position-history entries
1465    /// (the App's ring is private at this layer but the
1466    /// PickerContext carries an owned vec we can substitute
1467    /// for the test). Confirm the source emits newest-first
1468    /// with the appropriate source tags + `JumpInBuffer`
1469    /// routing.
1470    #[test]
1471    fn jumps_source_emits_newest_first_with_source_tags() {
1472        use lattice_picker::{PositionEntry, PositionSource};
1473
1474        let app = app_with("hi\n", 5);
1475        let snap = app.ad().snapshot.clone();
1476        let mut ctx = app.build_picker_context(&snap);
1477        ctx.position_history = vec![
1478            PositionEntry {
1479                buffer_id: 1,
1480                line: 0,
1481                col: 0,
1482                source: PositionSource::AutoJump,
1483            },
1484            PositionEntry {
1485                buffer_id: 1,
1486                line: 5,
1487                col: 2,
1488                source: PositionSource::NamedMark('a'),
1489            },
1490            PositionEntry {
1491                buffer_id: 2,
1492                line: 10,
1493                col: 0,
1494                source: PositionSource::PluginPush,
1495            },
1496        ];
1497        let source = JumpsSource::new();
1498        let result = source.init(&ctx, &[]).expect("inline");
1499        let PickerInitResult::Inline(pairs) = result else {
1500            panic!("expected Inline");
1501        };
1502        assert_eq!(pairs.len(), 3);
1503        // Newest first: plugin (line 10) leads.
1504        match &pairs[0].1 {
1505            RoutingPayload::JumpInBuffer {
1506                buffer_id,
1507                line,
1508                col,
1509            } => {
1510                assert_eq!(*buffer_id, 2);
1511                assert_eq!(*line, 10);
1512                assert_eq!(*col, 0);
1513            }
1514            other => panic!("expected JumpInBuffer, got {other:?}"),
1515        }
1516        // MP.4: the provenance tag is a `Source` marginalia cell.
1517        let source_tag = |i: usize| {
1518            pairs[i]
1519                .0
1520                .annotations
1521                .iter()
1522                .find(|a| a.category() == "source")
1523                .map(|a| a.display_text().into_owned())
1524        };
1525        assert_eq!(source_tag(1).as_deref(), Some("'a"));
1526        assert_eq!(source_tag(2).as_deref(), Some("auto"));
1527        // Each row also carries a line:col location cell.
1528        assert!(
1529            pairs[0]
1530                .0
1531                .annotations
1532                .iter()
1533                .any(|a| a.category() == "location")
1534        );
1535    }
1536
1537    /// P.3: lines source emits one row per addressable line
1538    /// in the active buffer, with `JumpInBuffer` routing
1539    /// payloads carrying the captured buffer id.
1540    #[test]
1541    fn lines_source_emits_row_per_line() {
1542        let app = app_with("alpha\nbeta\ngamma\n", 10);
1543        let snap = app.ad().snapshot.clone();
1544        let ctx = app.build_picker_context(&snap);
1545        let active_id = ctx.active_buffer.buffer_id;
1546        let source = LinesSource::new();
1547        let result = source.init(&ctx, &[]).expect("inline");
1548        let PickerInitResult::Inline(pairs) = result else {
1549            panic!("expected Inline");
1550        };
1551        // 3 addressable lines; the trailing-empty phantom is dropped.
1552        assert_eq!(pairs.len(), 3);
1553        for (i, (cand, routing)) in pairs.iter().enumerate() {
1554            match routing {
1555                RoutingPayload::JumpInBuffer {
1556                    buffer_id,
1557                    line,
1558                    col,
1559                } => {
1560                    assert_eq!(*buffer_id, active_id);
1561                    assert_eq!(*line, i as u32);
1562                    assert_eq!(*col, 0);
1563                }
1564                other => panic!("expected JumpInBuffer, got {other:?}"),
1565            }
1566            // MP.4: the line text is the matchable display; the 1-based
1567            // line number is a `location` marginalia cell.
1568            let loc = cand
1569                .annotations
1570                .iter()
1571                .find(|a| a.category() == "location")
1572                .map(|a| a.display_text().into_owned());
1573            assert_eq!(loc.as_deref(), Some((i + 1).to_string().as_str()));
1574        }
1575        // Spot-check the matchable text is the line content.
1576        assert_eq!(pairs[0].0.display, "alpha");
1577        assert_eq!(pairs[2].0.display, "gamma");
1578    }
1579
1580    /// P.3: empty buffer surfaces an error echo (the
1581    /// `line_count == 0` guard) rather than seating an empty
1582    /// picker.
1583    #[test]
1584    fn lines_source_empty_buffer_errors() {
1585        let app = app_with("", 5);
1586        let snap = app.ad().snapshot.clone();
1587        // ropey treats truly-empty as one logical line; force the
1588        // guard by constructing a context whose buffer has zero
1589        // line count -- skip via the buffer's own report. The
1590        // line_count == 0 branch is defensive (ropey rarely
1591        // produces it) so this test only confirms the non-empty
1592        // path doesn't panic when the rope contains a single
1593        // empty line.
1594        let ctx = app.build_picker_context(&snap);
1595        let source = LinesSource::new();
1596        let result = source.init(&ctx, &[]).expect("inline");
1597        if let PickerInitResult::Inline(pairs) = result {
1598            // One logical line, contents may be empty.
1599            assert_eq!(pairs.len(), 1);
1600        } else {
1601            panic!("expected Inline");
1602        }
1603    }
1604
1605    /// P.3: accept on a `JumpInBuffer` routing returns the
1606    /// matching outcome variant. Mismatched routing returns
1607    /// `Err`.
1608    #[test]
1609    fn lines_source_accept_translates_jump_in_buffer() {
1610        let app = app_with("hi\n", 5);
1611        let snap = app.ad().snapshot.clone();
1612        let ctx = app.build_picker_context(&snap);
1613        let source = LinesSource::new();
1614        let routing = RoutingPayload::JumpInBuffer {
1615            buffer_id: 7,
1616            line: 12,
1617            col: 3,
1618        };
1619        let outcome = source.accept(&ctx, &routing).expect("ok");
1620        match outcome {
1621            PickerAcceptOutcome::JumpInBuffer {
1622                buffer_id,
1623                line,
1624                col,
1625            } => {
1626                assert_eq!(buffer_id, 7);
1627                assert_eq!(line, 12);
1628                assert_eq!(col, 3);
1629            }
1630            other => panic!("expected JumpInBuffer, got {other:?}"),
1631        }
1632        let bad = RoutingPayload::OpenFile {
1633            path: "/tmp/x".into(),
1634        };
1635        assert!(source.accept(&ctx, &bad).is_err());
1636    }
1637}