use super::*;
use crate::features::chat_search_sort::SortMode;
fn scripted(reply: &str) -> Arc<dyn EngineBackend> {
Arc::new(MockBackend::scripted(vec![
ChatChunk::Text(reply.into()),
ChatChunk::Finished(crate::shared::api::FinishReason::Stop),
]))
}
async fn record_a_conversation(root: &std::path::Path, user: &str, assistant: &str) -> Uuid {
let (cmd_tx, mut evt_rx, handle) =
spawn_orch_at(root, Some(scripted(assistant)), AppConfig::default());
let activated = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let chat_id = match activated {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx.send(AppCommand::SendMessage(user.into())).unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
chat_id
}
async fn search(
cmd_tx: &UnboundedSender<AppCommand>,
evt_rx: &mut UnboundedReceiver<AppEvent>,
query: &str,
) -> Option<Vec<Uuid>> {
cmd_tx.send(AppCommand::SearchChats(query.into())).unwrap();
let ev = wait_for(evt_rx, |e| matches!(e, AppEvent::ChatSearchResults { .. }))
.await
.unwrap();
match ev {
AppEvent::ChatSearchResults {
query: echoed,
chat_ids,
} => {
assert_eq!(echoed, query, "the query must be echoed back verbatim");
chat_ids
}
_ => unreachable!(),
}
}
#[tokio::test]
async fn a_saved_conversation_becomes_searchable_by_content() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let chat_id = record_a_conversation(&root, "расскажи про кристаллографию", "конечно").await;
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
assert_eq!(
search(&cmd_tx, &mut evt_rx, "кристалл").await,
Some(vec![chat_id])
);
assert_eq!(
search(&cmd_tx, &mut evt_rx, "конечно").await,
Some(vec![chat_id])
);
let bootstrap_title =
crate::shared::i18n::locale(crate::shared::i18n::Lang::default()).t("defaults.chat_title");
assert_eq!(
search(&cmd_tx, &mut evt_rx, bootstrap_title).await,
Some(Vec::new())
);
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn a_query_matching_nothing_returns_an_empty_list() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
record_a_conversation(&root, "про кошек", "мяу").await;
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
assert_eq!(
search(&cmd_tx, &mut evt_rx, "бетономешалка").await,
Some(Vec::new())
);
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn a_too_short_query_does_not_filter() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
assert_eq!(search(&cmd_tx, &mut evt_rx, "ab").await, None);
assert_eq!(search(&cmd_tx, &mut evt_rx, " ").await, None);
assert_eq!(search(&cmd_tx, &mut evt_rx, "C++").await, Some(Vec::new()));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
fn indexed_chat(orch: &mut Orchestrator, title: &str, texts: &[&str]) -> (Uuid, Vec<Uuid>) {
let profile = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&profile, title);
for t in texts {
chat.push_message(Message::user(*t));
}
let ids: Vec<Uuid> = chat.messages.iter().map(|m| m.id).collect();
orch.storage.json().save_chat(&chat).unwrap();
orch.index_saved_chat(&chat);
let id = chat.id;
orch.chats.push(chat);
(id, ids)
}
fn indexed_chat_with_run(
orch: &mut Orchestrator,
title: &str,
own: &[&str],
run_title: &str,
run_texts: &[&str],
) -> (Uuid, Uuid, Vec<Uuid>) {
let profile = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&profile, title);
for t in own {
chat.push_message(Message::user(*t));
}
let run = crate::entities::subagent::SubagentRun::fixture(run_title, run_texts);
let run_id = run.id;
let run_msgs: Vec<Uuid> = run.messages.iter().map(|m| m.id).collect();
let mut carrier = Message::assistant("delegated");
carrier.tool_calls = vec![run.on_record()];
chat.push_message(carrier);
orch.storage.json().save_chat(&chat).unwrap();
orch.index_saved_chat(&chat);
let id = chat.id;
orch.chats.push(chat);
(id, run_id, run_msgs)
}
fn message_search(
orch: &mut Orchestrator,
rx: &mut UnboundedReceiver<AppEvent>,
query: &str,
) -> (Vec<crate::features::chat_search::SearchGroup>, usize) {
message_search_sorted(orch, rx, query, SortMode::Modified)
}
fn message_search_sorted(
orch: &mut Orchestrator,
rx: &mut UnboundedReceiver<AppEvent>,
query: &str,
sort: SortMode,
) -> (Vec<crate::features::chat_search::SearchGroup>, usize) {
orch.handle_search_messages(query.into(), sort);
loop {
match rx.try_recv().expect("no MessageSearchResults arrived") {
AppEvent::MessageSearchResults {
query: echoed,
groups,
total,
} => {
assert_eq!(echoed, query, "the query must be echoed back verbatim");
return (groups, total);
}
_ => continue,
}
}
}
#[test]
fn message_search_groups_by_chat_in_list_order_with_titles() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let (older, older_msgs) = indexed_chat(
&mut orch,
"Старый чат",
&["первое про маркер", "без совпадения", "второе про маркер"],
);
let (newer, _) = indexed_chat(&mut orch, "Свежий чат", &["ещё раз маркер"]);
orch.storage
.cache()
.index_chat(
Uuid::new_v4(),
1,
1,
&[crate::shared::storage::cache::IndexedMessage {
id: Uuid::new_v4(),
sub_id: None,
role: "user".into(),
ts: "2026-07-29T10:00:00+00:00".into(),
text: "призрачный маркер".into(),
}],
)
.unwrap();
let chat = orch.chats.iter_mut().find(|c| c.id == older).unwrap();
chat.messages[0].text = "первое про маркер, переписанное".into();
let chat = chat.clone();
orch.storage.json().save_chat(&chat).unwrap();
orch.index_saved_chat(&chat);
let now = chrono::Utc::now();
for chat in &mut orch.chats {
chat.modified_at = if chat.id == newer {
now
} else {
now - chrono::Duration::hours(1)
};
}
let (groups, total) = message_search(&mut orch, &mut rx, "маркер");
assert_eq!(groups.len(), 2, "the unknown chat's hit is dropped");
assert_eq!(groups[0].chat_id, newer, "most recently modified first");
assert_eq!(
groups[0].title, "Свежий чат",
"the title comes from the chat"
);
assert_eq!(groups[1].chat_id, older);
assert_eq!(groups[1].title, "Старый чат");
let hits: Vec<Uuid> = groups[1].hits.iter().map(|h| h.message_id).collect();
assert_eq!(
hits,
vec![older_msgs[0], older_msgs[2]],
"hits must follow the conversation, not the index's rowids"
);
let first = &groups[1].hits[0];
assert_eq!(first.role, "user");
assert!(!first.ts.is_empty());
assert!(first.snippet.text.contains("маркер"), "{:?}", first.snippet);
assert_eq!(
first.snippet.matches.len(),
1,
"the match must be located for highlighting"
);
assert_eq!(total, 4);
}
#[test]
fn message_search_nests_a_transcripts_hits_under_its_parent() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let (parent, run, run_msgs) = indexed_chat_with_run(
&mut orch,
"Родитель",
&["свой маркер", "и тайна"],
"Критик",
&["маркер в задании", "маркер в ответе", "тайна внутри"],
);
let (_plain, _) = indexed_chat(&mut orch, "Обычный", &["маркер тут"]);
let (groups, total) = message_search(&mut orch, &mut rx, "маркер");
assert_eq!(total, 4);
let shape: Vec<(Uuid, Option<Uuid>, usize)> = groups
.iter()
.map(|g| (g.chat_id, g.parent, g.hits.len()))
.collect();
let i = shape.iter().position(|g| g.0 == parent).unwrap();
assert_eq!(shape[i], (parent, None, 1));
assert_eq!(shape[i + 1], (run, Some(parent), 2), "{shape:?}");
assert_eq!(groups[i + 1].title, "Критик");
let hits: Vec<Uuid> = groups[i + 1].hits.iter().map(|h| h.message_id).collect();
assert_eq!(hits, vec![run_msgs[0], run_msgs[1]], "in the run's order");
let (groups, total) = message_search(&mut orch, &mut rx, "внутри");
assert_eq!(total, 1);
assert_eq!(groups.len(), 2, "{groups:?}");
assert_eq!((groups[0].chat_id, groups[0].hits.len()), (parent, 0));
assert_eq!((groups[1].chat_id, groups[1].parent), (run, Some(parent)));
let ids = |orch: &Orchestrator, rx: &mut UnboundedReceiver<AppEvent>, q: &str| {
orch.handle_search_chats(q.into());
loop {
if let AppEvent::ChatSearchResults { chat_ids, .. } = rx.try_recv().unwrap() {
return chat_ids.unwrap();
}
}
};
assert_eq!(
ids(&orch, &mut rx, "внутри"),
vec![run],
"the transcript stands for itself"
);
let mut both = ids(&orch, &mut rx, "тайна");
both.sort();
let mut want = vec![parent, run];
want.sort();
assert_eq!(both, want);
}
#[test]
fn first_match_in_chat_resolves_each_level_separately() {
let (_d, mut orch, _rx) = bare_orch_rx();
let (parent, run, run_msgs) = indexed_chat_with_run(
&mut orch,
"Родитель",
&["без совпадения", "свой маркер"],
"Критик",
&["маркер в задании"],
);
let own_hit = orch.chats.iter().find(|c| c.id == parent).unwrap().messages[1].id;
assert_eq!(orch.first_match_in_chat(run, "маркер"), Some(run_msgs[0]));
assert_eq!(orch.first_match_in_chat(parent, "маркер"), Some(own_hit));
assert_eq!(orch.first_match_in_chat(parent, "задании"), None);
}
#[test]
fn message_search_reports_the_true_total_when_the_cap_bites() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let cap = crate::features::chat_search::HIT_CAP;
let texts: Vec<String> = (0..cap + 5)
.map(|i| format!("совпадение номер {i}"))
.collect();
let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
indexed_chat(&mut orch, "Большой чат", &refs);
let (groups, total) = message_search(&mut orch, &mut rx, "совпадение");
let shown: usize = groups.iter().map(|g| g.hits.len()).sum();
assert_eq!(shown, cap, "the hits are capped");
assert_eq!(total, cap + 5, "the total stays honest");
}
#[test]
fn an_unsearchable_message_query_yields_no_groups() {
let (_d, mut orch, mut rx) = bare_orch_rx();
indexed_chat(&mut orch, "Чат", &["какое-то содержимое"]);
for query in ["ab", " ", ""] {
let (groups, total) = message_search(&mut orch, &mut rx, query);
assert!(groups.is_empty(), "query {query:?}");
assert_eq!(total, 0, "query {query:?}");
}
let (groups, total) = message_search(&mut orch, &mut rx, "бетономешалка");
assert!(groups.is_empty());
assert_eq!(total, 0);
}
#[test]
fn first_match_in_chat_uses_chat_order_not_index_order() {
let (_d, mut orch, _rx) = bare_orch_rx();
let (chat_id, msgs) = indexed_chat(
&mut orch,
"Чат",
&["первое про маркер", "без", "третье про маркер"],
);
let chat = orch.chats.iter_mut().find(|c| c.id == chat_id).unwrap();
chat.messages[0].text = "первое про маркер, переписанное".into();
let chat = chat.clone();
orch.storage.json().save_chat(&chat).unwrap();
orch.index_saved_chat(&chat);
assert_eq!(
orch.first_match_in_chat(chat_id, "маркер"),
Some(msgs[0]),
"the earliest message in the conversation, not the lowest rowid"
);
assert_eq!(orch.first_match_in_chat(chat_id, "бетономешалка"), None);
assert_eq!(orch.first_match_in_chat(chat_id, "ab"), None);
assert_eq!(orch.first_match_in_chat(Uuid::new_v4(), "маркер"), None);
}
#[tokio::test]
async fn open_chat_at_first_match_activates_with_the_focus() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let chat_id = record_a_conversation(&root, "вопрос про кристаллографию", "ответ").await;
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
let activated = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let user_msg = match activated {
AppEvent::ChatActivated { messages, .. } => messages[0].id,
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::NewChat { profile_id: None })
.unwrap();
let other = match wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id != chat_id),
)
.await
.unwrap()
{
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::OpenChatAtFirstMatch {
chat: chat_id,
query: "кристалл".into(),
})
.unwrap();
let ev = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
match ev {
AppEvent::ChatActivated { id, focus, .. } => {
assert_eq!(id, chat_id, "the chat must open");
let focus = focus.expect("on the message that matched");
assert_eq!(focus.message, user_msg);
assert_eq!(focus.query, "кристалл");
}
_ => unreachable!(),
}
cmd_tx.send(AppCommand::SwitchChat(other)).unwrap();
wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == other),
)
.await
.unwrap();
cmd_tx
.send(AppCommand::OpenChatAtFirstMatch {
chat: chat_id,
query: "бетономешалка".into(),
})
.unwrap();
let ev = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
assert!(matches!(
ev,
AppEvent::ChatActivated { id, focus: None, .. } if id == chat_id
));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn a_deleted_chat_drops_out_of_results() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let chat_id = record_a_conversation(&root, "секретное содержимое", "ага").await;
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
assert_eq!(
search(&cmd_tx, &mut evt_rx, "секретное").await,
Some(vec![chat_id])
);
cmd_tx.send(AppCommand::DeleteChat(chat_id)).unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatList(_)))
.await
.unwrap();
assert_eq!(
search(&cmd_tx, &mut evt_rx, "секретное").await,
Some(Vec::new()),
"a deleted chat must leave the index at once"
);
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[test]
fn message_search_follows_the_chat_lists_sort_toggle() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let (old_new, _) = indexed_chat(&mut orch, "Создан раньше", &["про маркер"]);
let (new_old, _) = indexed_chat(&mut orch, "Создан позже", &["тоже маркер"]);
{
let a = orch.chats.iter_mut().find(|c| c.id == old_new).unwrap();
a.created_at = "2020-01-01T00:00:00Z".parse().unwrap();
a.modified_at = "2030-01-01T00:00:00Z".parse().unwrap();
let b = orch.chats.iter_mut().find(|c| c.id == new_old).unwrap();
b.created_at = "2029-01-01T00:00:00Z".parse().unwrap();
b.modified_at = "2021-01-01T00:00:00Z".parse().unwrap();
}
let (by_modified, _) = message_search_sorted(&mut orch, &mut rx, "маркер", SortMode::Modified);
let (by_created, _) = message_search_sorted(&mut orch, &mut rx, "маркер", SortMode::Created);
assert_eq!(by_modified[0].chat_id, old_new, "newest modification first");
assert_eq!(by_created[0].chat_id, new_old, "newest creation first");
}