624 lines
20 KiB
Rust
624 lines
20 KiB
Rust
use super::*;
|
|
use crate::auth::auth_manager::AuthManager;
|
|
use crate::auth::AuthStateProvider;
|
|
use crate::search::command_search::searcher::CommandSearchMixer;
|
|
use crate::search::data_source::Query;
|
|
use crate::search::data_source::QueryResult;
|
|
use crate::search::item::SearchItem;
|
|
use crate::search::mixer::DataSourceRunErrorWrapper;
|
|
use crate::search::mixer::{AddAsyncSourceOptions, AsyncDataSource, BoxFuture};
|
|
use crate::search::result_renderer::ItemHighlightState;
|
|
use crate::search::{QueryFilter, SyncDataSource};
|
|
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
|
use crate::terminal::HistoryEntry;
|
|
use crate::{appearance::Appearance, search::command_search::history::history_data_source};
|
|
use galaxyui::r#async::Timer;
|
|
use galaxyui::AppContext;
|
|
use galaxyui::{elements::Empty, App, Element};
|
|
use itertools::Itertools;
|
|
use ordered_float::OrderedFloat;
|
|
use std::collections::HashSet;
|
|
use std::time::Duration;
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum TestItemAction {
|
|
Result,
|
|
}
|
|
type TestMixer = SearchMixer<TestItemAction>;
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct TestSearchItem {
|
|
is_async: bool,
|
|
}
|
|
|
|
impl SearchItem for TestSearchItem {
|
|
type Action = TestItemAction;
|
|
|
|
fn render_icon(&self, _: ItemHighlightState, _: &Appearance) -> Box<dyn Element> {
|
|
Empty::new().finish()
|
|
}
|
|
|
|
fn render_item(&self, _: ItemHighlightState, _: &AppContext) -> Box<dyn Element> {
|
|
Empty::new().finish()
|
|
}
|
|
|
|
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
|
None
|
|
}
|
|
|
|
fn score(&self) -> OrderedFloat<f64> {
|
|
if self.is_async {
|
|
OrderedFloat(0.5)
|
|
} else {
|
|
OrderedFloat(0.)
|
|
}
|
|
}
|
|
|
|
fn accept_result(&self) -> TestItemAction {
|
|
TestItemAction::Result
|
|
}
|
|
|
|
fn execute_result(&self) -> TestItemAction {
|
|
TestItemAction::Result
|
|
}
|
|
|
|
fn accessibility_label(&self) -> String {
|
|
if self.is_async {
|
|
"Async Test Result".to_string()
|
|
} else {
|
|
"Sync Test Result".to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A data source that is both sync and async.
|
|
/// When async, waits 100ms before returning a static result.
|
|
/// Note: the async data source produces an item with a higher score than the
|
|
/// item that the sync data source produces.
|
|
struct SlowDataSource {}
|
|
|
|
impl AsyncDataSource for SlowDataSource {
|
|
type Action = TestItemAction;
|
|
|
|
fn run_query(
|
|
&self,
|
|
_: &Query,
|
|
_: &AppContext,
|
|
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
|
|
Box::pin(async move {
|
|
Timer::after(Duration::from_millis(100)).await;
|
|
Ok(vec![TestSearchItem { is_async: true }.into()])
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SyncDataSource for SlowDataSource {
|
|
type Action = TestItemAction;
|
|
|
|
fn run_query(
|
|
&self,
|
|
_: &Query,
|
|
_: &AppContext,
|
|
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
|
Ok(vec![TestSearchItem { is_async: false }.into()])
|
|
}
|
|
}
|
|
|
|
fn initialize_app(app: &mut App) {
|
|
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
|
app.add_singleton_model(AuthManager::new_for_test);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_source_to_mixer() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let mixer = app.add_model(|_| CommandSearchMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
history_data_source(vec![HistoryEntry::command_only(
|
|
"git checkout master".to_owned(),
|
|
)]),
|
|
HashSet::from([QueryFilter::History]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
app.read(|app| {
|
|
assert!(mixer
|
|
.as_ref(app)
|
|
.registered_filters()
|
|
.any(|filter| filter == QueryFilter::History));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_exact_matches_rank_above_prefix_matches() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let short_command = "git".to_owned();
|
|
let long_command = "git checkout master".to_owned();
|
|
let unrelated_command = "echo hello!".to_owned();
|
|
|
|
let mixer = app.add_model(|_| CommandSearchMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
history_data_source(vec![HistoryEntry::command_only(long_command.clone())]),
|
|
HashSet::from([QueryFilter::History]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
mixer.add_async_source(
|
|
history_data_source(vec![
|
|
HistoryEntry::command_only(short_command.clone()),
|
|
HistoryEntry::command_only(unrelated_command),
|
|
]),
|
|
HashSet::from([QueryFilter::History]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
mixer.run_query("git".into(), ctx);
|
|
});
|
|
|
|
Timer::after(Duration::from_millis(200)).await;
|
|
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
|
|
// Note that ranking "higher" means the result should have a lower index, because the view
|
|
// renders highest ranked items at the bottom of the scrollable panel.
|
|
// While the two commands have the same "score", the `long_command` comes first
|
|
// because the data source it derives from was registered first.
|
|
assert_eq!(results.len(), 2);
|
|
|
|
assert!(matches!(
|
|
results.first().map(|result| result.accept_result()),
|
|
Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { command: long, linked_workflow_data: None })) if long == long_command));
|
|
|
|
assert!(matches!(
|
|
results.get(1).map(|result| result.accept_result()),
|
|
Some(CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem { command: short, linked_workflow_data: None })) if short == short_command));
|
|
});
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_query_filter_runs_all_data_sources() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let mixer = app.add_model(|_| CommandSearchMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
history_data_source(vec![HistoryEntry::command_only("git".to_owned())]),
|
|
HashSet::from([QueryFilter::History]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
mixer.add_async_source(
|
|
history_data_source(vec![HistoryEntry::command_only("git checkout".to_owned())]),
|
|
HashSet::from([QueryFilter::Workflows]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// Running a query with no filters should produce results from both sources.
|
|
mixer.run_query("git".into(), ctx);
|
|
});
|
|
|
|
Timer::after(Duration::from_millis(200)).await;
|
|
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["History item: git", "History item: git checkout"]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_filter_limits_data_sources() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let mixer = app.add_model(|_| CommandSearchMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
history_data_source(vec![HistoryEntry::command_only("git".to_owned())]),
|
|
HashSet::from([QueryFilter::History]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
mixer.add_async_source(
|
|
history_data_source(vec![HistoryEntry::command_only("git checkout".to_owned())]),
|
|
HashSet::from([QueryFilter::Workflows]),
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// Limiting results to a single query filter should only produces results from that source.
|
|
mixer.run_query(
|
|
Query {
|
|
filters: HashSet::from([QueryFilter::History]),
|
|
text: "git".into(),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
Timer::after(Duration::from_millis(200)).await;
|
|
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["History item: git"]
|
|
);
|
|
});
|
|
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
// Specifying both filters should produce results from both sources.
|
|
mixer.run_query(
|
|
Query {
|
|
filters: HashSet::from([QueryFilter::History, QueryFilter::Workflows]),
|
|
text: "git".into(),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
Timer::after(Duration::from_millis(200)).await;
|
|
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["History item: git", "History item: git checkout"]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_async_data_source() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let mixer = app.add_model(|_| TestMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
SlowDataSource {},
|
|
[QueryFilter::Actions],
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: Some(Duration::from_millis(100)),
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// We need to run with a non-empty text and a matching filter
|
|
// to ensure the async source matches the query.
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::from([QueryFilter::Actions]),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Since the debounce period is 100ms and the SlowDataSource
|
|
// takes 100ms, waiting 500ms should be more than sufficient.
|
|
Timer::after(Duration::from_millis(500)).await;
|
|
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["Async Test Result"]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_async_data_source_run_twice_with_debounce() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let mixer = app.add_model(|_| TestMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
SlowDataSource {},
|
|
[QueryFilter::Actions],
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: Some(Duration::from_millis(10)),
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// We need to run with a non-empty text and a matching filter
|
|
// to ensure the async source matches the query.
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::from_iter([QueryFilter::Actions]),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// After 50ms, the query would have started to run (because 10ms have elapsed)
|
|
// but it wouldn't have completed because it takes 100ms to complete.
|
|
Timer::after(Duration::from_millis(50)).await;
|
|
|
|
// Start another query while the other one has started but not completed.
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
// We need to run with a non-empty text and a matching filter
|
|
// to ensure the async source matches the query.
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::from_iter([QueryFilter::Actions]),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Wait till all queries are complete.
|
|
Timer::after(Duration::from_millis(500)).await;
|
|
|
|
// There should only be one result.
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["Async Test Result"]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_async_data_source_run_twice_without_debounce() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let mixer = app.add_model(|_| TestMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
SlowDataSource {},
|
|
[QueryFilter::Actions],
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// We need to run with a non-empty text and a matching filter
|
|
// to ensure the async source matches the query.
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::from_iter([QueryFilter::Actions]),
|
|
},
|
|
ctx,
|
|
);
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::from_iter([QueryFilter::Actions]),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Since the debounce period is 100ms and the SlowDataSource
|
|
// takes 100ms, waiting 500ms should be more than sufficient.
|
|
Timer::after(Duration::from_millis(500)).await;
|
|
|
|
// There should only be one result.
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["Async Test Result"]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_async_source_with_include_in_unfiltered_runs_on_empty_filters() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let mixer = app.add_model(|_| TestMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
SlowDataSource {},
|
|
[QueryFilter::Files],
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: true,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// Run with non-empty text but no filters (unfiltered mode).
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::new(),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
Timer::after(Duration::from_millis(500)).await;
|
|
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["Async Test Result"]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_async_source_without_include_in_unfiltered_skipped_on_empty_filters() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let mixer = app.add_model(|_| TestMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_async_source(
|
|
SlowDataSource {},
|
|
[QueryFilter::Files],
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: None,
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// Run with non-empty text but no filters (unfiltered mode).
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::new(),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
Timer::after(Duration::from_millis(500)).await;
|
|
|
|
// The async source should NOT have run because run_when_unfiltered is false.
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert!(results.is_empty());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_sync_and_async_data_sources() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let mixer = app.add_model(|_| TestMixer::new());
|
|
mixer.update(&mut app, |mixer, ctx| {
|
|
mixer.add_sync_source(SlowDataSource {}, [QueryFilter::Actions]);
|
|
mixer.add_async_source(
|
|
SlowDataSource {},
|
|
[QueryFilter::Actions],
|
|
AddAsyncSourceOptions {
|
|
debounce_interval: Some(Duration::from_millis(100)),
|
|
run_in_zero_state: false,
|
|
run_when_unfiltered: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// We need to run with a non-empty text and a matching filter
|
|
// to ensure the async source matches the query.
|
|
mixer.run_query(
|
|
Query {
|
|
text: "a".to_owned(),
|
|
filters: HashSet::from_iter([QueryFilter::Actions]),
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Results are buffered until all sources finish, so nothing is visible yet.
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert!(results.is_empty());
|
|
});
|
|
|
|
// Since the debounce period is 100ms and the SlowDataSource
|
|
// takes 100ms, waiting 500ms should be more than sufficient.
|
|
Timer::after(Duration::from_millis(500)).await;
|
|
|
|
// After the async data source runs, there should just be two items with the async data
|
|
// source item having a higher score (so it appears after).
|
|
app.read(|app| {
|
|
let results = mixer.as_ref(app).results();
|
|
assert_eq!(
|
|
results
|
|
.iter()
|
|
.map(|result| result.accessibility_label())
|
|
.collect_vec(),
|
|
vec!["Sync Test Result", "Async Test Result"]
|
|
);
|
|
});
|
|
});
|
|
}
|