Files
craft-anything/crates/ui-egui/tests/smoke.rs
BFlat 55816983e8 First-run steps scroll when the window is too short, so Continue stays reachable (#243)
At the smallest window the app allows (760×520), the first-run "What brings you here?" step was taller than the window and had nothing to scroll, so its Continue button sat below the window, out of reach.

The first-run steps now sit in a scroll area that stops above the footer (Back and the step counter). When a step fits, nothing changes; when it doesn't, it scrolls.

**Verified:**
- A new smoke test at 760×520 scrolls Continue into view above the footer and clicks it through to the last step. Without the fix, Continue is drawn at y=646 in a 520-high window and the test fails.
- `cargo test -p craft-anything-ui-egui` (all suites), clippy, fmt.
- Rendered the first-run screens at the default size (unchanged, no scroll bar) and at 760×520 (scrolls, ends above the footer).

Fixes #231
2026-11-02 13:32:55 +07:00

2147 lines
94 KiB
Rust

//! UI smoke tests: every screen draws, the main controls work, and nothing blocks or panics.
use craft_anything_engine::{Mode, TargetKind};
use craft_anything_ui_egui::state::{ChatItem, FirstRunStep, RailKind, Screen};
use craft_anything_ui_egui::{CraftApp, demo};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use std::time::{Duration, Instant};
fn harness(make: impl FnOnce() -> CraftApp + 'static) -> Harness<'static, CraftApp> {
harness_sized(egui::vec2(1280.0, 820.0), make)
}
fn harness_sized(size: egui::Vec2, make: impl FnOnce() -> CraftApp + 'static) -> Harness<'static, CraftApp> {
Harness::builder().with_size(size).with_max_steps(32).build_eframe(move |cc| {
CraftApp::setup_context(&cc.egui_ctx);
make()
})
}
/// Step until `done` holds (workers run on threads), or give up after a few seconds.
fn wait(h: &mut Harness<'static, CraftApp>, done: impl Fn(&CraftApp) -> bool) -> bool {
let start = Instant::now();
while start.elapsed() < Duration::from_secs(10) {
h.step();
if done(h.state()) {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
#[test]
fn first_run_walks_through_four_screens() {
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, false)));
h.run_steps(3);
// The window controls are there from the first screen (the native frame is off).
h.get_by_label("Close");
h.get_by_label("I'm new to this").click();
h.run_steps(3);
assert_eq!(h.state().state.first_run, FirstRunStep::Brain);
assert_eq!(h.state().settings.mode, Mode::Beginner);
h.get_by_label("No AI for now").click();
h.run_steps(3);
assert_eq!(h.state().state.first_run, FirstRunStep::Purpose);
assert!(h.state().state.backend.is_none());
// Why we ask is read on the page, not only on hover (#71).
h.get_by_label_contains("Saying why helps you keep to it");
h.get_by_label("I'm learning how things work").click();
h.run_steps(2);
h.get_by_label("Continue").click();
h.run_steps(3);
assert_eq!(h.state().state.first_run, FirstRunStep::Done);
h.get_by_label("Let's go").click();
h.run_steps(4);
assert!(h.state().settings.first_run_done);
assert_eq!(h.state().state.screen, Screen::Home);
h.get_by_label("Just look inside");
}
/// #231: at the smallest window the app allows, Purpose's Continue was below the window with
/// nothing to scroll; it scrolls into view above the footer and works.
#[test]
fn first_run_continue_is_reachable_at_the_smallest_window() {
let (w, hgt) = (760.0, 520.0);
let mut h = harness_sized(egui::vec2(w, hgt), || demo::app(demo::engine(Mode::Beginner, false)));
h.state_mut().state.first_run = FirstRunStep::Purpose;
h.run_steps(3);
h.get_by_label("Continue").scroll_to_me();
h.run_steps(3);
let r = h.get_by_label("Continue").rect();
assert!(r.top() >= 0.0 && r.bottom() <= hgt - 48.0, "Continue at {r:?} isn't in view above the footer");
h.get_by_label("Continue").click();
h.run_steps(3);
assert_eq!(h.state().state.first_run, FirstRunStep::Done);
}
#[test]
fn simple_title_bar_keeps_only_the_essentials() {
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(3);
assert!(h.query_by_label("Advanced").is_none(), "the mode switch lives in Settings in Simple mode");
h.get_by_label("Settings");
h.get_by_label("Clean room");
}
#[test]
fn a_game_is_built_with_its_engine_and_a_missing_engine_is_got_first() {
use craft_anything_engine::workspace::TargetRef;
use craft_anything_engines::game::{Builder, Found, Installed, Inventory, Made};
use craft_anything_ui_egui::state::Modal;
let d = tempfile::tempdir().unwrap();
let game = d.path().join("game");
std::fs::create_dir_all(&game).unwrap();
// Tall enough to show Start below the Clean room / Deep, engine and asset choices.
let mut h = harness_sized(egui::vec2(1280.0, 1120.0), move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_projects(&mut a);
a.state.make.target = TargetRef::Local { path: game };
a.state.make.game = true;
a.state.make.request = "Recreate this game".into();
a.state.make.found = Some(Found { engine: Made::Unity, version: Some("2021.3.16f1".into()), clue: "unityplayer.dll".into(), note: None });
a.game_engines = Some(Inventory {
engines: vec![Installed { builder: Builder::Godot, version: "4.4.1".into(), program: "godot.exe".into() }],
..Default::default()
});
a
});
h.run_steps(3);
h.get_by_label("Made with Unity 2021.3.16f1");
h.get_by_label("Get Unity");
// Unity isn't here, so Start opens the steps to get it (and starts once it's ready).
h.get_by_label("Start").click();
h.run_steps(4);
assert_eq!(h.state().state.modal, Some(Modal::Engine(Builder::Unity)));
assert!(h.state().engine_flow.start_after);
// The first step: Unity's own installer where we can check its signature, else its website.
h.get_by_label(if cfg!(windows) { "Download Unity Hub" } else { "Get Unity Hub" });
h.get_by_label("Build with Godot instead");
assert!(h.state().projects.iter().all(|p| p.manifest.request != "Recreate this game"), "nothing started yet");
}
/// Whether the fal.ai key is set up, as the app keeps it.
fn fal_key(stored: bool) -> std::collections::BTreeMap<String, bool> {
[(craft_anything_engines::assets::fal::ID.to_string(), stored)].into()
}
#[test]
fn turning_on_generated_assets_without_a_fal_key_asks_for_one() {
use craft_anything_engine::workspace::TargetRef;
use craft_anything_engines::game::{Builder, Installed, Inventory};
use craft_anything_ui_egui::state::Modal;
let d = tempfile::tempdir().unwrap();
let game = d.path().join("game");
std::fs::create_dir_all(&game).unwrap();
let mut h = harness_sized(egui::vec2(1280.0, 1000.0), move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_projects(&mut a);
a.state.make.target = TargetRef::Local { path: game };
a.state.make.game = true;
a.state.make.request = "Recreate this game".into();
a.state.make.builder = Some(Builder::Unity);
a.game_engines = Some(Inventory {
engines: vec![Installed { builder: Builder::Unity, version: "6000.0.23f1".into(), program: "Unity.exe".into() }],
..Default::default()
});
a.asset_keys = Some(fal_key(false));
a
});
h.run_steps(3);
assert!(!h.state().state.make.generate_assets, "off until the person turns it on");
h.get_by_label("Generate custom pictures and models").click();
h.run_steps(3);
assert!(h.state().state.make.generate_assets);
assert_eq!(h.state().state.modal, Some(Modal::Assets), "no key yet, so it's asked for");
h.get_by_label("Your fal.ai key");
h.get_by_label("fal.ai key");
h.get_by_label("Not now").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, None);
assert!(!h.state().state.make.generate_assets, "without a key it goes back off");
// The switch says what it costs and what happens without it, in either mode.
for deep in [true, false] {
h.state_mut().settings.deep_mode = deep;
h.run_steps(2);
h.get_by_label_contains("Off, the game is built without them.");
assert!(h.query_by_label_contains("original game").is_none());
}
// With a key, turning it on asks nothing.
h.state_mut().asset_keys = Some(fal_key(true));
h.get_by_label("Generate custom pictures and models").click();
h.run_steps(3);
assert!(h.state().state.make.generate_assets && h.state().state.modal.is_none());
}
#[test]
fn deep_mode_without_ghidra_asks_to_get_it_before_using_it() {
use craft_anything_engine::workspace::TargetRef;
use craft_anything_engines::game::{Builder, Installed, Inventory};
use craft_anything_ui_egui::state::Modal;
let d = tempfile::tempdir().unwrap();
let (store, game) = (d.path().join("engines"), d.path().join("game"));
std::fs::create_dir_all(&game).unwrap();
// Tall enough to show Start below the Clean room / Deep, engine and asset choices.
let mut h = harness_sized(egui::vec2(1280.0, 1120.0), move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_projects(&mut a);
// An empty engine store: no Ghidra here.
a.settings.engines.store_dir = Some(store);
a.state.make.target = TargetRef::Local { path: game };
a.state.make.game = true;
a.state.make.request = "Recreate this game".into();
a.state.make.builder = Some(Builder::Unity);
a.game_engines = Some(Inventory {
engines: vec![Installed { builder: Builder::Unity, version: "6000.0.23f1".into(), program: "Unity.exe".into() }],
..Default::default()
});
a.asset_keys = Some(fal_key(true));
a
});
h.run_steps(3);
// An app or a game offers Clean room or Deep right on the Make screen; picking Deep offers
// Ghidra instead of quietly going without it.
h.get_by_label("How closely may Craft Anything look at it?");
h.get_by_label("Deep").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, Some(Modal::Ghidra));
h.get_by_label("Get Ghidra for Deep mode");
if craft_anything_engines::ghidra::get::can_install() {
h.get_by_label("Download and use Ghidra");
}
h.get_by_label("I have Ghidra");
h.get_by_label("Not now").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, None);
assert!(h.state().settings.deep_mode && h.state_mut().ghidra_missing(), "Deep stays on, without Ghidra");
// Starting a build of a program asks again, and the project waits for the answer.
h.get_by_label("Start").click();
h.run_steps(4);
assert_eq!(h.state().state.modal, Some(Modal::Ghidra));
assert!(h.state().ghidra.start_after.is_some(), "the build starts once Ghidra is ready");
h.get_by_label("Build without it");
}
#[test]
fn a_long_prompt_wraps_inside_the_window_in_the_project_and_the_chat() {
use craft_anything_ui_egui::state::{ChatItem, FeedItem};
// Long sentences and one very long unbroken address.
let long = format!("{}https://example.com/{}", "Make it exactly like the original, with the same menus, weapons and maps. ".repeat(8), "a".repeat(300));
for in_chat in [false, true] {
let text = long.clone();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
if in_chat {
demo::fill_ask(&mut a);
a.state.chat.push(ChatItem::User(text));
a.state.chat.push(ChatItem::User("Make it blue".into()));
} else {
demo::fill_project(&mut a);
a.state.feed.push(FeedItem::User(text));
a.state.feed.push(FeedItem::User("Make it blue".into()));
}
a
});
h.run_steps(4);
let width = h.ctx.content_rect().width();
let r = h.get_by_label(&long).rect();
assert!(r.right() <= width && r.width() <= 560.0, "the bubble ({r:?}) stays inside the {width}px window (chat: {in_chat})");
assert!(r.height() > 60.0, "it wraps onto several lines (chat: {in_chat})");
let short = h.get_by_label("Make it blue").rect();
assert!(short.width() < 160.0 && short.right() <= width, "a short message keeps a small bubble ({short:?}, chat: {in_chat})");
}
}
#[test]
fn a_game_project_shows_its_asset_spending_and_can_raise_the_budget() {
use craft_anything_engine::workspace::{AssetRecord, TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let mut ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
ws.manifest.stack = "Unity".into();
ws.save().unwrap();
ws.record_asset(AssetRecord { file: "Assets/Generated/crate.glb".into(), kind: "model".into(), prompt: "a crate".into(), cost_usd: 0.51, at_ms: 0 })
.unwrap();
let root = ws.root.clone();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.asset_keys = Some(fal_key(true));
a.open_workspace(ws);
a
});
assert!(wait(&mut h, |a| a.facts.assets.0 == 1));
h.get_by_label("1 · $0.51 of $5.00");
// Generated assets are the person's choice, saved with the project through project_assets_allow.
h.get_by_label("Generate custom pictures and models").click();
assert!(wait(&mut h, |a| a.workspace.as_ref().is_some_and(|w| w.manifest.generate_assets)));
assert!(Workspace::open(&root).unwrap().manifest.generate_assets);
assert!(h.state().state.modal.is_none(), "a key is set up");
// Through the project_assets_budget tool, by the default budget from Settings.
h.get_by_label("Raise the budget by $5.00").click();
assert!(wait(&mut h, |a| a.workspace.as_ref().is_some_and(|w| w.manifest.asset_budget_usd == 10.0)));
assert_eq!(Workspace::open(&root).unwrap().manifest.asset_budget_usd, 10.0);
h.run_steps(2);
h.get_by_label("1 · $0.51 of $10.00");
}
#[test]
fn an_engine_download_shows_in_the_title_bar_on_every_screen() {
use craft_anything_engines::game::Builder;
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(2);
let id = h.state_mut().jobs.spawn("Getting Godot", true, |j| {
j.report(craft_anything_engine::Progress { fraction: Some(0.42), message: "63 of 150 MB".into() });
std::thread::sleep(Duration::from_secs(5));
Ok(craft_anything_ui_egui::Done::Nothing)
});
h.state_mut().engine_flow.job = Some((Builder::Godot, id));
assert!(wait(&mut h, |a| a.jobs.running().any(|j| j.progress().is_some())));
h.run_steps(2);
h.get_by_label("Getting Godot · 42%").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, Some(craft_anything_ui_egui::state::Modal::Engine(Builder::Godot)), "the bar opens the steps");
}
#[test]
fn a_projects_conversation_and_place_come_back_after_a_restart() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
use craft_anything_ui_egui::state::FeedItem;
struct Mem(std::collections::HashMap<String, String>);
impl eframe::Storage for Mem {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
// One session: a conversation on the project, then the app closes.
let mut first = demo::app(demo::engine(Mode::Beginner, true));
first.open_workspace(ws);
first.state.feed.push(FeedItem::User("Make it faster".into()));
first.state.feed.push(FeedItem::Agent { text: "Done: the snake moves faster now.".into(), streaming: true });
// A run keeps the feed in the project folder (here, as one would have).
let root = first.workspace.as_ref().unwrap().root.clone();
craft_anything_ai::rebuild::feed::save(&root, &first.state.feed).unwrap();
let mut storage = Mem(Default::default());
eframe::App::save(&mut first, &mut storage);
// The next start reopens that project with everything in it.
let mut next = demo::app(demo::engine(Mode::Beginner, true));
next.load_prefs(Some(&storage));
assert_eq!(next.state.screen, Screen::Project);
assert_eq!(next.workspace.as_ref().map(|w| w.manifest.name.as_str()), Some("Snake"));
let feed = &next.state.feed;
assert!(feed.iter().any(|f| matches!(f, FeedItem::User(t) if t == "Make it faster")), "{feed:?}");
assert!(
feed.iter().any(|f| matches!(f, FeedItem::Agent { text, streaming: false } if text.starts_with("Done:"))),
"nothing is still streaming after a restart: {feed:?}"
);
}
#[test]
fn the_builders_checklist_is_pinned_above_the_prompt_box() {
use craft_anything_ui_egui::state::{FeedItem, PlanStatus, PlanStep};
let step = |text: &str, status| PlanStep { text: text.into(), doing: text.into(), status };
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a.state.feed.push(FeedItem::Plan(vec![
step("Lay out the arena", PlanStatus::Done),
step("Add the snake", PlanStatus::Doing),
step("Keep score", PlanStatus::Todo),
]));
a
});
h.run_steps(3);
h.get_by_label("1 of 3 done");
h.get_by_label("Add the snake");
h.get_by_label("Keep score");
}
#[test]
fn the_sidebar_lists_projects_and_moves_between_them() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_projects(&mut a);
a
});
h.run_steps(3);
h.get_by_label("New project");
assert!(h.query_by_label("Look inside an app").is_none(), "looking inside is on the New project screen");
h.get_by_label("Settings");
h.get_by_label("Snake, Ready").click();
h.run_steps(3);
assert_eq!(h.state().state.screen, Screen::Project);
assert_eq!(h.state().workspace.as_ref().map(|w| w.manifest.name.as_str()), Some("Snake"));
h.get_by_label("New project").click();
h.run_steps(3);
assert_eq!(h.state().state.screen, Screen::Home);
h.get_by_label("Settings").click();
h.run_steps(3);
assert!(h.state().state.settings_open);
}
#[test]
fn the_prompt_box_sends_on_enter_with_its_pictures() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
use craft_anything_ui_egui::state::FeedItem;
let d = tempfile::tempdir().unwrap();
let pic = d.path().join("shot.png");
std::fs::write(&pic, "x").unwrap();
let ws = Workspace::create(&d.path().join("projects"), "Game", "Recreate this game", TargetRef::None).unwrap();
let root = ws.root.clone();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.workspace = Some(ws);
a.state.screen = Screen::Project;
a
});
h.run_steps(3);
h.state_mut().attach(vec![pic.clone(), d.path().join("notes.txt"), pic]);
h.run_steps(2);
assert_eq!(h.state().state.project_attachments.len(), 1, "pictures only, once each");
h.get_by_label("shot.png");
h.get_by_label("Ask for a change").focus();
h.run_steps(2);
h.get_by_label("Ask for a change").type_text("Make it faster");
h.run_steps(2);
// Shift+Enter adds a line; Enter sends.
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter);
h.run_steps(2);
h.get_by_label("Ask for a change").type_text("and louder");
h.run_steps(2);
assert_eq!(h.state().state.project_input, "Make it faster\nand louder");
h.key_press(egui::Key::Enter);
h.run_steps(1);
// The pictures are copied on a worker; the message goes once they're in.
assert!(wait(&mut h, |a| a.state.feed.len() > 1));
assert!(root.join("attachments/picture-1.png").is_file(), "the picture is copied into the project");
assert!(h.state().state.project_input.is_empty() && h.state().state.project_attachments.is_empty());
assert!(
h.state().state.feed.iter().any(|f| matches!(f, FeedItem::User(t) if t.starts_with("Make it faster\nand louder") && t.contains("1 picture attached"))),
"{:?}",
h.state().state.feed
);
}
#[test]
fn pictures_of_the_original_go_with_the_draft_then_into_the_project() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let pic = d.path().join("original.webp");
std::fs::write(&pic, "x").unwrap();
let notes = d.path().join("notes.txt");
std::fs::write(&notes, "x").unwrap();
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(3);
// "Nothing — start fresh" is picked: the request box offers pictures (not for websites).
h.get_by_label("Add pictures of the original (optional: screenshots help it look right)");
h.state_mut().add_pictures(vec![pic.clone(), notes, pic.clone()]);
h.run_steps(3);
assert_eq!(h.state().state.make.pictures, vec![pic.clone()], "pictures only, once each");
h.get_by_label("original.webp");
h.get_by_label("Remove original.webp").click();
h.run_steps(3);
assert!(h.state().state.make.pictures.is_empty());
// On a project's page they go straight into the project.
let ws = Workspace::create(&d.path().join("projects"), "Game", "Recreate this game", TargetRef::None).unwrap();
let root = ws.root.clone();
h.state_mut().workspace = Some(ws);
h.state_mut().state.screen = Screen::Project;
h.run_steps(3);
h.get_by_label("Add pictures of the original");
h.state_mut().add_pictures(vec![pic]);
assert!(wait(&mut h, |a| a.facts.references == 1), "copied on a worker, then counted again");
assert!(root.join("reference/picture-1.webp").is_file());
h.run_steps(2);
h.get_by_label("1 picture(s) to match.");
}
#[test]
fn a_finished_game_offers_to_play_it() {
use craft_anything_engine::workspace::{Status, TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let mut ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
// What `cargo build` leaves behind (never started here: the test doesn't click).
let debug = ws.app_dir().join("target/debug");
std::fs::create_dir_all(&debug).unwrap();
std::fs::write(ws.app_dir().join("Cargo.toml"), "[package]").unwrap();
let exe = debug.join(if cfg!(windows) { "snake.exe" } else { "snake" });
std::fs::write(&exe, "x").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
}
ws.manifest.status = Status::Ready;
ws.save().unwrap();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.workspace = Some(ws);
a.state.screen = Screen::Project;
a
});
// What was built is looked for on a worker, never while drawing.
assert!(wait(&mut h, |a| a.facts.program));
h.run_steps(2);
h.get_by_label("Your game is ready");
assert_eq!(h.get_all_by_label("Play the game").count(), 2, "in the sidebar and on the ready card");
}
#[test]
fn a_finished_build_can_be_pinned_and_opened_from_the_sidebar() {
use craft_anything_engine::workspace::{Status, TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let mut ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
// What `cargo build` leaves behind (never started here: the pin is never clicked).
let debug = ws.app_dir().join("target/debug");
std::fs::create_dir_all(&debug).unwrap();
std::fs::write(ws.app_dir().join("Cargo.toml"), "[package]").unwrap();
let exe = debug.join(if cfg!(windows) { "snake.exe" } else { "snake" });
std::fs::write(&exe, "x").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
}
ws.manifest.status = Status::Ready;
ws.save().unwrap();
let root = ws.root.clone();
let projects = d.path().to_path_buf();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
// Pins are listed from the project folders in settings: only this test's.
a.engine.session().settings.projects_dir = Some(projects);
a.workspace = Some(ws);
a.state.screen = Screen::Project;
a
});
assert!(wait(&mut h, |a| a.facts.program));
h.run_steps(2);
assert!(h.query_by_label("Pinned builds").is_none(), "no Pinned section before anything is pinned");
h.get_by_label("Pin this build").click();
// The dialog works out what a frozen copy takes; frozen is picked.
assert!(wait(&mut h, |a| matches!(a.state.pin_draft.size, Some(Ok(_)))));
h.run_steps(2);
h.get_by_label("A frozen copy (recommended)");
h.get_by_label("Pin it").click();
assert!(wait(&mut h, |a| !a.pins.is_empty()), "the pin shows up in the sidebar");
h.run_steps(2);
assert!(root.join("pinned/1/app").is_dir(), "a frozen copy was made");
h.get_by_label("Snake · pin 1");
// The section folds away and opens again.
h.get_by_label("Pinned builds").click();
h.run_steps(2);
assert!(h.query_by_label("Snake · pin 1").is_none());
h.get_by_label("Pinned builds").click();
h.run_steps(2);
h.get_by_label("Snake · pin 1");
// A folded sidebar keeps the pin as an icon, named for screen readers and on hover, and
// nothing moves up or down.
let (pin, refresh) = (h.get_by_label("Snake · pin 1").rect(), h.get_by_label("Look for projects again").rect());
h.state_mut().state.sidebar_folded = true;
h.run_steps(2);
assert_eq!(h.get_by_label("Snake · pin 1").rect().top(), pin.top());
let folded_refresh = h.get_by_label("Look for projects again").rect();
assert_eq!(folded_refresh.top(), refresh.top(), "the refresh button stays, on the same line");
// Everything is centred on the row icons.
let row = h.get_by_label("New project").rect().center().x;
for name in ["Show the sidebar", "Pinned builds", "Look for projects again"] {
let x = h.get_by_label(name).rect().center().x;
assert!((x - row).abs() <= 1.0, "{name} is at {x}, the rows at {row}");
}
// The section folds here too, as it does open.
h.get_by_label("Pinned builds").click();
h.run_steps(2);
assert!(h.query_by_label("Snake · pin 1").is_none());
}
#[test]
fn the_builders_questions_can_come_to_the_person_for_one_project() {
use craft_anything_engine::workspace::{Questions, TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
let root = ws.root.clone();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.workspace = Some(ws);
a.state.screen = Screen::Project;
a
});
h.run_steps(2);
// Auto mode is on by default, so the builder decides, and the switch under the box says so.
assert!(h.state().settings.auto_mode);
let shown = |a: &craft_anything_ui_egui::CraftApp| a.settings.questions_in(a.workspace.as_ref().map(|w| &w.manifest));
assert_eq!(shown(h.state()), Questions::Decide);
h.get_by_label("Decide yourself");
h.get_by_label("Ask me").click();
assert!(wait(&mut h, |_| Workspace::open(&root).unwrap().manifest.questions == Some(Questions::Ask)), "saved in the project");
h.run_steps(2);
assert_eq!(shown(h.state()), Questions::Ask);
assert!(h.state().settings.auto_mode, "Auto mode itself is unchanged");
}
#[test]
fn a_question_about_the_build_is_answered_without_building() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
let root = ws.root.clone();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.workspace = Some(ws);
a.state.screen = Screen::Project;
a
});
h.run_steps(2);
let feed_before = h.state().state.feed.len();
// /btw makes it a question, whatever the switch says.
h.state_mut().state.project_input = "/btw where are the latest files".into();
h.run_steps(1);
h.get_by_label("Send (Enter)").click();
h.run_steps(2);
let asked = h.state().asked.get(&root).cloned().unwrap_or_default();
assert_eq!(asked.len(), 1);
assert_eq!(asked[0].after, feed_before, "it shows where it was asked");
assert_eq!(asked[0].question, "where are the latest files");
assert!(h.state().state.project_input.is_empty(), "the box is cleared");
assert_eq!(h.state().state.feed.len(), feed_before, "nothing joined the build's conversation");
assert!(!h.state().runs.is_busy(&root), "no build started");
// This test engine has no AI layer: the card says why there's no answer.
assert!(wait(&mut h, |a| a.asked.get(&root).is_some_and(|l| l[0].answer.is_some())));
h.run_steps(2);
h.get_by_label("where are the latest files");
// It folds away and opens again.
assert!(h.query_by_label("Show the answer").is_none());
h.get_by_label("Hide the answer").click();
h.run_steps(2);
h.get_by_label("Show the answer").click();
h.run_steps(2);
h.get_by_label("Hide the answer");
// The switch makes every message a question.
h.get_by_label("Just ask").click();
h.run_steps(2);
assert!(h.state().state.just_ask);
// A mistyped command stays in the box, whatever the switch says.
h.state_mut().state.project_input = "/bwt where is the icon".into();
h.run_steps(1);
h.get_by_label("Send (Enter)").click();
h.run_steps(2);
assert_eq!(h.state().state.project_input, "/bwt where is the icon");
assert_eq!(h.state().asked[&root].len(), 1);
assert_eq!(h.state().state.feed.len(), feed_before);
assert!(!h.state().runs.is_busy(&root));
}
#[test]
fn a_long_answer_scrolls_with_the_conversation_and_comes_back_after_a_restart() {
use craft_anything_engine::questions::{self, AskedQuestion};
use craft_anything_engine::workspace::{TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
let long = (0..120).map(|n| format!("Line {n} of a long answer.")).collect::<Vec<_>>().join("\n\n");
questions::add(&ws, AskedQuestion::new("how does it all work".into(), long, true, "Claude".into(), None, 0)).unwrap();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.screen = Screen::Home;
a.open_workspace(ws);
a
});
h.run_steps(3);
let bottom = h.ctx.content_rect().bottom();
assert!(h.get_by_label("Ask for a change").rect().bottom() <= bottom, "the prompt box is on the screen");
// Kept from before, it starts folded; the question shows and the answer doesn't.
h.get_by_label("how does it all work");
assert!(h.query_by_label("Hide the answer").is_none());
h.get_by_label("Show the answer").click();
h.run_steps(3);
h.get_by_label("Hide the answer");
assert!(h.get_by_label("Ask for a change").rect().bottom() <= bottom, "opening it doesn't push the prompt box off the screen");
}
#[test]
fn website_choice_stays_picked_while_the_address_is_empty() {
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(3);
h.get_by_label("A website").click();
// Several frames: the field used to vanish on the frame after the click.
for _ in 0..3 {
h.run_steps(2);
assert!(h.state().state.make.editing_url);
h.get_by_label("Type the website's address:");
// A website is looked at as a visitor sees it, or read in Deep mode: asked.
h.get_by_label("How closely may Craft Anything look at it?");
}
h.get_by_label("Nothing — start fresh").click();
h.run_steps(3);
assert!(!h.state().state.make.editing_url);
assert!(h.query_by_label("How closely may Craft Anything look at it?").is_none(), "nothing to look at");
assert!(h.query_by_label("Type the website's address:").is_none());
}
#[test]
fn mode_switch_and_deep_toggle() {
let store = tempfile::tempdir().unwrap();
let empty = store.path().to_path_buf();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.settings.engines.store_dir = Some(empty);
a
});
h.run_steps(3);
// Simple mode: the switch is in Settings.
h.state_mut().state.settings_open = true;
h.run_steps(3);
h.get_by_label("Advanced").click();
h.run_steps(4);
h.state_mut().state.settings_open = false;
h.run_steps(3);
assert_eq!(h.state().settings.mode, Mode::Experienced);
// The setting reaches the engine's session (it's what tools check).
assert!(wait(&mut h, |a| a.engine.try_session().is_some_and(|s| s.settings.mode == Mode::Experienced)));
h.state_mut().set_deep(true);
assert!(wait(&mut h, |a| a.engine.try_session().is_some_and(|s| s.settings.deep_mode && !s.clean_room())));
// No Ghidra in the store: Deep mode offers it first. (Hover first, as a mouse does: the
// dialog's button only takes a click once the pointer has been over it for a frame.)
h.get_by_label("Not now").hover();
h.run_steps(2);
h.get_by_label("Not now").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, None, "Not now closes it");
h.get_by_label("Simple").click();
h.run_steps(3);
assert_eq!(h.state().settings.mode, Mode::Beginner);
}
#[test]
fn a_failure_with_a_log_offers_to_open_it_and_one_without_does_not() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_ask(&mut a);
a.state.chat = vec![ChatItem::Error { message: "It failed.".into(), remediation: Some("ssh printed: no".into()), log: None }];
a
});
h.run_steps(4);
h.get_by_label_contains("ssh printed: no");
assert!(h.query_by_label("Open log").is_none());
h.state_mut().state.chat = vec![ChatItem::Error { message: "It failed.".into(), remediation: None, log: Some("/tmp/agent.log".into()) }];
h.run_steps(4);
h.get_by_label("Open log");
}
#[test]
fn ask_screen_renders_every_chat_item_and_consent_answers() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_ask(&mut a);
a
});
h.run_steps(4);
h.get_by_label("Show me the proof");
h.get_by_label("Don't allow").click();
h.run_steps(3);
let answered = h.state().state.chat.iter().any(|i| matches!(i, ChatItem::Consent { answer: Some(_), .. }));
assert!(answered);
}
#[test]
fn asking_without_ai_runs_the_built_in_engines() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::deep(&mut a);
a.state.backend = None;
a.state.screen = Screen::Ask;
a
});
h.run_steps(3);
h.state_mut().send("What does this program do?");
// A first-look tool always exists (core_session_show at worst); its answer and the call to
// action arrive without blocking the UI.
assert!(wait(&mut h, |a| a.state.chat.iter().any(|i| matches!(i, ChatItem::AddBrain))));
h.run_steps(2);
h.get_by_label("Add a brain").click();
h.run_steps(2);
assert!(h.state().state.settings_open);
}
#[test]
fn opening_a_file_lands_on_ask_with_a_first_look() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tiny-program");
std::fs::write(&path, demo::sample_program()).unwrap();
let mut h = harness(|| {
let engine = std::sync::Arc::new(craft_anything_engine::Engine::new(craft_anything_engine::Session::new(craft_anything_engine::Settings {
first_run_done: true,
deep_mode: true,
language: "en".into(),
..Default::default()
})));
demo::app(engine)
});
h.run_steps(2);
h.state_mut().open_path(path.clone());
assert!(wait(&mut h, |a| a.state.screen == Screen::Ask && a.view.active_target().is_some()));
assert_eq!(h.state().settings.recent.first(), Some(&path));
assert!(wait(&mut h, |a| a.state.chat.len() >= 2), "the first look arrives");
}
#[test]
fn a_picked_game_shows_what_its_made_of_and_can_be_asked_about() {
let d = tempfile::tempdir().unwrap();
let game = d.path().join("Game");
let names = br#"{"names": ["Assembly-CSharp.dll", "FishNet.Runtime.dll", "UnityEngine.CoreModule.dll"]}"#;
for (rel, bytes) in [
("Game.exe", &b"MZ"[..]),
("GameAssembly.dll", b"MZ"),
("Game_Data/globalgamemanagers", b"\0\0\0\0\x16\0\x002022.3.62f2\0\0junk"),
("Game_Data/ScriptingAssemblies.json", names),
] {
let p = game.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, bytes).unwrap();
}
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::deep(&mut a);
a.state.screen = Screen::GameFiles;
a
});
h.run_steps(2);
craft_anything_ui_egui::game_files::folder_picked(h.state_mut(), game.clone());
assert!(wait(&mut h, |a| a.state.game.profile.is_some()), "the game is looked at");
h.run_steps(2);
h.get_by_label("Unity 2022.3.62f2");
h.get_by_label("FishNet");
// Asking opens the game in the chat, whose first look says what it's made of.
h.get_by_label("Ask about this game").click();
assert!(wait(&mut h, |a| a.state.screen == Screen::Ask && a.view.active_target().is_some()));
assert!(wait(&mut h, |a| format!("{:?}", a.state.chat).contains("FishNet (multiplayer)")), "{:?}", h.state().state.chat);
}
#[test]
fn opening_a_missing_file_is_a_calm_error() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::deep(&mut a);
a
});
h.run_steps(2);
h.state_mut().open_path(std::path::PathBuf::from("/definitely/not/here.exe"));
assert!(wait(&mut h, |a| a.state.toasts.iter().any(|(_, err)| *err)));
}
#[test]
fn clean_room_asks_for_deep_mode_instead_of_opening_a_file() {
use craft_anything_ui_egui::state::Modal;
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(2);
h.state_mut().open_path(std::path::PathBuf::from("/samples/tool.exe"));
assert!(wait(&mut h, |a| a.state.modal == Some(Modal::DeepMode)), "Clean room never opens the original");
assert!(!h.state().state.toasts.iter().any(|(_, err)| *err), "no error, a question");
h.get_by_label("Looking inside needs Deep mode");
// Hover first, as a mouse does: a dialog's button takes a click once the pointer was over it.
h.get_by_label("Not now").hover();
h.run_steps(2);
h.get_by_label("Not now").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, None);
assert!(!h.state().settings.deep_mode);
// The Game files screen asks the same before anything is picked.
h.state_mut().state.screen = Screen::GameFiles;
h.run_steps(3);
assert!(h.query_by_label("Pick a game's folder").is_none());
h.get_by_label("Turn on Deep mode").click();
h.run_steps(3);
assert!(h.state().settings.deep_mode);
h.state_mut().state.modal = None;
h.run_steps(2);
h.get_by_label("Pick a game's folder");
}
#[test]
fn workbench_lists_fall_back_gracefully() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Experienced, true));
demo::fill_workbench(&mut a);
a
});
h.run_steps(4);
h.get_by_label("percent_key_pressed");
for kind in RailKind::ALL {
if kind == RailKind::Targets {
continue;
}
// Every rail is backed by a registered tool for every kind of target.
for k in [None, Some(TargetKind::Managed), Some(TargetKind::Jar), Some(TargetKind::Apk), Some(TargetKind::MachO)] {
assert!(h.state().first_tool(kind.tools(k)).is_some(), "{kind:?} for {k:?} names no registered tool");
}
h.state_mut().state.lists.remove(&kind);
h.state_mut().state.rail = kind;
h.run_steps(2);
}
// Every centre tab draws (built-in hex view included) and names a real tool.
for tab in craft_anything_ui_egui::state::CenterTab::ALL {
assert!(tab == craft_anything_ui_egui::state::CenterTab::Result || h.state().first_tool(tab.tools()).is_some(), "{tab:?}");
h.state_mut().state.center = tab;
h.run_steps(2);
}
// The Jobs tab reads "Jobs (1)" while a list is still loading, so let them finish first.
assert!(wait(&mut h, |a| a.jobs.running().all(|j| !j.visible)), "the lists finished loading");
for tab in ["Inspector", "Evidence", "Unknowns", "Jobs", "Log", "Agent"] {
h.get_by_label(tab).click();
h.run_steps(2);
}
}
#[test]
fn the_agent_console_keeps_its_size() {
// The console's history and prompt box came out 17 points taller than the panel, so the panel
// grew by that every frame until its maximum, and then the top of its tabs was cut off.
let mut h = harness_sized(egui::vec2(1890.0, 1025.0), || {
let mut a = demo::app(demo::engine(Mode::Experienced, true));
demo::fill_workbench(&mut a);
a
});
h.run_steps(4);
assert!(wait(&mut h, |a| a.jobs.running().all(|j| !j.visible)), "the lists finished loading");
h.get_by_label("Agent").click();
h.run_steps(2);
let top = h.get_by_label("Agent").rect().top();
h.run_steps(10);
assert_eq!(h.get_by_label("Agent").rect().top(), top, "the agent console grew while nothing changed");
}
#[test]
fn command_palette_opens_and_lists_tools() {
let mut h = harness(|| demo::app(demo::engine(Mode::Experienced, true)));
h.run_steps(3);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K);
h.run_steps(3);
assert!(h.state().state.palette.open);
h.get_by_label("Session status");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(!h.state().state.palette.open);
}
/// A stand-in model server whose every answer lists an embedding model, then `qwen3:8b`. Its
/// OpenAI-style address.
fn model_server() -> String {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
let url = format!("http://{}/v1", listener.local_addr().unwrap());
std::thread::spawn(move || {
use std::io::{Read, Write};
for mut s in listener.incoming().flatten() {
let mut buf = [0u8; 4096];
let _ = s.read(&mut buf);
let body = r#"{"object":"list","data":[{"id":"nomic-embed-text"},{"id":"qwen3:8b"}]}"#;
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
}
});
url
}
/// #123, #127: the model an AI uses is changed in Settings → AI from the ones it lists.
#[test]
fn an_ais_model_is_changed_in_settings() {
let url = model_server();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Experienced, true));
a.hub.add_server("Box", &url, Some("nomic-embed-text"), None).unwrap();
a.state.settings_open = true;
a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Ai;
a
});
h.state_mut().refresh_backends();
assert!(wait(&mut h, |a| a.backends.iter().any(|b| b.name == "Box")));
h.run_steps(2);
h.get_by_label("Model: nomic-embed-text");
h.get_by_label("Change…").click();
assert!(wait(&mut h, |a| a.model_picker.as_ref().is_some_and(|(_, m)| m.as_deref() == Some(&["nomic-embed-text".to_string(), "qwen3:8b".into()][..]))));
h.run_steps(2);
h.state_mut().set_model("Box", "qwen3:8b");
assert!(wait(&mut h, |a| a.backends.iter().any(|b| b.name == "Box" && b.model == "qwen3:8b")));
h.run_steps(2);
h.get_by_label("Model: qwen3:8b");
assert!(h.state().model_picker.is_none(), "the picker closes once a model is chosen");
}
/// #54, #98: an AI server on another computer is added by its address in Settings → AI, with a
/// model that can chat, and the form empties once it's added.
#[test]
fn an_ai_server_is_added_by_its_address() {
let url = model_server();
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Experienced, true));
a.state.settings_open = true;
a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Ai;
a
});
h.run_steps(2);
h.get_by_label("Add an AI by its address");
h.state_mut().server_form = craft_anything_ui_egui::ServerForm { name: "Studio box".into(), address: url.clone(), ..Default::default() };
h.run_steps(1);
h.get_by_label("Add").click();
assert!(wait(&mut h, |a| a.backends.iter().any(|b| b.name == "Studio box" && b.model == "qwen3:8b")), "added with a model that can chat");
assert!(h.state().server_form.name.is_empty(), "the form empties once it's added");
}
/// #53: an agent app installed where it isn't looked for gets its program chosen in Settings → AI,
/// and goes back to being found by its usual name.
#[test]
fn an_agent_apps_program_is_set_in_settings() {
use craft_anything_ai::{BackendConfig, BackendKind};
let mut h = harness(|| {
let a = demo::app(demo::engine(Mode::Experienced, true));
let cfg =
BackendConfig { name: "OpenCode".into(), kind: BackendKind::AgentCli, model: "opencode".into(), base_url: None, ssh_host: None, enabled: true };
a.hub.add_backend(cfg, None).unwrap();
a
});
h.state_mut().refresh_backends();
assert!(wait(&mut h, |a| a.backends.iter().any(|b| b.name == "OpenCode")));
h.state_mut().state.settings_open = true;
h.state_mut().state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Ai;
h.run_steps(2);
h.get_by_label("Program: found automatically");
h.state_mut().set_agent_program("OpenCode", Some("/opt/oc/opencode".into()));
let program = |a: &CraftApp| a.backend_options.get("OpenCode").and_then(|o| o.command.clone());
assert!(wait(&mut h, |a| program(a).as_deref() == Some("/opt/oc/opencode")));
h.run_steps(2);
h.get_by_label("Program: /opt/oc/opencode");
h.get_by_label("Find it automatically").click();
assert!(wait(&mut h, |a| program(a).is_none()));
}
#[test]
fn settings_window_tabs_draw() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.settings_open = true;
a
});
h.run_steps(3);
// The window keeps its width on every tab: its close button (left of the title bar's) doesn't
// move (#68).
let edge = |h: &Harness<'static, CraftApp>| h.get_all_by_label("Close").map(|n| n.rect().right()).fold(f32::INFINITY, f32::min);
let right = edge(&h);
for tab in ["Engines", "AI", "Builds", "Plugins", "Safety", "General"] {
h.get_by_label(tab).click();
h.run_steps(2);
assert_eq!(edge(&h), right, "the Settings window changed width on {tab}");
}
h.get_by_label("Studio Light").click();
h.run_steps(3);
assert_eq!(h.state().settings.theme, "studio-light");
// At the same window size, the same tab keeps its content bounds with and without scrolling.
let content_bounds = |overflow: bool| {
let mut h = harness_sized(egui::vec2(1000.0, 700.0), move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.settings_open = true;
a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Safety;
a
});
h.run_steps(3);
if overflow {
h.state_mut().view.project = Some("Test project".into());
h.state_mut().view.project_allows = (0..20).map(|n| format!("extra tool {n}")).collect();
h.run_steps(2);
}
let content = h.get_by_label("Run to study").rect();
let close_right = edge(&h);
let scrollbars: Vec<_> = h.query_all_by_role(egui::accesskit::Role::ScrollBar).map(|node| node.rect()).collect();
(content, close_right, scrollbars)
};
let (short_content, short_window_right, short_scrollbars) = content_bounds(false);
let (long_content, long_window_right, long_scrollbars) = content_bounds(true);
let scrollbar = long_scrollbars
.iter()
.copied()
.filter(|r| r.left() < long_window_right)
.min_by(|a, b| a.left().total_cmp(&b.left()))
.expect("the overflowing Settings tab should show a scrollbar");
assert!((short_window_right - long_window_right).abs() < 0.1, "the windows have different widths");
assert!(short_scrollbars.iter().all(|r| r.left() > scrollbar.left()), "the short tab unexpectedly scrolls");
assert_eq!(short_content, long_content, "the Settings content bounds changed when scrolling");
assert!(
long_content.right() <= scrollbar.left() - 14.0,
"the content should stay at least 14 points before the scrollbar: content={long_content:?}, scrollbar={scrollbar:?}"
);
}
#[test]
fn settings_controls_change_builds_art_and_studying() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.settings_open = true;
a
});
h.run_steps(3);
h.get_by_label("Builds").click();
h.run_steps(2);
h.get_by_label("Use as a goal").click();
h.run_steps(2);
assert_eq!(h.state().settings.build.picture_good_enough, Some(80.0));
h.get_by_label("Budget for a new project");
h.get_by_label("Plugins").click();
h.run_steps(2);
h.get_by_label("No plugins yet.");
h.get_by_label("Safety").click();
h.run_steps(2);
assert!(!h.state().settings.runtime_study);
h.get_by_label("Run to study").click();
h.run_steps(2);
assert!(h.state().settings.runtime_study);
}
#[test]
fn a_website_check_lists_each_pages_likeness() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a
});
h.run_steps(3);
h.get_by_label("start page 96%");
h.get_by_label("/about 91%").click();
h.run_steps(2);
}
#[test]
fn a_check_shows_the_claims_that_dont_hold_and_the_rest_on_request() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a
});
h.run_steps(3);
h.get_by_label("2 of 3 requirements met");
h.get_by_label("/about doesn't scroll sideways at 390 px — it's 412 px wide");
assert!(h.query_by_label("The start page loads every file it asks for").is_none(), "the ones that hold wait behind a click");
h.get_by_label("Show the ones that are met").click();
h.run_steps(3);
h.get_by_label("The start page loads every file it asks for");
}
/// An app whose builds are stand-ins (one at a time), with an agent app as its Builder, the one
/// the queue checks for up front.
fn stand_in_app(projects: std::path::PathBuf, runs: &craft_anything_ai::rebuild::StandIns) -> CraftApp {
use craft_anything_ai::{AiConfig, BackendConfig, BackendEntry, BackendKind, BackendOptions};
let engine = demo::engine(Mode::Beginner, true);
let mut cfg = AiConfig::default();
let agent =
BackendConfig { name: "Claude Code".into(), kind: BackendKind::AgentCli, model: "claude-code".into(), base_url: None, ssh_host: None, enabled: true };
cfg.upsert(BackendEntry { config: agent, options: BackendOptions::default() });
engine.session().settings.ai = cfg.to_table().unwrap();
let mut app = demo::app(engine);
demo::fill_projects(&mut app);
app.settings.projects_dir = Some(projects);
app.settings.build.parallel = 1;
app.runs = craft_anything_ai::rebuild::RunSet::new(1).with_starter(runs.starter());
app
}
#[test]
fn a_note_waiting_for_the_builder_can_be_taken_back_into_the_prompt_box() {
use craft_anything_ai::rebuild::{RunEvent, StandIns};
use craft_anything_engine::workspace::{TargetRef, Workspace};
use craft_anything_ui_egui::state::FeedItem;
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let runs = StandIns::new();
let (dir, r) = (d.path().to_path_buf(), runs.clone());
let mut h = harness(move || stand_in_app(dir, &r));
h.run_steps(2);
h.state_mut().open_workspace(a.clone());
h.state_mut().follow_up(Some("Add tags".into()));
h.run_steps(2);
// Notes sent while it works wait under the conversation, not in it.
for note in ["Make them blue", "And sortable"] {
h.state_mut().state.project_input = note.into();
h.state_mut().send_message();
}
h.run_steps(3);
let in_feed = |app: &CraftApp, text: &str| app.state.feed.iter().any(|f| matches!(f, FeedItem::User(t) if t == text));
assert!(!in_feed(h.state(), "Make them blue"));
h.get_by_label("Make them blue");
h.get_by_label("And sortable");
// Taking the first back puts it in the box, before what's been typed since.
h.state_mut().state.project_input = "draft".into();
h.get_all_by_label("Take back: puts it in the box again").next().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().state.project_input, "Make them blue\ndraft");
assert!(h.query_by_label("Make them blue").is_none());
// The builder reads the other: it joins the conversation and can't be taken back.
assert!(runs.send(&a.root, RunEvent::Note("And sortable".into())));
h.run_steps(2);
assert!(in_feed(h.state(), "And sortable"));
h.state_mut().pause();
assert!(runs.finish(&a.root));
h.run_steps(2);
}
#[test]
fn up_in_the_prompt_box_takes_back_the_last_waiting_note() {
use craft_anything_ai::rebuild::StandIns;
use craft_anything_engine::workspace::{TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let runs = StandIns::new();
let (dir, r) = (d.path().to_path_buf(), runs.clone());
let mut h = harness(move || stand_in_app(dir, &r));
h.run_steps(2);
h.state_mut().open_workspace(a.clone());
h.state_mut().follow_up(Some("Add tags".into()));
h.run_steps(2);
for note in ["Make them blue", "And sortable"] {
h.state_mut().state.project_input = note.into();
h.state_mut().send_message();
}
h.run_steps(3);
h.get_by_label("Ask for a change").focus();
h.run_steps(2);
// In an empty box, Up brings back the last note, as Claude Code does with a queued message.
h.key_press(egui::Key::ArrowUp);
h.run_steps(3);
assert_eq!(h.state().state.project_input, "And sortable");
assert!(h.query_by_label("And sortable").is_none(), "it no longer waits under the conversation");
// The cursor went to the end of the text, so Up now moves it instead of taking back another.
h.key_press(egui::Key::ArrowUp);
h.run_steps(3);
assert_eq!(h.state().state.project_input, "And sortable", "Up away from the start edits the text");
h.state_mut().state.project_input.clear();
h.run_steps(2);
h.key_press(egui::Key::ArrowUp);
h.run_steps(3);
assert_eq!(h.state().state.project_input, "Make them blue", "the one before comes next");
h.state_mut().pause();
assert!(runs.finish(&a.root));
h.run_steps(2);
}
#[test]
fn up_and_down_in_the_prompt_box_go_through_earlier_prompts() {
use craft_anything_ai::rebuild::StandIns;
use craft_anything_engine::workspace::{TargetRef, Workspace};
use craft_anything_ui_egui::state::FeedItem;
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let (dir, runs) = (d.path().to_path_buf(), StandIns::new());
let mut h = harness(move || stand_in_app(dir, &runs));
h.run_steps(2);
h.state_mut().open_workspace(a);
h.run_steps(2);
let user = |t: &str| FeedItem::User(t.into());
h.state_mut().state.feed = vec![user("A notes app"), user("Make it blue\n(1 picture attached)"), user("Make it blue"), user("First line\nSecond line")];
h.state_mut().state.project_input = String::new();
h.get_by_label("Ask for a change").focus();
h.run_steps(2);
let input = |h: &Harness<'static, CraftApp>| h.state().state.project_input.clone();
let press = |h: &mut Harness<'static, CraftApp>, key| {
h.key_press(key);
h.run_steps(3);
};
let (up, down) = (egui::Key::ArrowUp, egui::Key::ArrowDown);
let many_lines = "First line\nSecond line";
// Up leaves the cursor at the start, so it keeps going. The same prompt twice in a row counts
// once, and pictures aren't part of what was typed.
press(&mut h, up);
assert_eq!(input(&h), many_lines);
press(&mut h, up);
assert_eq!(input(&h), "Make it blue");
press(&mut h, up);
assert_eq!(input(&h), "A notes app");
press(&mut h, up);
assert_eq!(input(&h), "A notes app", "the oldest stays");
// The cursor is at the start, so Down first goes to the end; then it leaves the cursor there.
press(&mut h, down);
assert_eq!(input(&h), "A notes app", "Down moved the cursor to the end");
press(&mut h, down);
assert_eq!(input(&h), "Make it blue");
press(&mut h, down);
assert_eq!(input(&h), many_lines);
press(&mut h, down);
assert_eq!(input(&h), "", "past the newest is what was being typed");
// A half-typed message: Up first goes to its start, and it comes back after looking around.
h.event(egui::Event::Text("half a th".into()));
h.run_steps(3);
press(&mut h, up);
assert_eq!(input(&h), "half a th", "Up moved the cursor to the start");
press(&mut h, up);
assert_eq!(input(&h), many_lines);
press(&mut h, down);
assert_eq!(input(&h), many_lines, "Down moved the cursor to the last line");
press(&mut h, down);
assert_eq!(input(&h), many_lines, "and to the end of it");
press(&mut h, down);
assert_eq!(input(&h), "half a th", "only then is it left");
// In several lines Up goes line by line, and cycles only from the very start.
h.event(egui::Event::Text("\nb\nc".into()));
h.run_steps(3);
let typed = input(&h);
assert_eq!(typed, "half a th\nb\nc");
press(&mut h, up);
assert_eq!(input(&h), typed, "to the middle line");
press(&mut h, up);
assert_eq!(input(&h), typed, "to the first line");
press(&mut h, up);
assert_eq!(input(&h), typed, "to the very start");
press(&mut h, up);
assert_eq!(input(&h), many_lines);
press(&mut h, down);
press(&mut h, down);
press(&mut h, down);
assert_eq!(input(&h), typed, "it was kept, lines and all");
// Editing what came back ends it: Down at the end no longer changes the prompt.
press(&mut h, up);
press(&mut h, up);
press(&mut h, up);
press(&mut h, up);
assert_eq!(input(&h), many_lines);
h.state_mut().state.project_input.push_str(" and bold");
h.run_steps(2);
press(&mut h, down);
press(&mut h, down);
press(&mut h, down);
assert_eq!(input(&h), "First line\nSecond line and bold");
}
#[test]
fn the_prompt_box_is_laid_out_with_its_new_height_in_the_frame_it_grows() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(2);
h.state_mut().open_workspace(a);
h.run_steps(3);
let bottom = |h: &Harness<'static, CraftApp>| h.get_by_label("Ask for a change").rect().max.y;
let before = bottom(&h);
// Five more lines at once: the very next frame shown already has the taller box in place,
// not one laid out with last frame's height (which pushed it off the bottom, then back).
h.state_mut().state.project_input = "one
two
three
four
five
six"
.into();
h.step();
let after = bottom(&h);
h.run_steps(3);
assert!((after - bottom(&h)).abs() < 0.5, "the box didn't move after the frame it grew in: {after} then {}", bottom(&h));
assert!((after - before).abs() < 0.5, "it grows upwards, keeping its bottom edge: {before} then {after}");
}
#[test]
fn nothing_blinks_over_the_text_while_typing() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
// Everything painted over a spot of the typed text, after the text itself.
fn over_text(shapes: &[egui::epaint::ClippedShape], spot: egui::Rect) -> Vec<String> {
fn flatten(s: &egui::Shape, out: &mut Vec<egui::Shape>) {
match s {
egui::Shape::Vec(v) => v.iter().for_each(|s| flatten(s, out)),
s => out.push(s.clone()),
}
}
let mut seen_text = false;
let mut over = Vec::new();
for cs in shapes {
let mut flat = Vec::new();
flatten(&cs.shape, &mut flat);
for s in flat {
if !s.visual_bounding_rect().intersects(spot) || !cs.clip_rect.intersects(spot) {
continue;
}
match &s {
egui::Shape::Text(t) if t.galley.text().starts_with("make") => seen_text = true,
// The window's own outline goes round everything.
egui::Shape::Rect(r) if r.fill.a() == 0 && r.rect.contains_rect(cs.clip_rect) => {}
_ if seen_text => over.push(format!("{s:?}").chars().take(160).collect()),
_ => {}
}
}
}
over
}
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(2);
h.state_mut().open_workspace(a);
h.run_steps(3);
h.get_by_label("Ask for a change").focus();
h.run_steps(2);
// The scroll area's "more below" fade showed for one frame after each keystroke, dimming
// the whole line.
for c in "make it blue".chars() {
h.event(egui::Event::Text(c.to_string()));
for _ in 0..3 {
h.step();
let field = h.get_by_label("Ask for a change").rect();
let spot = egui::Rect::from_min_size(field.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 8.0));
let over = over_text(&h.output().shapes, spot);
assert!(over.is_empty(), "painted over the text after typing {c:?}: {over:?}");
}
}
assert_eq!(h.state().state.project_input, "make it blue");
}
#[test]
fn builds_past_the_limit_wait_their_turn_on_screen() {
use craft_anything_ai::rebuild::{RunEvent, StandIns, Step};
use craft_anything_engine::workspace::{TargetRef, Workspace};
use craft_anything_ui_egui::state::FeedItem;
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let b = Workspace::create(d.path(), "Beta", "A timer app", TargetRef::None).unwrap();
let runs = StandIns::new();
let (dir, r) = (d.path().to_path_buf(), runs.clone());
let mut h = harness(move || stand_in_app(dir, &r));
h.run_steps(2);
// Alpha builds; Beta waits behind it.
h.state_mut().open_workspace(a.clone());
h.state_mut().follow_up(Some("Add tags".into()));
assert_eq!(runs.started(), std::slice::from_ref(&a.root));
h.state_mut().open_workspace(b.clone());
h.state_mut().follow_up(Some("Add a lap button".into()));
assert_eq!(h.state().queue_position(), Some(1));
// A message for a project waiting its turn stays in the box instead of being lost.
h.state_mut().state.project_input = "Make it red".into();
h.state_mut().send_message();
assert_eq!(h.state().state.project_input, "Make it red");
h.state_mut().state.project_input.clear();
assert!(wait(&mut h, |app| app.projects.len() == 2), "both projects listed");
h.run_steps(3);
h.get_by_label("Waiting for another build to finish: this one starts next.");
h.get_by_label("Beta, Waiting");
// Alpha's progress stays in Alpha's conversation, not on Beta's screen.
assert!(runs.send(&a.root, RunEvent::Step { step: Step::Build, text: "Writing the tag list".into() }));
h.run_steps(2);
let shows = |app: &CraftApp, text: &str| app.state.feed.iter().any(|f| matches!(f, FeedItem::Step { text: t, .. } if t == text));
assert!(!shows(h.state(), "Writing the tag list"));
h.state_mut().open_workspace(a.clone());
assert!(shows(h.state(), "Writing the tag list"), "opening a building project shows its own feed");
// Alpha finishes: Beta starts in its place.
assert!(runs.finish(&a.root));
h.run_steps(3);
assert_eq!(runs.started(), [a.root.clone(), b.root.clone()]);
h.state_mut().open_workspace(b.clone());
assert_eq!(h.state().queue_position(), None);
h.run_steps(2);
assert!(h.query_by_label("Waiting for another build to finish: this one starts next.").is_none());
h.state_mut().pause();
assert!(runs.finish(&b.root));
h.run_steps(2);
assert!(!h.state().busy());
}
#[test]
fn a_projects_saved_time_and_ai_use_show_beside_it_and_the_session_total_in_the_title_bar() {
use craft_anything_engine::workspace::ProjectUsage;
// What earlier builds saved in the project folder: shown after a restart too.
let d = tempfile::tempdir().unwrap();
let root = d.path().to_path_buf();
ProjectUsage::update(&root, |u| {
u.work_ms = 725_000;
u.add_turn(1_180_000, 42_000, Some(3.21), true);
})
.unwrap();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
if let Some(ws) = a.workspace.as_mut() {
ws.root = root.clone();
}
a.session_usage.add_turn(1_180_000, 42_000, Some(3.21), true);
a
});
h.run_steps(3);
h.get_by_label("Time spent");
h.get_by_label("12m 05s");
// A coding agent's figure is called an estimate, in the open and not only on hover.
h.get_by_label("AI cost (estimate)");
h.get_by_label("The coding agent's estimate at API prices, not your bill. A subscription isn't charged this.");
h.get_by_label("1.2M");
// The project's row and the title bar's session total.
h.get_by_label("about $3.21");
h.get_by_label("about $3.21 (estimate)");
}
#[test]
fn ghidras_memory_is_automatic_until_the_person_sets_it() {
let mut h = harness_sized(egui::vec2(1280.0, 1000.0), || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.settings_open = true;
a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Engines;
a
});
h.run_steps(3);
assert_eq!(h.state().settings.engines.ghidra.memory_gb, None);
h.get_by_label_contains("Half of this computer's memory");
h.get_by_label("Custom").click();
h.run_steps(3);
assert_eq!(h.state().settings.engines.ghidra.memory_gb, Some(16), "starts at what a 500 MB program wants");
assert_eq!(h.get_all_by_label("Memory for Ghidra").count(), 2, "the row and its slider");
h.get_by_label("Automatic").click();
h.run_steps(3);
assert_eq!(h.state().settings.engines.ghidra.memory_gb, None);
}
#[test]
fn the_front_door_makes_something_or_just_looks_inside() {
use craft_anything_ui_egui::state::Modal;
let mut h = harness(|| demo::app(demo::engine(Mode::Beginner, true)));
h.run_steps(3);
h.get_by_label("What do you want to make?");
h.get_by_label("Just look inside").click();
h.run_steps(3);
assert!(h.state().state.make.look);
h.get_by_label("What do you want to look inside?");
// Each way in reads the original, so Clean room asks for Deep mode first.
assert!(h.query_by_label("An app or a file").is_none());
h.get_by_label("Looking inside needs Deep mode");
h.get_by_label("Turn on Deep mode").click();
h.run_steps(3);
assert!(h.state().settings.deep_mode);
// Without Ghidra here, Deep mode offers it; not now.
h.state_mut().state.modal = None;
h.run_steps(2);
h.get_by_label("An app or a file");
h.get_by_label("A website").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, Some(Modal::Website));
h.state_mut().state.modal = None;
h.run_steps(2);
h.get_by_label("A game's files").click();
h.run_steps(3);
assert_eq!(h.state().state.screen, Screen::GameFiles);
// Back on New project, it's still on "Just look inside"; switching back shows the Make steps.
h.get_by_label("New project").click();
h.run_steps(3);
h.get_by_label("Make my own version").click();
h.run_steps(3);
h.get_by_label("What do you want to recreate?");
}
#[test]
fn a_running_build_says_what_it_is_on_and_opens_up_into_its_activity() {
use craft_anything_ai::rebuild::{RunEvent, RunSet, StandIns};
use craft_anything_ai::{AgentEvent, AiConfig, BackendConfig, BackendEntry, BackendKind, BackendOptions};
use craft_anything_engine::workspace::{Step, TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let runs = StandIns::new();
let (dir, starter) = (d.path().to_path_buf(), runs.starter());
let mut h = harness(move || {
let engine = demo::engine(Mode::Beginner, true);
let mut cfg = AiConfig::default();
let agent = BackendConfig {
name: "Claude Code".into(),
kind: BackendKind::AgentCli,
model: "claude-code".into(),
base_url: None,
ssh_host: None,
enabled: true,
};
cfg.upsert(BackendEntry { config: agent, options: BackendOptions::default() });
engine.session().settings.ai = cfg.to_table().unwrap();
let mut app = demo::app(engine);
app.settings.projects_dir = Some(dir);
app.runs = RunSet::new(1).with_starter(starter);
app
});
h.run_steps(2);
h.state_mut().open_workspace(ws.clone());
h.state_mut().continue_build();
h.run_steps(2);
let tool = |id: &str, name: &str, input: serde_json::Value| RunEvent::Agent(AgentEvent::ToolStarted { call_id: id.into(), tool: name.into(), input });
assert!(runs.send(&ws.root, RunEvent::Step { step: Step::Build, text: "Building the first version…".into() }));
assert!(runs.send(&ws.root, tool("a", "Read", serde_json::json!({"file_path": "src/main.rs"}))));
assert!(runs.send(
&ws.root,
RunEvent::Agent(AgentEvent::ToolFinished { call_id: "a".into(), tool: "Read".into(), ok: true, summary: "fn main".into(), evidence_id: None })
));
h.run_steps(2);
// Between actions it says what it did last.
assert!(h.query_all_by_label_contains("Last: Reading src/main.rs").count() >= 1);
assert!(runs.send(&ws.root, tool("b", "Bash", serde_json::json!({"command": "cargo test --quiet"}))));
h.run_steps(2);
assert!(h.query_all_by_label_contains("Now: Running cargo test --quiet").count() >= 1);
// Folded until it's wanted, then the lines and the log.
assert!(h.query_by_label("Open log").is_none());
h.get_by_label("More detail").click();
h.run_steps(3);
h.get_by_label("Open log");
h.get_by_label("Reading src/main.rs: fn main");
h.get_by_label("Running cargo test --quiet");
assert!(ws.root.join(craft_anything_engine::workspace::ACTIVITY_LOG).is_file());
// Finished: the record stays, named for what it is now.
assert!(runs.finish(&ws.root));
h.run_steps(3);
h.get_by_label("What happened in this build");
}
#[test]
fn a_build_that_stopped_part_way_offers_to_continue() {
use craft_anything_ai::rebuild::{RunSet, StandIns};
use craft_anything_ai::{AiConfig, BackendConfig, BackendEntry, BackendKind, BackendOptions};
use craft_anything_engine::workspace::{Status, TargetRef, Workspace};
let d = tempfile::tempdir().unwrap();
// Paused by the person, and one the app closed on mid-build (its status still says Building).
let mut paused = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
paused.set_status(Status::Stopped, "Paused. Send a message to continue.").unwrap();
let mut cut = Workspace::create(d.path(), "Beta", "A timer app", TargetRef::None).unwrap();
cut.set_status(Status::Building, "Building the first version…").unwrap();
let runs = StandIns::new();
let (dir, starter) = (d.path().to_path_buf(), runs.starter());
let mut h = harness(move || {
let engine = demo::engine(Mode::Beginner, true);
let mut cfg = AiConfig::default();
let agent = BackendConfig {
name: "Claude Code".into(),
kind: BackendKind::AgentCli,
model: "claude-code".into(),
base_url: None,
ssh_host: None,
enabled: true,
};
cfg.upsert(BackendEntry { config: agent, options: BackendOptions::default() });
engine.session().settings.ai = cfg.to_table().unwrap();
let mut app = demo::app(engine);
app.settings.projects_dir = Some(dir);
app.runs = RunSet::new(1).with_starter(starter);
app
});
h.run_steps(2);
h.state_mut().refresh_projects();
assert!(wait(&mut h, |app| app.projects.len() == 2), "both projects listed");
h.state_mut().open_workspace(paused.clone());
h.run_steps(3);
// In the feed, and on the project's status pill.
assert!(h.query_all_by_label("Paused").count() >= 1);
// #210: "enough now". It's kept as it is and stops offering to carry on.
h.get_by_label("It's done").click();
assert!(wait(&mut h, |app| app.workspace.as_ref().is_some_and(|w| w.manifest.status == Status::Ready)), "called done");
h.run_steps(3);
assert_eq!(Workspace::open(&paused.root).unwrap().manifest.status, Status::Ready);
assert!(h.query_by_label("Continue").is_none(), "nothing left to carry on");
h.get_by_label(craft_anything_engine::tools::project_tools::CALLED_DONE);
h.state_mut().open_workspace(cut.clone());
h.run_steps(3);
h.get_by_label("Beta, Interrupted");
h.get_by_label("This build stopped part-way");
h.get_by_label("Continue").click();
h.run_steps(3);
assert_eq!(runs.continued(), std::slice::from_ref(&cut.root), "it carries on instead of starting over");
assert!(h.query_by_label("This build stopped part-way").is_none(), "no card while it works");
}
#[test]
fn the_builders_open_questions_show_beside_the_project() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a
});
h.run_steps(3);
h.get_by_label("Open questions");
h.get_by_label("May the original's logo and text be reused, or must they be placeholders?");
h.get_by_label("Answer any of them in a message below.");
}
#[test]
fn saves_a_picture_from_a_unity_bundle() {
use craft_anything_testkit::games::{unity_bundle_with, unity_serialized_file, unity_texture2d};
use craft_anything_ui_egui::{game_files, state::Modal};
// A game whose only pictures are in a bundle: one DXT1 texture, its pixels in the bundle's .resS.
let dir = tempfile::tempdir().unwrap();
let (game, out) = (dir.path().join("Game"), dir.path().join("wall.png"));
let aa = game.join("Game_Data/StreamingAssets/aa");
std::fs::create_dir_all(&aa).unwrap();
let wall = unity_texture2d("2022.3.62f1", "Wall", 4, 4, 10, None, Some(("archive:/CAB-0123456789abcdef/CAB-0123456789abcdef.resS", 0, 8)));
let red = [0x00, 0xf8, 0x1f, 0x00, 0, 0, 0, 0];
std::fs::write(aa.join("walls.bundle"), unity_bundle_with(&unity_serialized_file("2022.3.62f1", &[(28, wall)]), &red)).unwrap();
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::deep(&mut a);
a
});
h.state_mut().state.screen = Screen::GameFiles;
game_files::folder_picked(h.state_mut(), game);
assert!(wait(&mut h, |a| !a.state.game.looking && !a.state.game.archives.is_empty()), "the bundle is found");
let name = h.state().state.game.archives[0]["name"].as_str().unwrap().to_string();
h.get_by_label(&name).click();
assert!(wait(&mut h, |a| !a.state.game.loading && !a.state.game.rows.is_empty()), "its pictures are listed");
h.run_steps(3);
h.get_by_label("Wall");
h.get_by_label("Save picture").click();
h.run_steps(3);
assert_eq!(h.state().state.modal, Some(Modal::GamePermission), "asked before anything is taken out");
h.get_by_label("I have permission").click();
h.run_steps(3);
// Headless, the save dialog doesn't open; answer it as the person would.
game_files::save_to(h.state_mut(), out.clone());
assert!(wait(&mut h, |_| out.exists()), "the picture is saved");
assert!(std::fs::read(&out).unwrap().starts_with(b"\x89PNG"));
}
#[test]
fn a_new_project_can_start_in_a_folder_that_already_has_the_persons_files() {
use craft_anything_ai::rebuild::StandIns;
use craft_anything_engine::workspace::{ExistingFiles, Workspace};
use craft_anything_ui_egui::state::Modal;
let (d, mine) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
std::fs::write(mine.path().join("index.html"), "<p>hi</p>").unwrap();
let runs = StandIns::new();
let (dir, r) = (d.path().to_path_buf(), runs.clone());
let mut h = harness(move || stand_in_app(dir, &r));
h.run_steps(2);
h.state_mut().state.make.request = "Add a contact form".into();
craft_anything_ui_egui::folders::picked(h.state_mut(), mine.path().to_path_buf());
h.run_steps(3);
assert!(matches!(h.state().state.modal, Some(Modal::ExistingFiles)), "it asks what the files are");
h.get_by_label("Don't ask again (you can change this in Settings)").click();
h.run_steps(2);
h.get_by_label("They're my app: edit them in place").click();
h.run_steps(3);
assert!(h.state().state.modal.is_none());
assert_eq!(h.state().state.make.existing, Some(ExistingFiles::Edit));
assert_eq!(h.state().settings.existing_files, Some(ExistingFiles::Edit), "not asked again");
h.get_by_label("Pick another folder…");
h.state_mut().start_make();
assert!(wait(&mut h, |a| a.creating.is_none() && a.workspace.is_some()), "made on a worker");
let ws = Workspace::open(mine.path()).expect("the project is in the picked folder");
assert!(ws.manifest.app_in_root && ws.app_dir() == mine.path());
assert_eq!(std::fs::read_to_string(mine.path().join("index.html")).unwrap(), "<p>hi</p>");
assert_eq!(runs.started(), [mine.path().to_path_buf()]);
assert!(h.state().settings.project_folders.contains(&mine.path().to_path_buf()), "it stays in the list");
assert!(wait(&mut h, |app| app.projects.iter().any(|w| w.root == mine.path())), "and is listed");
assert_eq!(std::fs::read_dir(d.path()).map(|r| r.count()).unwrap_or(0), 0, "nothing in the projects folder");
// The next folder with files uses the remembered answer.
let other = tempfile::tempdir().unwrap();
std::fs::write(other.path().join("notes.md"), "ideas").unwrap();
craft_anything_ui_egui::folders::picked(h.state_mut(), other.path().to_path_buf());
h.run_steps(2);
assert!(h.state().state.modal.is_none() && h.state().state.make.existing == Some(ExistingFiles::Edit));
h.state_mut().pause();
assert!(runs.finish(mine.path()));
h.run_steps(2);
}
#[test]
fn settings_list_projects_from_more_folders_and_remove_them_from_the_list_only() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
let (d, more) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let elsewhere = Workspace::create(more.path(), "Elsewhere", "A timer", TargetRef::None).unwrap();
let dir = d.path().to_path_buf();
let mut h = harness(move || {
let mut app = demo::app(demo::engine(Mode::Beginner, true));
app.settings.projects_dir = Some(dir);
app
});
h.run_steps(2);
h.state_mut().add_project_folder(more.path().to_path_buf());
assert!(wait(&mut h, |app| app.projects.iter().any(|w| w.root == elsewhere.root)), "its projects are listed");
h.state_mut().state.settings_open = true;
h.run_steps(3);
h.get_by_label("Add a folder…");
let label = format!("Stop listing {}", more.path().display());
h.get_by_label(&label).click();
assert!(wait(&mut h, |app| app.projects.iter().all(|w| w.root != elsewhere.root)), "off the list");
assert!(h.state().settings.project_folders.is_empty());
assert!(elsewhere.root.join("craft.json").is_file(), "nothing is deleted");
}
#[test]
fn a_typed_space_stays_in_a_settings_path() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.settings_open = true;
a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Engines;
a
});
h.run_steps(3);
h.get_all_by_label("Ghidra folder").last().unwrap().focus();
h.run_steps(2);
h.get_all_by_label("Ghidra folder").last().unwrap().type_text("/opt/my ");
h.run_steps(2);
h.get_all_by_label("Ghidra folder").last().unwrap().type_text("tools");
h.run_steps(2);
assert_eq!(h.state().settings.engines.ghidra.install_dir, Some(std::path::PathBuf::from("/opt/my tools")));
}
#[test]
fn a_paragraph_is_drawn_as_one_piece_of_text() {
use craft_anything_ui_egui::state::FeedItem;
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a.state.feed.push(FeedItem::Agent { text: "The snake moves faster now and the score is bigger.".into(), streaming: false });
a
});
h.run_steps(3);
// Not one label per word: a screen reader reads it as one sentence, and a long reply is a
// handful of widgets instead of hundreds.
h.get_by_label("The snake moves faster now and the score is bigger.");
}
#[test]
fn make_says_who_builds_and_a_picked_builder_stays_with_the_project() {
use craft_anything_ai::{BackendConfig, BackendKind, rebuild::StandIns};
use craft_anything_engine::workspace::Workspace;
let d = tempfile::tempdir().unwrap();
let runs = StandIns::new();
let (dir, r) = (d.path().to_path_buf(), runs.clone());
let mut h = harness(move || {
let mut a = stand_in_app(dir, &r);
// Claude Code is set up, and so is an API key.
let cfg = |name: &str, kind, model: &str| BackendConfig { name: name.into(), kind, model: model.into(), base_url: None, ssh_host: None, enabled: true };
a.backends = vec![cfg("Claude", BackendKind::Anthropic, "claude-opus-5-5"), cfg("Claude Code", BackendKind::AgentCli, "claude-code")];
a
});
h.run_steps(3);
// The agent app builds by default, and every AI that can build is offered by name.
let combo = h.get_all_by_label("Built by").last().unwrap();
assert_eq!(combo.value().as_deref(), Some("Automatic (Claude Code)"));
let names: Vec<String> = h.state().builder_choices().into_iter().map(|c| c.name).collect();
assert_eq!(names, ["Claude", "Claude Code"]);
// Picked explicitly, though it's also the default: it stays with the project.
h.state_mut().state.make.ai = Some("Claude Code".into());
h.run_steps(2);
assert_eq!(h.get_all_by_label("Built by").last().unwrap().value().as_deref(), Some("Claude Code"));
h.state_mut().state.make.request = "A notes app".into();
h.state_mut().start_make();
assert!(wait(&mut h, |a| a.creating.is_none() && a.workspace.is_some()));
let root = h.state().workspace.as_ref().unwrap().root.clone();
assert_eq!(Workspace::open(&root).unwrap().manifest.builder.as_deref(), Some("Claude Code"), "the project remembers it");
h.state_mut().pause();
assert!(runs.finish(&root));
h.run_steps(2);
}
#[test]
fn with_nothing_to_build_with_start_offers_what_was_found_instead_of_a_dead_end() {
use craft_anything_ui_egui::state::Modal;
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.backends.clear();
a.detected = Some(Vec::new());
a
});
h.run_steps(3);
h.get_by_label("Building needs a coding agent such as Claude Code, or an AI with an API key.");
h.state_mut().state.make.request = "A notes app".into();
h.state_mut().start_make();
h.run_steps(2);
assert_eq!(h.state().state.modal, Some(Modal::NeedBuilder { start: true }));
// Found on this computer meanwhile (detection runs when the dialog opens).
h.state_mut().detecting = false;
h.state_mut().detected = Some(demo::detected());
h.run_steps(2);
h.get_by_label("Look again");
h.get_by_label("Use an API key…");
assert!(h.state().can_build(), "an agent app that was found can build: one rule for the warning and Start");
h.get_by_label("Build with Claude Code");
}
#[test]
fn an_ai_can_be_turned_off_and_removed_in_settings() {
use craft_anything_ai::rebuild::StandIns;
let d = tempfile::tempdir().unwrap();
let runs = StandIns::new();
let (dir, r) = (d.path().to_path_buf(), runs.clone());
let mut h = harness(move || {
let mut a = stand_in_app(dir, &r);
a.backends = a.hub.backends();
a.state.settings_open = true;
a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Ai;
a
});
h.run_steps(3);
h.get_by_label("Turn off").click();
assert!(wait(&mut h, |a| a.backends.first().is_some_and(|b| !b.enabled)), "kept, but off");
assert!(!h.state().can_build(), "an AI that's off doesn't build");
h.run_steps(2);
h.get_by_label("Remove Claude Code").click();
h.run_steps(2);
h.get_by_label("Remove it and its key").click();
assert!(wait(&mut h, |a| a.backends.is_empty()), "removed");
}
#[test]
fn start_is_in_view_at_a_common_window_height() {
use craft_anything_engine::workspace::TargetRef;
for app_target in [false, true] {
let mut h = harness_sized(egui::vec2(1280.0, 900.0), move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
if app_target {
// An app to recreate adds the Clean room / Deep choice.
a.state.make.target = TargetRef::Local { path: std::path::PathBuf::from("/samples/calculator") };
}
a
});
h.run_steps(3);
let bottom = h.get_by_label("Start").rect().max.y;
assert!(bottom <= 900.0 - 16.0, "Start ends at {bottom} px with an app picked: {app_target}");
}
}
#[test]
fn typed_drafts_come_back_after_a_restart_and_stay_with_their_project() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
struct Mem(std::collections::HashMap<String, String>);
impl eframe::Storage for Mem {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let d = tempfile::tempdir().unwrap();
let a = Workspace::create(d.path(), "Alpha", "A notes app", TargetRef::None).unwrap();
let b = Workspace::create(d.path(), "Beta", "A snake game", TargetRef::None).unwrap();
let mut first = demo::app(demo::engine(Mode::Beginner, true));
first.state.make.request = "A website for my bakery".into();
first.open_workspace(a.clone());
first.state.project_input = "Make the notes bold".into();
// Another project: its own box, empty; Alpha's text waits for Alpha.
first.open_workspace(b.clone());
assert!(first.state.project_input.is_empty(), "one project's text doesn't follow you to the next");
first.state.project_input = "Make it faster".into();
first.open_workspace(a.clone());
assert_eq!(first.state.project_input, "Make the notes bold");
let mut storage = Mem(Default::default());
eframe::App::save(&mut first, &mut storage);
let mut next = demo::app(demo::engine(Mode::Beginner, true));
next.load_prefs(Some(&storage));
assert_eq!(next.state.make.request, "A website for my bakery", "Make's draft is kept");
assert_eq!(next.state.project_input, "Make the notes bold", "the open project's draft is kept");
next.open_workspace(b);
assert_eq!(next.state.project_input, "Make it faster", "and every other project's");
}
#[test]
fn the_ask_screen_waits_only_for_its_own_answer() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.screen = Screen::Ask;
a
});
h.run_steps(2);
// A long job that isn't the conversation (a download, a build's helper).
let (tx, rx) = std::sync::mpsc::channel::<()>();
let other = h.state_mut().jobs.spawn("Download an engine", true, move |j| {
while matches!(rx.recv_timeout(Duration::from_millis(20)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) && !j.cancel.is_cancelled() {}
Ok(craft_anything_ui_egui::Done::Nothing)
});
h.run_steps(2);
assert!(!h.state().answering());
assert!(h.query_by_label("Working on your answer").is_none(), "a download isn't an answer");
h.state_mut().stop();
h.run_steps(2);
assert!(h.state().jobs.running().any(|j| j.id == other), "Stop on the Ask screen leaves other jobs alone");
drop(tx);
assert!(wait(&mut h, |a| a.jobs.running().next().is_none()));
}
#[test]
fn closing_the_ghidra_question_means_the_project_doesnt_start_later() {
use craft_anything_ui_egui::state::Modal;
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.ghidra.start_after = Some(Box::new(a.state.make.clone()));
a.state.modal = Some(Modal::Ghidra);
a
});
h.run_steps(2);
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().state.modal.is_none());
assert!(h.state().ghidra.start_after.is_none(), "dismissed with nothing downloading: nothing waits for Ghidra");
}
#[test]
fn a_project_that_needs_you_says_so_in_the_sidebar() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_projects(&mut a);
let root = a.projects.iter().find(|w| w.manifest.name == "Snake").unwrap().root.clone();
a.asking.insert(root);
a
});
h.run_steps(3);
h.get_by_label("Snake, Needs you");
}
#[test]
fn a_paused_build_shows_where_it_stopped() {
use craft_anything_ai::rebuild::Step;
use craft_anything_engine::workspace::{Status, TargetRef, Workspace};
use craft_anything_ui_egui::state::FeedItem;
let d = tempfile::tempdir().unwrap();
let mut ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap();
ws.manifest.status = Status::Stopped;
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.workspace = Some(ws);
a.state.screen = Screen::Project;
a.state.feed = vec![
FeedItem::Step { step: Step::Study, text: "Studying".into() },
FeedItem::Step { step: Step::Build, text: "Building".into() },
FeedItem::Paused,
];
a
});
h.run_steps(3);
h.get_by_label("Study: Done");
h.get_by_label("Build: Stopped here");
assert!(h.query_by_label("Pause").is_none(), "nothing to pause");
}
#[test]
fn opening_another_target_starts_a_new_conversation() {
use craft_anything_ui_egui::state::ChatItem;
let d = tempfile::tempdir().unwrap();
let other = d.path().join("notes.txt");
std::fs::write(&other, "hello").unwrap();
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::deep(&mut a);
a.state.chat = vec![ChatItem::User("What does the calculator's % key do?".into())];
a
});
h.run_steps(3);
h.state_mut().open_path(other);
assert!(wait(&mut h, |a| !a.state.chat.iter().any(|c| matches!(c, ChatItem::User(t) if t.contains("calculator")))), "answers about two things don't mix");
}
#[test]
fn advanced_mode_keeps_projects_and_their_questions_in_sight() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Experienced, true));
demo::fill_project(&mut a);
a
});
h.run_steps(3);
// The project's approval card is on screen in Advanced mode too, with the sidebar beside it.
h.get_by_label("Allow once");
h.get_by_label("Workbench").click();
h.run_steps(3);
assert_eq!(h.state().state.screen, Screen::Workbench);
h.get_by_label("Projects and builds").click();
h.run_steps(3);
assert_eq!(h.state().state.screen, Screen::Project, "back to the open project");
h.get_by_label("Allow once");
}
#[test]
fn the_website_dialog_is_ready_to_type_into() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.state.modal = Some(craft_anything_ui_egui::state::Modal::Website);
a
});
h.run_steps(3);
assert!(h.get_by_label("Website address").is_focused());
}
#[test]
fn a_project_is_renamed_duplicated_and_deleted_from_its_page() {
use craft_anything_engine::workspace::{TargetRef, Workspace};
use craft_anything_ui_egui::state::Modal;
let d = tempfile::tempdir().unwrap();
let ws = Workspace::create(d.path(), "Snake", "A snake game", TargetRef::None).unwrap();
let root = ws.root.clone();
let dir = d.path().to_path_buf();
let mut h = harness(move || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
a.settings.projects_dir = Some(dir);
a.open_workspace(ws);
a
});
h.run_steps(3);
h.get_by_label("Rename").click();
h.run_steps(2);
assert_eq!(h.state().state.modal, Some(Modal::Rename(root.clone())));
h.state_mut().state.rename_input = "Snake II".into();
h.run_steps(1);
assert!(h.get_by_label("Project name").is_focused(), "ready to type");
h.key_press(egui::Key::Enter);
assert!(wait(&mut h, |a| a.workspace.as_ref().is_some_and(|w| w.manifest.name == "Snake II")), "renamed through project_rename");
h.get_by_label("Duplicate").click();
assert!(wait(&mut h, |a| a.projects.iter().any(|w| w.manifest.name == "Snake II copy")), "the copy is listed");
h.get_by_label("Delete the project").click();
h.run_steps(2);
h.get_by_label("Delete Snake II?");
h.get_by_label("Delete it").click();
assert!(wait(&mut h, |a| a.workspace.is_none() && a.state.screen == Screen::Home));
assert!(!root.exists());
assert!(wait(&mut h, |a| a.projects.len() == 1), "only the copy is left");
}
#[test]
fn messages_float_over_the_screen_and_an_error_isnt_replaced() {
let mut h = harness(|| {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a
});
h.run_steps(3);
let before = h.get_by_label("Ask for a change").rect();
h.state_mut().toast("Couldn't save the settings.", true);
h.state_mut().toast("Opened example.com", false);
h.run_steps(3);
h.get_by_label("Couldn't save the settings.");
h.get_by_label("Opened example.com");
assert_eq!(h.get_by_label("Ask for a change").rect(), before, "the screen under the messages doesn't move");
}
#[test]
fn a_narrow_window_folds_the_sidebar_and_puts_the_project_details_above_the_feed() {
let mut h = harness_sized(egui::vec2(760.0, 820.0), || {
let mut a = demo::app(demo::engine(Mode::Beginner, true));
demo::fill_project(&mut a);
a
});
h.run_steps(3);
// Folded to icons: the names are still there for screen readers and on hover.
let new = h.get_by_label("New project").rect();
assert!(new.width() <= 60.0, "the sidebar is folded: {new:?}");
h.get_by_label("Project details");
assert!(h.query_by_label("Open folder").is_none(), "folded away until opened");
let send = h.get_by_label("Send (Enter)").rect();
assert!(send.max.x <= 760.0 && send.max.y <= 820.0, "the prompt box fits: {send:?}");
}