use std::path::{Path, PathBuf};
use qframe::color::ColorDepth;
use qframe::env::{AssetDirs, Env};
use qframe::event::MouseKind;
use qframe::icons::GlyphMode;
use serde_json::{Value, json};
use super::*;
use crate::provider::fake::{FakeServer, calls, says};
const MADE_UP: &str = "not-a-real-key-0000-wxyz";
pub(crate) struct Place {
pub(crate) base: PathBuf,
}
impl Place {
pub(crate) fn new(name: &str) -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let number = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let base = std::env::temp_dir().join(format!("qcli-app-{name}-{}-{number}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("work")).expect("the working folder");
std::fs::create_dir_all(base.join("config")).expect("the settings folder");
let key = base.join("key");
std::fs::write(&key, format!("{MADE_UP}\n")).expect("the key file");
std::fs::set_permissions(&key, std::os::unix::fs::PermissionsExt::from_mode(0o600)).expect("private");
Self { base }
}
pub(crate) fn work(&self) -> PathBuf {
self.base.join("work")
}
pub(crate) fn base(&self) -> &Path {
&self.base
}
pub(crate) fn store(&self) -> Store {
let workspace = Workspace::new(&self.work()).expect("workspace");
Store::new(&self.base.join("conversations"), workspace.root())
}
pub(crate) fn settings(&self, address: Option<&str>) -> Settings {
let mut settings = config::load_in(&self.base.join("config"));
if let Some(address) = address {
Provider {
address: address.to_owned(),
model: "test-model".into(),
key_file: self.base.join("key").display().to_string(),
key_header: "api-key".into(),
source: config::Source::Settings,
}
.write(&mut settings);
}
settings
}
pub(crate) fn app(&self, address: Option<&str>) -> QCli {
QCli::new(Workspace::new(&self.work()).expect("workspace"), self.store(), self.settings(address))
}
}
pub(crate) fn opened(app: QCli, folder: &Path) -> Harness<QCli> {
let mut harness = runtime(app).harness_in(folder, 100, 40).expect("the runtime opens");
harness.set_depth(ColorDepth::TrueColor);
harness
}
impl Drop for Place {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn env() -> Env {
let dirs = AssetDirs {
locale_sources: locales(),
keymap_source: Some(("keymap.toml".to_owned(), KEYMAP.to_owned())),
..AssetDirs::default()
};
Env::load(&dirs).expect("the built-in files load")
}
pub(crate) fn harness(app: QCli) -> Harness<QCli> {
let mut harness = Harness::with_env(app, env(), 100, 40);
harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode).set_reduced_motion(true);
harness
}
fn harness_with(app: QCli, locales: Vec<(String, String)>, keymap: &str) -> Harness<QCli> {
let dirs = AssetDirs {
locale_sources: locales,
keymap_source: Some(("keymap.toml".to_owned(), keymap.to_owned())),
..AssetDirs::default()
};
let env = Env::load(&dirs).expect("the test's own files load");
let mut harness = Harness::with_env(app, env, 100, 40);
harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode).set_reduced_motion(true);
harness
}
fn locales() -> Vec<(String, String)> {
crate::locales().iter().map(|(file, text)| ((*file).to_owned(), (*text).to_owned())).collect()
}
fn locales_with_english(english: &str) -> Vec<(String, String)> {
let mut files = locales();
files.retain(|(file, _)| file != "en.toml");
files.insert(0, ("en.toml".to_owned(), english.to_owned()));
files
}
pub(crate) fn say(harness: &mut Harness<QCli>, text: &str) {
harness.type_text(text).press("enter");
}
fn last_message(server: &FakeServer, index: usize) -> Value {
server.received()[index].body["messages"]
.as_array()
.and_then(|messages| messages.last().cloned())
.expect("a message")
}
fn saved(place: &Place) -> String {
std::fs::read_to_string(place.store().file()).expect("the conversation is saved")
}
#[test]
fn the_name_in_the_header_comes_from_the_language_file() {
let place = Place::new("name");
let (file, text) = crate::locales()[0];
assert_eq!(file, "en.toml");
let named = text.replacen("name = \"qcli\"", "name = \"the-test-name\"", 1);
assert_ne!(named, text, "the test's file really carries another name: {named}");
let harness = harness_with(place.app(None), locales_with_english(&named), KEYMAP);
let screen = harness.screen();
assert!(screen.contains("the-test-name"), "the header asks the language file:\n{screen}");
assert!(!screen.contains(" qcli "), "and writes no name of its own:\n{screen}");
}
#[test]
fn a_message_goes_out_and_the_reply_streams_onto_the_screen_and_into_the_file() {
let place = Place::new("chat");
let server = FakeServer::start(vec![(200, says("Hello from the stand-in."))]);
let mut harness = harness(place.app(Some(&server.address)));
assert!(harness.screen().contains("Ask anything about this folder"));
say(&mut harness, "hello there");
assert!(harness.screen().contains("hello there"), "{}", harness.screen());
assert!(harness.screen().contains("Hello from the stand-in."), "{}", harness.screen());
assert_eq!(harness.app().phase(), &Phase::Idle);
let request = &server.received()[0];
assert_eq!(request.body["messages"][0]["content"][0]["text"], "hello there");
assert!(request.body["system"].as_str().is_some_and(|system| system.contains("qcli")));
assert_eq!(request.body["tools"].as_array().map(Vec::len), Some(5));
assert_eq!(request.header("api-key"), Some(MADE_UP));
assert_eq!(request.body["model"], "test-model", "the model comes from the settings");
let file = saved(&place);
assert!(file.contains("Hello from the stand-in."));
assert!(!file.contains(MADE_UP), "the key never reaches the saved conversation");
assert!(!harness.screen().contains(MADE_UP), "nor the screen");
}
#[test]
fn the_conversation_comes_back_when_the_folder_is_opened_again() {
let place = Place::new("resume");
let server = FakeServer::start(vec![(200, says("First answer.")), (200, says("Second answer."))]);
{
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "first question");
}
let mut harness = harness(place.app(Some(&server.address)));
assert!(harness.screen().contains("first question"));
assert!(harness.screen().contains("First answer."));
say(&mut harness, "second question");
let messages = server.received()[1].body["messages"].clone();
assert_eq!(messages.as_array().map(Vec::len), Some(3), "the earlier turn goes out with the new one");
}
#[test]
fn a_read_is_done_without_asking_and_its_result_goes_back_to_the_model() {
let place = Place::new("read");
std::fs::write(place.work().join("notes.txt"), "the secret word is apricot\n").expect("write");
let server = FakeServer::start(vec![
(200, calls("c1", "read_file", &json!({"path": "notes.txt"}))),
(200, says("The word is apricot.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "what is the word?");
assert_eq!(server.received().len(), 2, "no question in between: reading does not ask");
let result = &last_message(&server, 1)["content"][0];
assert_eq!(result["type"], "tool_result");
assert_eq!(result["tool_use_id"], "c1");
assert!(result["content"].as_str().is_some_and(|text| text.contains("apricot")));
let screen = harness.screen();
assert!(screen.contains("read notes.txt"), "{screen}");
assert!(screen.contains("The word is apricot."));
}
#[test]
fn an_edit_shows_its_diff_and_waits_until_allow_is_clicked() {
let place = Place::new("edit");
let file = place.work().join("greeting.txt");
std::fs::write(&file, "hello world\n").expect("write");
let server = FakeServer::start(vec![
(200, calls("e1", "edit_file", &json!({"path": "greeting.txt", "old_text": "world", "new_text": "moon"}))),
(200, says("Changed it.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "say hello to the moon");
let screen = harness.screen();
assert!(screen.contains("Change greeting.txt?"), "{screen}");
assert!(screen.contains("hello moon") && screen.contains("hello world"), "both sides of the diff: {screen}");
assert_eq!(std::fs::read_to_string(&file).expect("read"), "hello world\n", "nothing changes before the answer");
assert_eq!(server.received().len(), 1);
read_card(&mut harness);
harness.click_text("Allow");
assert_eq!(std::fs::read_to_string(&file).expect("read"), "hello moon\n");
let result = &last_message(&server, 1)["content"][0];
assert_eq!(result["is_error"], false);
assert!(harness.screen().contains("Changed it."));
}
#[test]
fn a_declined_command_is_never_run_and_the_model_hears_why() {
let place = Place::new("decline");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
(200, says("Understood.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "make a file");
assert!(harness.screen().contains("Run this command?"));
assert!(harness.screen().contains("printf ran > ran.txt"));
read_card(&mut harness);
harness.click_text("Decline");
assert!(!place.work().join("ran.txt").exists(), "the command did not run");
let result = &last_message(&server, 1)["content"][0];
assert_eq!(result["content"], DECLINED);
assert_eq!(result["is_error"], true);
}
#[test]
fn the_y_key_allows_a_command_and_its_output_goes_back() {
let place = Place::new("run");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
(200, says("Done.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "run it");
read_card(&mut harness);
harness.press("y");
let result = &last_message(&server, 1)["content"][0];
assert!(result["content"].as_str().is_some_and(|text| text.contains("from the command")), "{result}");
assert!(harness.screen().contains("from the command"), "the start of the output is shown");
}
#[test]
fn keys_typed_while_a_card_comes_up_do_not_answer_it() {
let place = Place::new("typing-on");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
(200, says("Done.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "make a file");
assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card is up");
harness.type_text("hey you").press("enter").press("space").press("n");
assert!(!place.work().join("ran.txt").exists(), "nothing typed on ran the command");
assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "nor answered the card either way");
assert_eq!(server.received().len(), 1, "and nothing went back to the model");
harness.advance(QUIET);
harness.press("y");
assert!(place.work().join("ran.txt").exists(), "a key pressed after the pause answers the card");
}
#[test]
fn enter_and_space_on_a_card_answer_nothing() {
let place = Place::new("enter-answers-nothing");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
(200, says("Understood.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "make a file");
read_card(&mut harness);
harness.press("enter").press("space");
assert!(!place.work().join("ran.txt").exists(), "neither ran the command");
assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card still waits");
assert_eq!(server.received().len(), 1);
read_card(&mut harness);
harness.click_text("Decline");
assert_eq!(last_message(&server, 1)["content"][0]["content"], DECLINED, "a click answers it");
}
pub(crate) fn read_card(harness: &mut Harness<QCli>) {
harness.advance(QUIET);
}
fn button_row(screen: &str) -> String {
screen.lines().find(|line| line.contains("Allow")).expect("the card's button row").trim().to_owned()
}
#[test]
fn a_cards_buttons_name_the_keys_the_keymap_binds_them_to() {
let place = Place::new("card-keys");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
(200, says("Done.")),
]);
let moved = KEYMAP.replace("allow = \"y\"", "allow = \"ctrl+y\"").replace("deny = \"n\"", "deny = \"ctrl+e\"");
assert_ne!(moved, KEYMAP, "the test's keymap really moves both bindings");
let mut harness = harness_with(place.app(Some(&server.address)), locales(), &moved);
say(&mut harness, "run it");
let screen = harness.screen();
let row = button_row(&screen);
assert!(row.contains("ctrl y Allow"), "the moved allow key is on the button: {row}");
assert!(row.contains("ctrl e Decline"), "and the moved deny key on the other: {row}");
read_card(&mut harness);
harness.press("ctrl+y");
let result = &last_message(&server, 1)["content"][0];
assert!(
result["content"].as_str().is_some_and(|text| text.contains("from the command")),
"the key the button names is the key that answers: {result}"
);
let own = Place::new("card-keys-own");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
(200, says("Done.")),
]);
let mut shipped = super::tests::harness(own.app(Some(&server.address)));
say(&mut shipped, "run it");
let row = button_row(&shipped.screen());
assert!(row.contains("y Allow") && row.contains("n Decline"), "the letters of qcli's own keymap: {row}");
assert_ne!(row, button_row(&screen), "the card follows the keymap it is given");
}
#[test]
fn the_cards_buttons_carry_the_single_letters_the_keymap_binds() {
let place = Place::new("card-keys-letters");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
(200, says("Done.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "run it");
let row = button_row(&harness.screen());
assert!(row.contains("y Allow"), "allow is bound to y: {row}");
assert!(row.contains("n Decline"), "deny is bound to n: {row}");
}
#[test]
fn esc_on_a_card_stops_the_turn_and_answers_every_waiting_call() {
let place = Place::new("stop");
let reply = crate::provider::fake::sse(&[
json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "a", "name": "run_command", "input": {}}}),
json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"command\": \"printf a\"}"}}),
json!({"type": "content_block_stop", "index": 0}),
json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "b", "name": "list_dir", "input": {}}}),
json!({"type": "content_block_stop", "index": 1}),
json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
json!({"type": "message_stop"}),
]);
let server = FakeServer::start(vec![(200, reply), (200, says("Fine."))]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "two things");
assert!(matches!(harness.app().phase(), Phase::Asking { .. }));
harness.press("esc");
assert_eq!(harness.app().phase(), &Phase::Idle);
assert_eq!(harness.app().notice(), Some(&Notice::Stopped));
assert_eq!(server.received().len(), 1, "nothing more goes out after a stop");
let last = harness.app().conversation().messages().last().cloned().expect("a message");
let ids: Vec<_> = conversation::blocks(&last).map(|block| block["tool_use_id"].clone()).collect();
assert_eq!(ids, ["a", "b"], "both calls are answered, so the next request is one the API accepts");
assert!(harness.screen().contains("Stopped."));
say(&mut harness, "go on");
assert_eq!(server.received().len(), 2, "a stopped turn does not keep the next one from starting");
}
#[test]
fn a_refusal_is_shown_in_the_servers_words() {
let place = Place::new("refused");
let body = json!({"type": "error", "error": {"type": "authentication_error", "message": "invalid api key"}});
let server = FakeServer::start(vec![(401, body.to_string())]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "hi");
let screen = harness.screen();
assert!(screen.contains("The provider did not answer"), "{screen}");
assert!(screen.contains("invalid api key"));
assert_eq!(harness.app().phase(), &Phase::Idle);
}
#[test]
fn the_folders_agents_file_goes_out_as_instructions() {
let place = Place::new("agents");
std::fs::write(place.work().join("AGENTS.md"), "Answer like a pirate.\n").expect("write");
let server = FakeServer::start(vec![(200, says("Arr."))]);
let mut harness = harness(place.app(Some(&server.address)));
assert!(harness.screen().contains("AGENTS.md is read as instructions."));
say(&mut harness, "hi");
let system = server.received()[0].body["system"].as_str().unwrap_or_default().to_owned();
assert!(system.ends_with("Answer like a pirate."), "{system}");
}
#[test]
fn without_a_provider_the_form_opens_and_saving_it_writes_the_settings() {
let place = Place::new("form");
let mut harness = harness(place.app(None));
assert!(harness.screen().contains("Provider"), "{}", harness.screen());
assert!(harness.is_focused(form::FIRST));
harness.click_text("Save");
assert!(harness.screen().contains("This cannot be empty."), "the empty fields say so");
assert!(harness.is_focused(form::FIRST), "and focus goes back to the first of them");
harness.type_text("http://127.0.0.1:9/anthropic").press("tab").type_text("some-model").press("tab");
harness.type_text(&place.base.join("missing-key").display().to_string());
harness.click_text("Save");
assert!(
harness.screen().contains("cannot be read"),
"a key file that is not there is caught: {}",
harness.screen()
);
assert!(harness.is_focused("provider-key-file"));
harness.press("ctrl+a").type_text(&place.base.join("key").display().to_string());
harness.click_text("Save");
assert!(!harness.screen().contains("Key file"), "the form closed: {}", harness.screen());
let conf = std::fs::read_to_string(place.base.join("config/cli.conf")).expect("cli.conf is written");
assert!(conf.contains("model = \"some-model\""), "{conf}");
assert!(!conf.contains(MADE_UP), "only the key file's path is written, never the key");
}
#[test]
fn the_language_the_settings_name_is_the_language_the_screen_is_drawn_in() {
let place = Place::new("language");
let folder = place.base.join("config");
std::fs::write(folder.join("quvyta.conf"), "language = \"en\"\n").expect("the ecosystem's shared file");
std::fs::write(folder.join("cli.conf"), "language = \"tr\"\n").expect("qcli's own file");
let harness = opened(place.app(None), &folder);
let screen = harness.screen();
assert_eq!(harness.env().i18n().active(), "tr", "qcli's own file wins over the shared one");
assert!(screen.contains("Bu klasörle ilgili"), "qcli's own words are drawn in Turkish:\n{screen}");
assert!(screen.contains("çık"), "and so are the framework's:\n{screen}");
assert!(!screen.contains("quit"), "and not in English:\n{screen}");
}
#[test]
fn run_starts_qcli_as_a_member_of_the_ecosystem() {
let place = Place::new("run-member");
let folder = place.base.join("config");
std::fs::write(folder.join("quvyta.conf"), "language = \"tr\"\n").expect("the ecosystem's shared file");
let harness = opened(place.app(None), &folder);
let screen = harness.screen();
assert_eq!(harness.env().i18n().active(), "tr", "the shared language is followed");
assert!(screen.contains("Bu klasörle ilgili"), "in qcli's own words:\n{screen}");
}
#[test]
fn every_value_in_qclis_own_file_is_the_value_the_screen_is_drawn_with() {
let server = FakeServer::start(vec![]);
let drawn = |name: &str, own: Option<&str>| {
let place = Place::new(name);
let folder = place.base.join("config");
std::fs::write(folder.join("quvyta.conf"), "theme = \"monochrome\"\nicons = \"unicode\"\n").expect("shared");
if let Some(own) = own {
std::fs::write(folder.join("cli.conf"), own).expect("qcli's own");
}
let app = place.app(Some(&server.address));
let harness = opened(app, &folder);
let name = harness.env().i18n().translate("app.name", &[]);
let (x, y) = harness.find(&name).expect("the name in the corner");
let screen = harness.screen();
let placeholder = harness.env().i18n().translate("chat.placeholder", &[]);
let field = screen.lines().find(|line| line.contains(&placeholder)).unwrap_or_default();
(harness.fg(x as u16, y as u16), field.trim().to_owned())
};
let (amber, ascii) = drawn("settings-own", Some("theme = \"amber\"\nicons = \"ascii\"\n"));
let (plain, unicode) = drawn("settings-shared", None);
assert_ne!(amber, plain, "`theme` is the colour the name in the corner is drawn in");
assert!(ascii.starts_with("> "), "`icons = \"ascii\"` writes `>`: {ascii:?}");
assert!(unicode.starts_with("▌ ❯"), "and `icons = \"unicode\"` writes the pillar and the arrow: {unicode:?}");
}
#[test]
fn the_forms_problems_are_named_after_the_controls_they_belong_to() {
let provider = Provider::default();
let mut form = ProviderForm::from_provider(&provider);
assert!(form.check().is_none(), "an empty provider cannot be asked");
let named: Vec<_> = form.errors().iter().map(|(field, _)| field).collect();
assert_eq!(named, ["provider-address", "provider-model", "provider-key-file"], "in field order");
assert_eq!(form.errors().first(), Some(form::FIRST), "focus goes to the first of them");
let empty = form.errors().get(form::FIRST).expect("the address is empty, so it has a problem");
assert!(!empty.is_empty(), "an empty field says so: {empty}");
assert!(named.iter().all(|field| form.errors().get(field) == Some(empty)), "every empty field says the same");
assert!(!form.errors().has("provider-key-header"), "the header has a default, so it is never a problem");
let mut form = ProviderForm::from_provider(&Provider {
address: "http://127.0.0.1:9".to_owned(),
model: "some-model".to_owned(),
key_file: "/nowhere/at/all/made-up-key".to_owned(),
key_header: String::new(),
..Provider::default()
});
assert!(form.check().is_none());
assert_eq!(form.errors().iter().map(|(field, _)| field).collect::<Vec<_>>(), ["provider-key-file"]);
let problem = form.errors().get("provider-key-file").expect("the key file is the problem");
assert_ne!(problem, empty, "the key file's own problem, not an empty field's: {problem}");
assert!(form.errors().get("provider-address").is_none(), "the fields that are filled are left alone");
}
#[test]
fn ctrl_n_puts_the_conversation_aside_and_starts_a_new_one() {
let place = Place::new("new");
let server = FakeServer::start(vec![(200, says("Remember me."))]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "keep this");
harness.press("ctrl+n");
assert!(harness.app().conversation().is_empty());
assert!(harness.screen().contains("Ask anything about this folder"));
let kept = std::fs::read_dir(place.base.join("conversations")).expect("folder").count();
assert_eq!(kept, 1, "the old conversation is kept under another name");
}
#[test]
fn the_end_of_the_conversation_stays_in_view_as_it_grows() {
let place = Place::new("follow");
let long: String = (1..=60).map(|n| format!("Line number {n} of a long answer.\n\n")).collect();
let server = FakeServer::start(vec![(200, says(&long))]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "tell me a lot");
let screen = harness.screen();
assert!(screen.contains("Line number 60"), "the last line is on screen: {screen}");
assert!(screen.contains("Write a message"), "and the writing field under it");
}
#[test]
fn a_person_scrolled_up_to_read_is_not_pulled_down_while_the_reply_streams() {
let place = Place::new("read-back");
let long: String = (1..=60).map(|n| format!("Line number {n} of a long answer.\n\n")).collect();
let first = crate::provider::fake::sse(&[
json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": long}}),
json!({"type": "content_block_stop", "index": 0}),
json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "r1", "name": "run_command", "input": {}}}),
json!({"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"command\": \"printf done\"}"}}),
json!({"type": "content_block_stop", "index": 1}),
json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
json!({"type": "message_stop"}),
]);
let more: String = (1..=20).map(|n| format!("More text number {n}.\n\n")).collect();
let server = FakeServer::start(vec![(200, first), (200, says(&more)), (200, says("Back at the end."))]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "tell me a lot");
assert!(harness.screen().contains("Run this command?"), "the card is in view at the end");
for _ in 0..10 {
harness.mouse(MouseKind::ScrollUp, 40, 10);
}
let reading = harness.screen();
assert!(!reading.contains("Run this command?"), "scrolled up: {reading}");
read_card(&mut harness);
harness.press("y");
assert_eq!(server.received().len(), 2, "the reply after the command came");
let lines = |screen: &str| {
screen
.lines()
.filter_map(|line| line.split(" of a long").next().filter(|_| line.contains("Line number")))
.map(|line| line.trim().to_owned())
.collect::<Vec<_>>()
};
let (before, after) = (lines(&reading), lines(&harness.screen()));
assert!(!after.is_empty() && before.starts_with(&after), "the view moved: {}", harness.screen());
assert!(before.len() - after.len() <= 1);
assert!(!harness.screen().contains("More text number 20"));
assert!(harness.screen().contains("Write a message"), "the writing field stays under the conversation");
say(&mut harness, "and now?");
assert!(harness.screen().contains("Back at the end."), "{}", harness.screen());
}
#[test]
#[ignore = "talks to a real provider; run by hand"]
fn live_a_real_model_reads_a_file_it_is_asked_about() {
let place = Place::new("live");
std::fs::write(place.work().join("pantry.txt"), "The jar on the top shelf holds cardamom.\n").expect("write");
let variable = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"));
let mut settings = config::load_in(&place.base.join("config"));
Provider {
address: variable("QCLI_LIVE_ADDRESS"),
model: variable("QCLI_LIVE_MODEL"),
key_file: variable("QCLI_LIVE_KEY_FILE"),
key_header: std::env::var("QCLI_LIVE_HEADER").unwrap_or_default(),
source: config::Source::Settings,
}
.write(&mut settings);
let app = QCli::new(Workspace::new(&place.work()).expect("workspace"), place.store(), settings);
let mut harness = harness(app);
say(&mut harness, "Read pantry.txt and tell me in one short sentence what the jar on the top shelf holds.");
println!("{}", harness.screen());
assert_eq!(harness.app().notice(), None, "no error");
let messages = harness.app().conversation().messages();
let read = messages.iter().flat_map(conversation::blocks).any(|block| block["name"] == "read_file");
assert!(read, "the model used the read action");
let answer = messages.last().map(ToString::to_string).unwrap_or_default().to_lowercase();
assert!(answer.contains("cardamom"), "the answer comes from the file: {answer}");
}
fn update_folders(place: &Place) -> config::UpdateFolders {
config::UpdateFolders { config: place.base.join("config"), state: place.base.join("state") }
}
#[test]
fn qcli_asks_once_at_start_for_a_newer_version_of_itself() {
let place = Place::new("updates");
let harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
let asked = harness.update_checks().to_vec();
assert_eq!(asked.len(), 1, "one question at start");
assert_eq!((asked[0].package(), asked[0].current()), ("quvyta-cli", env!("CARGO_PKG_VERSION")));
}
fn next_alpha() -> String {
let current = env!("CARGO_PKG_VERSION");
let (base, number) = current.rsplit_once('.').expect("an alpha version");
let number: u32 = number.parse().expect("the alpha's number");
format!("{base}.{}", number + 1)
}
#[test]
fn a_person_on_an_alpha_is_told_in_the_corner_when_the_next_alpha_is_out() {
let place = Place::new("updates-alpha");
let mut harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
let next = next_alpha();
harness.set_latest_version(Some(&next));
let screen = harness.screen();
assert!(screen.contains(&next), "the next alpha is named in the corner:\n{screen}");
}
#[test]
fn a_person_on_an_alpha_is_not_told_about_the_alpha_already_running() {
let place = Place::new("updates-same");
let mut harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
harness.set_latest_version(Some(env!("CARGO_PKG_VERSION")));
assert!(!harness.screen().contains("is out"), "nothing newer, nothing said");
}
#[test]
fn with_the_ecosystems_switch_off_nothing_is_asked() {
let place = Place::new("updates-off");
std::fs::write(place.base.join("config/quvyta.conf"), "update-notice = false\n")
.expect("the ecosystem's shared file");
let mut harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
assert!(harness.update_checks().is_empty(), "nothing is asked");
harness.set_latest_version(Some("9.4.7"));
assert!(!harness.screen().contains("is out"));
}
#[test]
fn the_n_key_declines_a_card_and_nothing_runs() {
let place = Place::new("n-declines");
let server = FakeServer::start(vec![
(200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
(200, says("Understood.")),
]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "make a file");
read_card(&mut harness);
harness.press("n");
assert!(!place.work().join("ran.txt").exists(), "the command did not run");
assert_eq!(last_message(&server, 1)["content"][0]["content"], DECLINED);
}
#[test]
fn cancel_on_the_provider_form_closes_it_and_writes_nothing() {
let place = Place::new("form-cancel");
let mut harness = harness(place.app(Some("https://models.example.test")));
let settings = place.base().join("config").join("cli.conf");
let before = std::fs::read_to_string(&settings).unwrap_or_default();
harness.press("ctrl+o");
harness.type_text("-changed");
harness.click_text("Cancel");
assert!(harness.app().form.is_none(), "the form is closed");
assert_eq!(std::fs::read_to_string(&settings).unwrap_or_default(), before, "and nothing was written");
assert!(harness.screen().contains("Write a message"), "the chat is back: {}", harness.screen());
}
#[test]
fn a_conversation_that_cannot_be_saved_is_said_and_the_chat_goes_on() {
let place = Place::new("unsaved");
std::fs::write(place.base().join("conversations"), "in the way").expect("a file in the way");
let server = FakeServer::start(vec![(200, says("Still here."))]);
let mut harness = harness(place.app(Some(&server.address)));
say(&mut harness, "hello");
assert!(matches!(harness.app().notice(), Some(Notice::Unsaved(_))), "{:?}", harness.app().notice());
assert!(harness.screen().contains("could not be saved"), "the person is told: {}", harness.screen());
assert!(harness.screen().contains("Still here."), "and the reply still came: {}", harness.screen());
}