use super::*;
use crate::features::export_command::ExportFormat;
#[test]
fn new_chat_value_uses_chosen_profile_with_greeting() {
let (_d, mut orch) = bare_orch();
let p1 = Profile::new("A", "sys A");
let mut p2 = Profile::new("B", "sys B");
p2.greeting = Some("Здравствуйте!".into());
let (id1, id2) = (p1.id, p2.id);
orch.profiles.push(p1);
orch.profiles.push(p2);
let chat = orch.new_chat_value(Some(id2));
assert_eq!(chat.profile_id, id2);
assert_eq!(chat.system_message, "sys B");
assert_eq!(chat.messages.len(), 1);
assert_eq!(chat.messages[0].role, MessageRole::Assistant);
assert_eq!(chat.messages[0].text, "Здравствуйте!");
let chat = orch.new_chat_value(None);
assert_eq!(chat.profile_id, id1);
assert!(chat.messages.is_empty());
}
#[test]
fn copy_chat_emits_clipboard_text_or_error_when_empty() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&profile, "Чат");
let id = chat.id;
chat.push_message(Message::user("привет"));
chat.push_message(Message::assistant("здравствуйте"));
orch.chats.push(chat);
orch.handle_copy_chat(id);
match rx.try_recv().unwrap() {
AppEvent::CopyToClipboard(text) => {
assert!(text.contains("Пользователь:\nпривет"));
assert!(text.contains("Ассистент:\nздравствуйте"));
}
other => panic!("expected CopyToClipboard, got {other:?}"),
}
let empty = Chat::from_profile(&profile, "Пустой");
let empty_id = empty.id;
orch.chats.push(empty);
orch.handle_copy_chat(empty_id);
assert!(matches!(rx.try_recv().unwrap(), AppEvent::ChatListError(_)));
}
#[test]
fn copy_chat_labels_roles_with_profile_names() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let mut profile = Profile::new("P", "sys");
profile.character_names.user = "Гайя".into();
profile.character_names.assistant = "Анна".into();
let mut chat = Chat::from_profile(&profile, "Чат");
let id = chat.id;
chat.push_message(Message::user("привет"));
chat.push_message(Message::assistant("здравствуйте"));
orch.profiles.push(profile);
orch.chats.push(chat);
orch.handle_copy_chat(id);
match rx.try_recv().unwrap() {
AppEvent::CopyToClipboard(text) => {
assert!(text.contains("Гайя:\nпривет"), "{text}");
assert!(text.contains("Анна:\nздравствуйте"), "{text}");
}
other => panic!("expected CopyToClipboard, got {other:?}"),
}
}
#[test]
fn set_draft_persists_to_active_chat_without_bumping_modified() {
let (_d, mut orch, _rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "Чат");
let id = chat.id;
let modified = chat.modified_at;
orch.chats.push(chat);
orch.active_id = Some(id);
orch.handle_set_draft("недописанный текст".into());
let c = orch.chats.iter().find(|c| c.id == id).unwrap();
assert_eq!(c.draft, "недописанный текст");
assert_eq!(
c.modified_at, modified,
"editing a draft doesn't bump the chat up the list"
);
assert!(orch.saves.is_dirty(id), "the chat is flagged for saving");
orch.saves.take();
orch.handle_set_draft("недописанный текст".into());
assert!(!orch.saves.is_dirty(id));
}
#[test]
fn set_feed_view_persists_to_active_chat_without_bumping_modified() {
let (_d, mut orch, _rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "Чат");
let id = chat.id;
let modified = chat.modified_at;
orch.chats.push(chat);
orch.active_id = Some(id);
let view = FeedView {
thoughts: true,
tools: true,
};
orch.handle_set_feed_view(view);
let c = orch.chats.iter().find(|c| c.id == id).unwrap();
assert_eq!(c.feed_view, view);
assert_eq!(
c.modified_at, modified,
"collapsing a block doesn't bump the chat up the list"
);
assert!(orch.saves.is_dirty(id), "the chat is flagged for saving");
orch.saves.take();
orch.handle_set_feed_view(view);
assert!(!orch.saves.is_dirty(id));
}
#[test]
fn set_children_expanded_persists_by_id_and_reemits_the_list() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "Чат");
let id = chat.id;
let modified = chat.modified_at;
orch.chats.push(chat);
orch.active_id = None;
orch.handle_set_children_expanded(id, true);
let c = orch.chats.iter().find(|c| c.id == id).unwrap();
assert!(c.children_expanded);
assert_eq!(
c.modified_at, modified,
"folding must not bump the chat up the list"
);
assert!(orch.saves.is_dirty(id), "the chat is flagged for saving");
match rx.try_recv().unwrap() {
AppEvent::ChatList(list) => {
assert!(list.iter().find(|s| s.id == id).unwrap().children_expanded);
}
other => panic!("expected the updated list, got {other:?}"),
}
assert!(matches!(rx.try_recv(), Ok(AppEvent::TaskList(_))));
orch.saves.take();
orch.handle_set_children_expanded(id, true);
assert!(!orch.saves.is_dirty(id));
assert!(rx.try_recv().is_err());
}
#[test]
fn set_children_expanded_resolves_a_transcript_to_its_parent() {
let (carrier, run_id) = carrier_with_run("Критик", &["x"]);
let (_d, mut orch, _rx, chat_id) = bare_with_chat(vec![Message::user("привет"), carrier]);
orch.handle_set_children_expanded(run_id, true);
assert_eq!(orch.chats[0].id, chat_id);
assert!(orch.chats[0].children_expanded, "the parent holds the fold");
}
#[tokio::test]
async fn feed_view_is_per_chat_and_survives_a_reopen() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
let root = _d.path().to_path_buf();
let activated = |e: &AppEvent| matches!(e, AppEvent::ChatActivated { .. });
let first = match wait_for(&mut evt_rx, activated).await.unwrap() {
AppEvent::ChatActivated {
id, feed_view: v, ..
} => {
assert_eq!(v, FeedView::default(), "a fresh chat opens collapsed");
id
}
_ => unreachable!(),
};
let expanded = FeedView {
thoughts: true,
tools: true,
};
cmd_tx.send(AppCommand::SetFeedView(expanded)).unwrap();
cmd_tx
.send(AppCommand::NewChat { profile_id: None })
.unwrap();
let second = match wait_for(&mut evt_rx, activated).await.unwrap() {
AppEvent::ChatActivated {
id, feed_view: v, ..
} => {
assert_eq!(v, FeedView::default(), "the new chat is its own");
id
}
_ => unreachable!(),
};
assert_ne!(first, second);
cmd_tx.send(AppCommand::SwitchChat(first)).unwrap();
match wait_for(&mut evt_rx, activated).await.unwrap() {
AppEvent::ChatActivated { feed_view: v, .. } => assert_eq!(v, expanded),
_ => unreachable!(),
}
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let reopened = Storage::open(Paths::with_root(&root)).unwrap();
let chat = reopened.json().load_chat(first).unwrap().unwrap();
assert_eq!(chat.feed_view, expanded, "the choice survives a restart");
}
#[tokio::test]
async fn draft_persists_and_clears_on_send() {
let backend = Arc::new(MockBackend::scripted(vec![
ChatChunk::Text("ответ".into()),
ChatChunk::Finished(FinishReason::Stop),
])) as Arc<dyn EngineBackend>;
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(Some(backend));
let root = _d.path().to_path_buf();
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let chat_id = match active {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::SetDraft("недописанное".into()))
.unwrap();
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let reopened = Storage::open(Paths::with_root(&root)).unwrap();
let chat = reopened.json().load_chat(chat_id).unwrap().unwrap();
assert_eq!(chat.draft, "", "the draft is cleared after sending");
}
#[tokio::test]
async fn draft_survives_reopen_when_not_sent() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
let root = _d.path().to_path_buf();
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let chat_id = match active {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::SetDraft("черновик на потом".into()))
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let reopened = Storage::open(Paths::with_root(&root)).unwrap();
let chat = reopened.json().load_chat(chat_id).unwrap().unwrap();
assert_eq!(chat.draft, "черновик на потом");
}
#[tokio::test]
async fn open_chat_at_activates_the_chat_carrying_the_focus() {
let backend = Arc::new(MockBackend::scripted(vec![
ChatChunk::Text("ответ".into()),
ChatChunk::Finished(FinishReason::Stop),
])) as Arc<dyn EngineBackend>;
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(Some(backend));
let activated = |e: &AppEvent| matches!(e, AppEvent::ChatActivated { .. });
let a = wait_for(&mut evt_rx, activated).await.unwrap();
let first_id = match a {
AppEvent::ChatActivated { id, focus, .. } => {
assert_eq!(focus, None, "bootstrap activation carries no focus");
id
}
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
cmd_tx
.send(AppCommand::NewChat { profile_id: None })
.unwrap();
let a = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id != first_id),
)
.await
.unwrap();
let second_id = match a {
AppEvent::ChatActivated { id, focus, .. } => {
assert_eq!(focus, None, "creating a chat carries no focus");
id
}
_ => unreachable!(),
};
cmd_tx.send(AppCommand::SwitchChat(first_id)).unwrap();
let a = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == first_id),
)
.await
.unwrap();
let msg_id = match a {
AppEvent::ChatActivated {
messages, focus, ..
} => {
assert_eq!(focus, None, "a plain switch carries no focus");
messages.first().expect("the chat has messages").id
}
_ => unreachable!(),
};
cmd_tx.send(AppCommand::SwitchChat(second_id)).unwrap();
wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == second_id),
)
.await
.unwrap();
let jumped = |e: AppEvent, what: &str| {
match e {
AppEvent::ChatActivated { id, focus, .. } => {
assert_eq!(id, first_id, "{what}");
let focus = focus.expect(what);
assert_eq!(focus.message, msg_id, "{what}");
assert_eq!(focus.query, "привет", "the query must ride along: {what}");
}
_ => unreachable!(),
};
};
cmd_tx
.send(AppCommand::OpenChatAt {
chat: first_id,
message: msg_id,
query: "привет".into(),
})
.unwrap();
let a = wait_for(&mut evt_rx, activated).await.unwrap();
jumped(
a,
"a jump must activate the chat and carry the focused message",
);
cmd_tx
.send(AppCommand::OpenChatAt {
chat: first_id,
message: msg_id,
query: "привет".into(),
})
.unwrap();
let a = wait_for(&mut evt_rx, activated).await.unwrap();
jumped(a, "a jump within the open chat must re-emit with the focus");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn bootstrap_emits_chat_list_and_active_chat() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
let list = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatList(_)))
.await
.unwrap();
if let AppEvent::ChatList(chats) = list {
assert_eq!(chats.len(), 1, "one default chat should be created");
}
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
assert!(matches!(active, AppEvent::ChatActivated { .. }));
drop(cmd_tx);
handle.await.unwrap();
}
#[tokio::test]
async fn a_missing_database_is_reported_once_in_the_feed_after_the_feed_is_built() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let json = crate::shared::storage::JsonStore::new(Paths::with_root(&root));
let profile = Profile::new("A", "sys");
json.upsert_profile(&profile).unwrap();
json.save_chat(&Chat::from_profile(&profile, "перенесённый чат"))
.unwrap();
assert!(!Paths::with_root(&root).data_db().exists());
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
let mut seen: Vec<AppEvent> = Vec::new();
while let Some(ev) = evt_rx.recv().await {
let done = matches!(ev, AppEvent::Settings { .. });
seen.push(ev);
if done {
break;
}
}
let note_at = seen
.iter()
.position(|e| matches!(e, AppEvent::Notice(_)))
.expect("the launch must say the database was missing");
let activated_at = seen
.iter()
.position(|e| matches!(e, AppEvent::ChatActivated { .. }))
.expect("the chat is activated at bootstrap");
assert!(
activated_at < note_at,
"a note before the feed rebuild would be wiped by it: {seen:?}"
);
match &seen[note_at] {
AppEvent::Notice(text) => assert_eq!(
text,
crate::shared::i18n::locale(crate::shared::i18n::Lang::default())
.t("ui.startup.db_missing")
),
_ => unreachable!(),
}
assert_eq!(
seen.iter()
.filter(|e| matches!(e, AppEvent::Notice(_)))
.count(),
1,
"said once, not per chat"
);
drop(cmd_tx);
handle.await.unwrap();
}
#[tokio::test]
async fn an_ordinary_restart_says_nothing_about_the_database() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
{
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Settings { .. }))
.await
.unwrap();
drop(cmd_tx);
handle.await.unwrap();
}
assert!(Paths::with_root(&root).data_db().exists());
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
let mut seen: Vec<AppEvent> = Vec::new();
while let Some(ev) = evt_rx.recv().await {
let done = matches!(ev, AppEvent::Settings { .. });
seen.push(ev);
if done {
break;
}
}
assert!(
!seen.iter().any(|e| matches!(e, AppEvent::Notice(_))),
"a database that is there is not news: {seen:?}"
);
drop(cmd_tx);
handle.await.unwrap();
}
#[tokio::test]
async fn remembers_and_restores_last_opened_chat() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let first_id;
{
let storage = Arc::new(Storage::open(Paths::with_root(&root)).unwrap());
let (cmd_tx, cmd_rx) = unbounded_channel();
let (evt_tx, mut evt_rx) = unbounded_channel();
let deps = OrchestratorDeps {
cmd_rx,
evt_tx,
storage,
config: AppConfig::default(),
supervisor: Arc::new(MockSupervisor::with_backend(None)),
default_language: crate::shared::i18n::Lang::default(),
extra_tools: Vec::new(),
};
let handle = tokio::spawn(run(deps));
let a = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
first_id = match a {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::NewChat { profile_id: None })
.unwrap();
wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id != first_id),
)
.await
.unwrap();
cmd_tx.send(AppCommand::SwitchChat(first_id)).unwrap();
wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == first_id),
)
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
let persisted = Storage::open(Paths::with_root(&root)).unwrap();
let config = persisted.json().load_config().unwrap();
assert_eq!(config.last_active_chat, Some(first_id));
{
let storage = Arc::new(Storage::open(Paths::with_root(&root)).unwrap());
let (cmd_tx, cmd_rx) = unbounded_channel();
let (evt_tx, mut evt_rx) = unbounded_channel();
let deps = OrchestratorDeps {
cmd_rx,
evt_tx,
storage,
config,
supervisor: Arc::new(MockSupervisor::with_backend(None)),
default_language: crate::shared::i18n::Lang::default(),
extra_tools: Vec::new(),
};
let handle = tokio::spawn(run(deps));
let a = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
match a {
AppEvent::ChatActivated { id, .. } => assert_eq!(id, first_id),
_ => unreachable!(),
}
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
}
#[tokio::test]
async fn new_chat_adds_to_list_and_activates() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
cmd_tx
.send(AppCommand::NewChat { profile_id: None })
.unwrap();
let list = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatList(c) if c.len() == 2),
)
.await
.unwrap();
assert!(matches!(list, AppEvent::ChatList(c) if c.len() == 2));
drop(cmd_tx);
handle.await.unwrap();
}
#[tokio::test]
async fn rename_updates_chat_list() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let id = match active {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx
.send(AppCommand::RenameChat {
id,
title: "Переименован".into(),
})
.unwrap();
let list = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatList(c) if c.iter().any(|s| s.title == "Переименован")),
)
.await
.unwrap();
assert!(matches!(list, AppEvent::ChatList(_)));
drop(cmd_tx);
handle.await.unwrap();
}
#[tokio::test]
async fn delete_active_chat_creates_replacement() {
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let id = match active {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
cmd_tx.send(AppCommand::DeleteChat(id)).unwrap();
let active2 = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id: nid, .. } if *nid != id),
)
.await
.unwrap();
assert!(matches!(active2, AppEvent::ChatActivated { .. }));
drop(cmd_tx);
handle.await.unwrap();
}
fn orch_with_conversation() -> (
tempfile::TempDir,
Orchestrator,
UnboundedReceiver<AppEvent>,
Uuid,
) {
let (dir, mut orch, rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&profile, "Про космос");
let id = chat.id;
chat.push_message(Message::user("привет"));
chat.push_message(Message::assistant("здравствуйте"));
orch.profiles.push(profile);
orch.chats.push(chat);
(dir, orch, rx, id)
}
#[test]
fn export_writes_the_file_and_refuses_to_overwrite_it() {
let (dir, mut orch, mut rx, id) = orch_with_conversation();
let target = dir.path().join("вывод.md");
orch.handle_export_chat(
id,
ExportFormat::Markdown,
Some(&target.display().to_string()),
);
match rx.try_recv().unwrap() {
AppEvent::Notice(text) => assert!(text.contains("вывод.md"), "the note names it: {text}"),
other => panic!("expected a Notice, got {other:?}"),
}
let written = std::fs::read_to_string(&target).expect("the file exists");
assert!(
written.contains("привет"),
"the conversation is in it: {written}"
);
assert!(written.contains("здравствуйте"), "{written}");
orch.handle_export_chat(
id,
ExportFormat::Markdown,
Some(&target.display().to_string()),
);
match rx.try_recv().unwrap() {
AppEvent::Error(text) => assert!(text.contains("вывод.md"), "the refusal names it: {text}"),
other => panic!("expected an Error, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
written,
"the existing export must survive untouched"
);
}
#[test]
fn the_markdown_file_is_exactly_what_the_clipboard_gets() {
let (dir, mut orch, mut rx, id) = orch_with_conversation();
orch.handle_copy_chat(id);
let copied = match rx.try_recv().unwrap() {
AppEvent::CopyToClipboard(text) => text,
other => panic!("expected CopyToClipboard, got {other:?}"),
};
let target = dir.path().join("out.md");
orch.handle_export_chat(
id,
ExportFormat::Markdown,
Some(&target.display().to_string()),
);
assert!(matches!(rx.try_recv().unwrap(), AppEvent::Notice(_)));
assert_eq!(std::fs::read_to_string(&target).unwrap(), copied);
}
#[test]
fn a_json_export_imports_back_and_says_what_it_drops() {
let (dir, mut orch, mut rx, id) = orch_with_conversation();
let target = dir.path().join("chat.json");
orch.handle_export_chat(id, ExportFormat::Json, Some(&target.display().to_string()));
match rx.try_recv().unwrap() {
AppEvent::Notice(text) => {
assert!(text.contains("chat.json"), "the note names it: {text}");
assert!(
text.contains("md"),
"the note must name the format that keeps tool calls: {text}"
);
}
other => panic!("expected a Notice, got {other:?}"),
}
let json = std::fs::read_to_string(&target).unwrap();
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
let result = crate::features::import::parse_import(&json, loc)
.expect("the export is a valid import document");
assert_eq!(result.chats.len(), 1);
assert_eq!(result.chats[0].id, id, "it comes back as the same chat");
assert_eq!(result.chats[0].title, "Про космос");
}
#[test]
fn exporting_an_empty_chat_is_refused() {
let (dir, mut orch, mut rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let empty = Chat::from_profile(&profile, "Пустой");
let id = empty.id;
orch.profiles.push(profile);
orch.chats.push(empty);
let target = dir.path().join("empty.md");
orch.handle_export_chat(
id,
ExportFormat::Markdown,
Some(&target.display().to_string()),
);
assert!(matches!(rx.try_recv().unwrap(), AppEvent::Error(_)));
assert!(!target.exists(), "nothing may be written");
}