use std::{
collections::HashMap,
ffi::OsString,
io::{Read, Write},
net::{SocketAddr, TcpStream},
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard},
thread,
time::Duration,
};
use alix::{
assemble::{AssembleConfig, Pacing},
config::{Audience, Config},
parser,
recent::RecentDecks,
serve::{self, PairInfo, ReviewOptions},
store::Store,
};
use tempfile::TempDir;
use tiny_http::Server;
struct HttpResp {
status: u16,
headers: HashMap<String, String>,
body: Vec<u8>,
}
impl HttpResp {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
fn http(base: &str, method: &str, path: &str, headers: &[(&str, &str)], body: &[u8]) -> HttpResp {
let host = base
.strip_prefix("http://")
.expect("spawn_test_server's base is always an http:// URL");
let mut stream = TcpStream::connect(host).expect("connect to the test server");
let mut head = format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n");
for (name, value) in headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
stream
.write_all(head.as_bytes())
.expect("write the request head");
stream.write_all(body).expect("write the request body");
let mut raw = Vec::new();
stream
.read_to_end(&mut raw)
.expect("read the response to EOF");
parse_response(&raw)
}
fn parse_response(raw: &[u8]) -> HttpResp {
let split = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.expect("the response has a header/body separator");
let (head, rest) = raw.split_at(split);
let body = rest[4..].to_vec();
let head = String::from_utf8_lossy(head);
let mut lines = head.split("\r\n");
let status = lines
.next()
.unwrap_or_default()
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap_or(0);
let mut headers = HashMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_string(), value.trim().to_string());
}
}
HttpResp {
status,
headers,
body,
}
}
struct Guard {
server: Arc<Server>,
handle: Option<thread::JoinHandle<()>>,
dir: TempDir,
}
impl Guard {
fn dir(&self) -> &Path {
self.dir.path()
}
}
impl Drop for Guard {
fn drop(&mut self) {
self.server.unblock();
if let Some(handle) = self.handle.take() {
if let Err(e) = handle.join()
&& !thread::panicking()
{
std::panic::resume_unwind(e);
}
}
}
}
const FIXTURE_DECK: &str = "# 2 + 2\n\t4\n\n# 3 + 3\n\t6\n";
fn review_options(base: &str, auth: Option<String>) -> ReviewOptions {
let config = Config::default();
ReviewOptions {
keys: config.keys,
picker: config.picker,
browse: config.browse,
exam: config.exam,
ai: config.ai,
generate: config.generate,
audience: config.serve.audience,
auth,
config_path: None,
pair: PairInfo {
url: base.to_string(),
lan: false,
},
scoped: true,
cfg: AssembleConfig {
review: config.review,
ask: config.ask,
trace_auto_grade: false,
pacing: Pacing {
max_new: 10,
limit: None,
},
instance_store: None,
},
}
}
fn spawn_test_server() -> (String, Guard) {
spawn_test_server_with(None)
}
fn spawn_test_server_with(token: Option<&str>) -> (String, Guard) {
spawn_test_server_fixture(token, |_dir| {})
}
fn spawn_test_server_fixture(token: Option<&str>, extra: impl FnOnce(&Path)) -> (String, Guard) {
let dir = TempDir::new().unwrap();
let deck_path = dir.path().join("sample.txt");
std::fs::write(&deck_path, FIXTURE_DECK).unwrap();
extra(dir.path());
let store_path = dir.path().join("store.json");
let store = Store::open(&store_path).unwrap();
let recent = RecentDecks::load(dir.path().join("recent.json"));
let decks_dir = dir.path().to_path_buf();
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let server = Arc::new(serve::bind(addr).unwrap());
let port = server
.server_addr()
.to_ip()
.expect("bound to a loopback IP")
.port();
let base = format!("http://127.0.0.1:{port}");
let opts = review_options(&base, token.map(str::to_string));
let opts = ReviewOptions {
cfg: AssembleConfig {
trace_auto_grade: false,
pacing: Pacing {
max_new: 10,
limit: None,
},
instance_store: Some(store_path),
..opts.cfg
},
..opts
};
let stop_handle = Arc::clone(&server);
let handle = thread::spawn(move || {
let _ = serve::run_review(store, recent, decks_dir, server, opts);
});
(
base,
Guard {
server: stop_handle,
handle: Some(handle),
dir,
},
)
}
const CHOICE_DECK: &str =
"# 1 + 1\n\t2\n\n# 2 + 2\n\t4\n\n# 3 + 3\n\t6\n\n# 4 + 4\n\t8\n\n# 5 + 5\n\t10\n";
fn choice_answer(front: &str) -> &'static str {
match front {
"1 + 1" => "2",
"2 + 2" => "4",
"3 + 3" => "6",
"4 + 4" => "8",
"5 + 5" => "10",
other => panic!("not a CHOICE_DECK front: {other}"),
}
}
const TRACE_DECK: &str = "% trace: how it works\n\
% source: source.txt\n\
# Predict the first hop\n\
\t% given: line — the input line\n\
\tit reads the first line\n\
\t% at: 1\n\
# Predict the second hop\n\
\tit reads line two\n\
\t% at: 2\n";
const TRACE_SOURCE: &str = "first\nsecond\nthird\n";
fn spawn_full_server(ask_command: Option<&Path>) -> (String, Guard) {
spawn_full_server_fixture(ask_command, |_dir| {})
}
fn spawn_full_server_fixture(
ask_command: Option<&Path>,
extra: impl FnOnce(&Path),
) -> (String, Guard) {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.txt"), FIXTURE_DECK).unwrap();
std::fs::write(dir.path().join("choice.txt"), CHOICE_DECK).unwrap();
std::fs::write(dir.path().join("trace.txt"), TRACE_DECK).unwrap();
std::fs::write(dir.path().join("source.txt"), TRACE_SOURCE).unwrap();
extra(dir.path());
let store_path = dir.path().join("store.json");
{
let mut seed = Store::open(&store_path).unwrap();
for card in parser::parse_str("choice.txt", CHOICE_DECK).unwrap() {
seed.get_or_insert(card.id(), 0);
}
seed.save().unwrap();
}
let store = Store::open(&store_path).unwrap();
let recent = RecentDecks::load(dir.path().join("recent.json"));
let decks_dir = dir.path().to_path_buf();
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let server = Arc::new(serve::bind(addr).unwrap());
let port = server
.server_addr()
.to_ip()
.expect("bound to a loopback IP")
.port();
let base = format!("http://127.0.0.1:{port}");
let mut opts = review_options(&base, None);
if let Some(cmd) = ask_command {
opts.cfg.ask.command = cmd.to_str().unwrap().to_string();
}
let auto_grade = ask_command.is_some();
let opts = ReviewOptions {
cfg: AssembleConfig {
trace_auto_grade: auto_grade,
pacing: Pacing {
max_new: 10,
limit: None,
},
instance_store: Some(store_path),
..opts.cfg
},
..opts
};
let stop_handle = Arc::clone(&server);
let handle = thread::spawn(move || {
let _ = serve::run_review(store, recent, decks_dir, server, opts);
});
(
base,
Guard {
server: stop_handle,
handle: Some(handle),
dir,
},
)
}
static EXEC_LOCK: Mutex<()> = Mutex::new(());
fn exec_lock() -> std::sync::MutexGuard<'static, ()> {
EXEC_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
fn fake_reply(dir: &Path, reply: &str) -> PathBuf {
let out = dir.join("fake-reply");
std::fs::write(&out, reply).unwrap();
let path = dir.join("fake-claude");
std::fs::write(
&path,
format!(
"#!/bin/sh\nPATH=/usr/bin:/bin\ncat >/dev/null\ncat {}\n",
out.display()
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
fn poll_until(
base: &str,
path: &str,
done: impl Fn(&serde_json::Value) -> bool,
) -> serde_json::Value {
for _ in 0..250 {
let resp = http(base, "GET", path, &[], &[]);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
if done(&body) {
return body;
}
thread::sleep(Duration::from_millis(20));
}
panic!("{path} did not settle within the poll budget");
}
static PATH_LOCK: Mutex<()> = Mutex::new(());
struct PathGuard {
original: Option<OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for PathGuard {
fn drop(&mut self) {
match self.original.take() {
Some(p) => unsafe { std::env::set_var("PATH", p) },
None => unsafe { std::env::remove_var("PATH") },
}
}
}
fn with_empty_path<R>(dir: &Path, f: impl FnOnce() -> R) -> R {
let lock = PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let original = std::env::var_os("PATH");
let _guard = PathGuard {
original,
_lock: lock,
};
unsafe { std::env::set_var("PATH", dir) };
f()
}
#[test]
fn get_api_version_returns_200_json_with_a_version_field() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/version", &[], &[]);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(body.get("version").is_some(), "body: {body}");
}
fn post_json(base: &str, path: &str, json: &str) -> HttpResp {
http(
base,
"POST",
path,
&[("Content-Type", "application/json")],
json.as_bytes(),
)
}
fn select_fixture(base: &str) -> HttpResp {
post_json(base, "/api/select", r#"{"deck":"sample.txt"}"#)
}
#[test]
fn get_api_decks_returns_200_with_the_fixture_deck_in_the_catalog() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/decks", &[], &[]);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let recent = body["recent"].as_array().expect("recent is an array");
assert!(
recent.iter().any(|d| d["name"] == "sample.txt"),
"body: {body}"
);
}
fn write_animals_workspace(dir: &Path) {
let ws = dir.join("animals");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"Animals\"\n").unwrap();
std::fs::write(ws.join("one.txt"), "# q1\n\ta1\n").unwrap();
std::fs::write(ws.join("two.txt"), "# q2\n\ta2\n").unwrap();
}
#[test]
fn get_api_decks_lists_a_workspace_with_its_member_decks() {
let (base, _guard) = spawn_test_server_fixture(None, write_animals_workspace);
let resp = http(&base, "GET", "/api/decks", &[], &[]);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let workspaces = body["workspaces"]
.as_array()
.expect("workspaces is an array");
let animals = workspaces
.iter()
.find(|w| w["name"] == "animals")
.unwrap_or_else(|| panic!("no `animals` workspace row: body: {body}"));
assert_eq!(true, animals["is_workspace"], "row: {animals}");
let members = animals["members"].as_array().expect("members is an array");
assert!(!members.is_empty(), "row: {animals}");
for m in members {
assert!(
m["name"].as_str().is_some_and(|n| !n.is_empty()),
"a member has an empty name: {m}"
);
}
}
#[test]
fn every_member_deck_name_from_api_decks_is_selectable() {
let (base, _guard) = spawn_test_server_fixture(None, write_animals_workspace);
let decks_resp = http(&base, "GET", "/api/decks", &[], &[]);
let body: serde_json::Value = serde_json::from_slice(&decks_resp.body).unwrap();
let animals = body["workspaces"]
.as_array()
.unwrap()
.iter()
.find(|w| w["name"] == "animals")
.unwrap_or_else(|| panic!("no `animals` workspace row: body: {body}"));
let members = animals["members"].as_array().unwrap();
assert!(!members.is_empty(), "row: {animals}");
for m in members {
let name = m["name"].as_str().expect("member name is a string");
assert_eq!(
true, m["selectable"],
"member {name:?} should report selectable: true — row: {m}"
);
let req = serde_json::json!({ "deck": name }).to_string();
let resp = post_json(&base, "/api/select", &req);
assert_eq!(
200,
resp.status,
"selecting member {name:?} failed: {}",
String::from_utf8_lossy(&resp.body)
);
let sel: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
"review", sel["kind"],
"member {name:?} did not select into a review session: {sel}"
);
}
}
#[test]
fn a_workspace_row_name_is_not_selectable() {
let (base, _guard) = spawn_test_server_fixture(None, write_animals_workspace);
let decks_resp = http(&base, "GET", "/api/decks", &[], &[]);
let body: serde_json::Value = serde_json::from_slice(&decks_resp.body).unwrap();
let animals = body["workspaces"]
.as_array()
.unwrap()
.iter()
.find(|w| w["name"] == "animals")
.unwrap_or_else(|| panic!("no `animals` workspace row: body: {body}"));
assert_eq!(
false, animals["selectable"],
"a workspace row must report selectable: false — row: {animals}"
);
let resp = post_json(&base, "/api/select", r#"{"deck":"animals"}"#);
assert_eq!(400, resp.status);
}
#[test]
fn grading_a_workspace_member_writes_the_workspace_store_not_the_instance_store() {
let (base, guard) = spawn_test_server_fixture(None, write_animals_workspace);
let ws_store = guard.dir().join("animals").join("progress.json");
assert!(!ws_store.exists(), "no review has happened yet");
let decks_resp = http(&base, "GET", "/api/decks", &[], &[]);
let body: serde_json::Value = serde_json::from_slice(&decks_resp.body).unwrap();
let animals = body["workspaces"]
.as_array()
.unwrap()
.iter()
.find(|w| w["name"] == "animals")
.unwrap_or_else(|| panic!("no `animals` workspace row: body: {body}"));
let member = animals["members"][0]["name"]
.as_str()
.expect("member name is a string");
let select_req = serde_json::json!({ "deck": member }).to_string();
let resp = post_json(&base, "/api/select", &select_req);
assert_eq!(200, resp.status, "select {member:?} failed");
let resp = post_json(&base, "/api/grade", r#"{"grade":"passed"}"#);
assert_eq!(200, resp.status);
assert!(
ws_store.exists(),
"the workspace's own progress.json must receive the grade write"
);
assert!(
!guard.dir().join("store.json").exists(),
"the instance store must not receive a workspace member's progress"
);
}
#[test]
fn post_api_select_returns_a_review_state_for_the_fixture_deck() {
let (base, _guard) = spawn_test_server();
let resp = select_fixture(&base);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["kind"], "body: {body}");
assert_eq!("review", body["phase"], "body: {body}");
assert_eq!("2 + 2", body["card"]["front"], "body: {body}");
assert_eq!("flip", body["mode"], "body: {body}");
assert_eq!("recall", body["depth"], "body: {body}");
assert_eq!(2, body["remaining"], "body: {body}");
assert_eq!(2, body["initial"], "body: {body}");
}
#[test]
fn get_api_state_reflects_the_active_session_after_select() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = http(&base, "GET", "/api/state", &[], &[]);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["phase"], "body: {body}");
assert_eq!("2 + 2", body["card"]["front"], "body: {body}");
}
#[test]
fn post_api_grade_passed_returns_the_next_state_dto() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/grade", r#"{"grade":"passed"}"#);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["phase"], "body: {body}");
assert_eq!("3 + 3", body["card"]["front"], "body: {body}");
assert_eq!(1, body["passed"], "body: {body}");
assert_eq!(1, body["remaining"], "body: {body}");
}
#[test]
fn get_api_doctor_returns_200_with_doctor_rows() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/doctor", &[], &[]);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let rows = body["rows"].as_array().expect("rows is an array");
assert!(!rows.is_empty(), "body: {body}");
assert!(rows.iter().any(|r| r["name"] == "config"), "body: {body}");
}
#[test]
fn get_api_pair_returns_200_with_the_pairing_url() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/pair", &[], &[]);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(base, body["url"], "body: {body}");
assert_eq!(false, body["lan"], "body: {body}");
assert!(body["svg"].is_null(), "body: {body}");
}
#[test]
fn a_missing_bearer_token_yields_401_with_an_empty_body() {
let (base, _guard) = spawn_test_server_with(Some("secret"));
let resp = http(&base, "GET", "/api/state", &[], &[]);
assert_eq!(401, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn the_correct_bearer_token_is_accepted() {
let (base, _guard) = spawn_test_server_with(Some("secret"));
let resp = http(
&base,
"GET",
"/api/state",
&[("Authorization", "Bearer secret")],
&[],
);
assert_eq!(200, resp.status);
}
#[test]
fn a_query_token_is_accepted_as_a_fallback_when_no_bearer_is_sent() {
let (base, _guard) = spawn_test_server_with(Some("secret"));
let resp = http(&base, "GET", "/api/state?token=secret", &[], &[]);
assert_eq!(200, resp.status);
}
#[test]
fn a_wrong_bearer_token_yields_401() {
let (base, _guard) = spawn_test_server_with(Some("secret"));
let resp = http(
&base,
"GET",
"/api/state",
&[("Authorization", "Bearer wrong")],
&[],
);
assert_eq!(401, resp.status);
}
#[test]
fn post_api_grade_with_a_malformed_body_yields_400() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/grade", r#"{"nonsense":true}"#);
assert_eq!(400, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn post_api_grade_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/grade", r#"{"grade":"passed"}"#);
assert_eq!(409, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn get_api_nope_yields_404() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/nope", &[], &[]);
assert_eq!(404, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn get_img_with_an_unknown_key_yields_404() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/img/badkey", &[], &[]);
assert_eq!(404, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn post_api_browse_returns_a_browse_dto_with_the_fixture_cards() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/browse", r#"{"deck":"sample.txt"}"#);
assert_eq!(200, resp.status);
assert_eq!(
Some("application/json; charset=utf-8"),
resp.header("Content-Type")
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("browse", body["phase"], "body: {body}");
let cards = body["cards"].as_array().expect("cards is an array");
assert_eq!(2, cards.len(), "body: {body}");
assert_eq!("2 + 2", cards[0]["front"], "body: {body}");
}
#[test]
fn post_api_browse_with_an_unknown_deck_yields_400() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/browse", r#"{"deck":"nope.txt"}"#);
assert_eq!(400, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn post_api_deck_topology_reports_the_fixture_decks_due_count() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/deck-topology", r#"{"deck":"sample.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["topologies"].as_array().unwrap().is_empty(),
"no augmentation was ever generated: body: {body}"
);
assert_eq!(2, body["deck_due"], "body: {body}");
}
#[test]
fn post_api_deck_topology_with_an_unknown_deck_still_returns_the_empty_default_dto() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/deck-topology", r#"{"deck":"nope.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["topologies"].as_array().unwrap().is_empty(),
"body: {body}"
);
assert_eq!(0, body["deck_due"], "body: {body}");
}
#[test]
fn post_api_reset_clears_the_fixture_decks_progress() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
post_json(&base, "/api/grade", r#"{"grade":"passed"}"#);
let resp = post_json(&base, "/api/reset", r#"{"deck":"sample.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("sample.txt", body["deck"], "body: {body}");
assert_eq!(1, body["cards_cleared"], "body: {body}");
}
#[test]
fn post_api_reset_with_an_unknown_deck_yields_400() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/reset", r#"{"deck":"nope.txt"}"#);
assert_eq!(400, resp.status);
assert!(resp.body.is_empty(), "body: {:?}", resp.body);
}
#[test]
fn post_api_import_lands_a_txt_deck_and_reports_its_card_count() {
let (base, _guard) = spawn_test_server();
let resp = post_json(
&base,
"/api/import",
r##"{"name":"extra.txt","text":"# f\n\tb\n"}"##,
);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("extra.txt", body["deck"], "body: {body}");
assert_eq!(1, body["cards"], "body: {body}");
}
#[test]
fn post_api_import_converts_a_tsv_upload_to_a_deck() {
let (base, _guard) = spawn_test_server();
let resp = post_json(
&base,
"/api/import",
r#"{"name":"cards.tsv","text":"Front1\tBack1\nFront2\tBack2\n"}"#,
);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(2, body["cards"], "body: {body}");
assert!(
body["deck"].as_str().unwrap().ends_with(".txt"),
"body: {body}"
);
}
#[test]
fn post_api_import_with_an_unrecognized_extension_yields_400() {
let (base, _guard) = spawn_test_server();
let resp = post_json(
&base,
"/api/import",
r#"{"name":"cards.csv","text":"whatever"}"#,
);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_import_with_unparseable_tsv_yields_400() {
let (base, _guard) = spawn_test_server();
let resp = post_json(
&base,
"/api/import",
r#"{"name":"bad.tsv","text":"no tabs at all here\n"}"#,
);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_import_with_a_malformed_body_yields_400() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/import", r#"{"oops":true}"#);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_check_reports_a_correct_typed_answer_without_recording_a_grade() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/check", r#"{"lines":["4"]}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(true, body["passed"], "body: {body}");
let results = body["results"].as_array().unwrap();
assert_eq!(1, results.len(), "body: {body}");
assert_eq!("4", results[0]["input"], "body: {body}");
assert_eq!("4", results[0]["expected"], "body: {body}");
assert_eq!(true, results[0]["passed"], "body: {body}");
let state = http(&base, "GET", "/api/state", &[], &[]);
let state_body: serde_json::Value = serde_json::from_slice(&state.body).unwrap();
assert_eq!("2 + 2", state_body["card"]["front"], "body: {state_body}");
assert_eq!(0, state_body["passed"], "body: {state_body}");
}
#[test]
fn post_api_check_with_a_wrong_answer_reports_failure() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/check", r#"{"lines":["5"]}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(false, body["passed"], "body: {body}");
assert_eq!(false, body["results"][0]["passed"], "body: {body}");
}
#[test]
fn post_api_check_derives_orderedness_from_the_mode_not_the_client() {
let (base, _guard) = spawn_test_server_fixture(None, |dir| {
std::fs::write(
dir.join("steps.txt"),
"# steps\n% reveal: line\n\tone\n\ttwo\n",
)
.unwrap();
});
let resp = post_json(
&base,
"/api/select",
r#"{"deck":"steps.txt","depth":"reconstruct"}"#,
);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("typeline", body["mode"], "body: {body}");
let resp = post_json(&base, "/api/check", r#"{"lines":["two","one"]}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(false, body["passed"], "body: {body}");
}
#[test]
fn post_api_check_with_a_malformed_body_yields_400() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/check", r#"{"nonsense":true}"#);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_check_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/check", r#"{"lines":["4"]}"#);
assert_eq!(409, resp.status);
}
#[test]
fn post_api_choose_reports_the_correct_index_for_a_recognize_session() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(
&base,
"/api/select",
r#"{"deck":"choice.txt","depth":"recognize"}"#,
);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("recognize", body["depth"], "body: {body}");
assert_eq!("choice", body["mode"], "body: {body}");
let choices = body["choices"]
.as_array()
.expect("a recognize session offers choices");
assert_eq!(4, choices.len(), "body: {body}");
let front = body["card"]["front"].as_str().unwrap();
let expected = choice_answer(front);
let correct_index = choices
.iter()
.position(|c| c.as_str() == Some(expected))
.unwrap_or_else(|| panic!("the correct answer {expected:?} is among {choices:?}"));
let resp = post_json(
&base,
"/api/choose",
&format!(r#"{{"index":{correct_index}}}"#),
);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(correct_index, body["chosen"], "body: {body}");
assert_eq!(correct_index, body["correct"], "body: {body}");
assert_eq!(true, body["passed"], "body: {body}");
}
#[test]
fn choices_keep_their_order_across_state_pulls_while_the_card_is_on_screen() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(
&base,
"/api/select",
r#"{"deck":"choice.txt","depth":"recognize"}"#,
);
let first: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let before = first["choices"].clone();
post_json(&base, "/api/choose", r#"{"index":0}"#);
let resp = http(&base, "GET", "/api/state", &[], &[]);
let after: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
first["card"]["front"], after["card"]["front"],
"the session stays on the answered card while its feedback shows"
);
assert_eq!(
before, after["choices"],
"the option order must not shift while the card is on screen"
);
}
#[test]
fn choices_keep_their_order_across_a_full_tutor_round_trip() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let fake = fake_reply(scripts.path(), "a condensed tutor note");
let (base, _guard) = spawn_full_server(Some(&fake));
let resp = post_json(
&base,
"/api/select",
r#"{"deck":"choice.txt","depth":"recognize"}"#,
);
let first: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let before = first["choices"].clone();
assert!(before.is_array(), "body: {first}");
post_json(&base, "/api/choose", r#"{"index":0}"#);
post_json(
&base,
"/api/ask",
r#"{"question":"why is that the answer?"}"#,
);
poll_until(&base, "/api/ask", |b| b["thinking"] == false);
post_json(&base, "/api/ask/note", "{}");
poll_until(&base, "/api/ask", |b| b["thinking"] == false);
let resp = http(&base, "GET", "/api/state", &[], &[]);
let after: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
first["card"]["front"], after["card"]["front"],
"the session stays on the answered card through the tutor round trip"
);
assert_eq!(
before, after["choices"],
"the option order must not shift across the tutor round trip"
);
}
#[test]
fn cloze_choice_options_with_ai_distractors_keep_their_order_across_pulls() {
const CLOZE_DECK: &str = "# What is frb, in one sentence?\n\
% reveal: cloze\n\
\tA {{code-generation}} tool generating the {{FFI}} glue on both sides.\n";
for seed_store in [true, false] {
let (base, _guard) = spawn_full_server_fixture(None, |dir| {
std::fs::write(dir.join("frb.txt"), CLOZE_DECK).unwrap();
let cards = parser::parse_str("frb.txt", CLOZE_DECK).unwrap();
let mut cache = alix::augment::AugmentCache::open(dir.join("augment.json"));
for c in &cards {
cache.set_distractors(
c.id(),
vec!["IPC".into(), "RPC".into(), "a REST API".into()],
);
}
cache.save().unwrap();
if seed_store {
let mut store = Store::open(dir.join("store.json")).unwrap();
for c in &cards {
store.get_or_insert(c.id(), 0);
}
store.save().unwrap();
}
});
let resp = post_json(
&base,
"/api/select",
r#"{"deck":"frb.txt","depth":"recognize"}"#,
);
let first: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let before = first["choices"].clone();
assert!(
before.is_array(),
"expected a choice question (seed_store={seed_store}): {first}"
);
post_json(&base, "/api/choose", r#"{"index":0}"#);
let resp = http(&base, "GET", "/api/state", &[], &[]);
let after: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
first["card"]["front"], after["card"]["front"],
"same card (seed_store={seed_store})"
);
assert_eq!(
before, after["choices"],
"option order shifted (seed_store={seed_store})"
);
}
}
#[test]
fn post_api_choose_with_a_malformed_body_yields_400() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/choose", r#"{"nonsense":true}"#);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_choose_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/choose", r#"{"index":0}"#);
assert_eq!(409, resp.status);
}
#[test]
fn post_api_skip_defers_the_current_card_without_grading_it() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/skip", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("3 + 3", body["card"]["front"], "body: {body}");
assert_eq!(0, body["passed"], "body: {body}");
assert_eq!(0, body["failed"], "body: {body}");
assert_eq!(2, body["remaining"], "body: {body}");
}
#[test]
fn post_api_skip_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/skip", "{}");
assert_eq!(409, resp.status);
}
#[test]
fn post_api_acquire_acknowledges_a_never_seen_card_without_grading_it() {
let (base, _guard) = spawn_test_server();
let select_resp = select_fixture(&base);
let select_body: serde_json::Value = serde_json::from_slice(&select_resp.body).unwrap();
assert_eq!(
true, select_body["acquire"],
"a brand-new store has never seen this card: {select_body}"
);
let resp = post_json(&base, "/api/acquire", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["phase"], "body: {body}");
assert_eq!("3 + 3", body["card"]["front"], "body: {body}");
assert_eq!(0, body["passed"], "body: {body}");
assert_eq!(0, body["failed"], "body: {body}");
assert_eq!(1, body["remaining"], "body: {body}");
}
#[test]
fn post_api_acquire_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/acquire", "{}");
assert_eq!(409, resp.status);
}
#[test]
fn post_api_promote_the_current_card_when_it_is_not_virtual_yields_400() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/promote", "{}");
assert_eq!(400, resp.status);
}
#[test]
fn post_api_promote_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/promote", "{}");
assert_eq!(409, resp.status);
}
#[test]
fn post_api_restart_rebuilds_the_queue_and_resets_session_stats() {
let (base, _guard) = spawn_test_server();
post_json(&base, "/api/select", r#"{"deck":"sample.txt","cram":true}"#);
let grade_resp = post_json(&base, "/api/grade", r#"{"grade":"passed"}"#);
let grade_body: serde_json::Value = serde_json::from_slice(&grade_resp.body).unwrap();
assert_eq!(1, grade_body["passed"], "body: {grade_body}");
assert_eq!("3 + 3", grade_body["card"]["front"], "body: {grade_body}");
let resp = post_json(&base, "/api/restart", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(2, body["remaining"], "body: {body}");
assert_eq!(0, body["passed"], "body: {body}");
assert_eq!("2 + 2", body["card"]["front"], "body: {body}");
}
#[test]
fn post_api_restart_with_no_active_session_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/restart", "{}");
assert_eq!(409, resp.status);
}
#[test]
fn post_api_deselect_returns_to_the_picker_state_dto() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(&base, "/api/deselect", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["kind"], "body: {body}");
assert_eq!("select", body["phase"], "body: {body}");
assert!(body["card"].is_null(), "body: {body}");
}
#[test]
fn post_api_augment_open_reports_coverage_for_the_fixture_deck() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/augment/open", r#"{"deck":"sample.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("sample.txt", body["deck"], "body: {body}");
assert_eq!(2, body["cards"], "body: {body}");
assert!(body["busy"].is_null(), "body: {body}");
let rows = body["rows"].as_array().unwrap();
let choices = rows
.iter()
.find(|r| r["kind"] == "choices")
.expect("a choices row");
assert_eq!(0, choices["covered"], "body: {body}");
assert_eq!(2, choices["eligible"], "body: {body}");
}
#[test]
fn post_api_augment_open_with_an_unknown_deck_yields_400() {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/augment/open", r#"{"deck":"nope.txt"}"#);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_augment_remove_on_an_empty_cache_still_succeeds_as_a_noop() {
let (base, _guard) = spawn_test_server();
post_json(&base, "/api/augment/open", r#"{"deck":"sample.txt"}"#);
let resp = post_json(&base, "/api/augment/remove", r#"{"target":"choices"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let choices = body["rows"]
.as_array()
.unwrap()
.iter()
.find(|r| r["kind"] == "choices")
.unwrap();
assert_eq!(0, choices["covered"], "body: {body}");
}
#[test]
fn post_api_augment_close_returns_the_picker_state_dto() {
let (base, _guard) = spawn_test_server();
post_json(&base, "/api/augment/open", r#"{"deck":"sample.txt"}"#);
let resp = post_json(&base, "/api/augment/close", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["kind"], "body: {body}");
assert_eq!("select", body["phase"], "body: {body}");
}
#[test]
fn get_api_augment_with_no_open_screen_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/augment", &[], &[]);
assert_eq!(409, resp.status);
}
#[test]
fn post_api_augment_generate_with_a_targets_list_runs_every_target_even_after_one_fails() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let fake = fake_reply(scripts.path(), r#"{"0": "a note"}"#);
let (base, _guard) = spawn_full_server(Some(&fake));
post_json(&base, "/api/augment/open", r#"{"deck":"choice.txt"}"#);
let resp = post_json(
&base,
"/api/augment/generate",
r#"{"targets":[{"target":"notes"},{"target":"choices"}]}"#,
);
assert_eq!(200, resp.status);
let body = poll_until(&base, "/api/augment", |b| {
b["busy"].is_null() && b["queued"].as_array().is_some_and(|q| q.is_empty())
});
let done = body["done"].as_array().unwrap();
assert!(
done.iter().any(|t| t == "notes"),
"notes should have succeeded: body: {body}"
);
let failed = body["failed"].as_array().unwrap();
let choices_failure = failed
.iter()
.find(|f| f["target"] == "choices")
.unwrap_or_else(|| panic!("choices should have been attempted and failed: body: {body}"));
assert!(
!choices_failure["error"].as_str().unwrap().is_empty(),
"body: {body}"
);
}
#[test]
fn each_batch_target_carries_its_own_guidance() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let log = scripts.path().join("prompts.log");
let reply = scripts.path().join("reply.json");
std::fs::write(&reply, r#"{"0": "a note"}"#).unwrap();
let fake = scripts.path().join("fake-claude");
std::fs::write(
&fake,
format!(
"#!/bin/sh\nPATH=/usr/bin:/bin\ncat >> {log}\necho '===EOM===' >> {log}\ncat {reply}\n",
log = log.display(),
reply = reply.display()
),
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let (base, _guard) = spawn_full_server(Some(&fake));
post_json(&base, "/api/augment/open", r#"{"deck":"choice.txt"}"#);
let resp = post_json(
&base,
"/api/augment/generate",
r#"{"targets":[{"target":"notes","with":"mnemonic style"},
{"target":"questions","with":"vary the angle"}]}"#,
);
assert_eq!(200, resp.status);
poll_until(&base, "/api/augment", |b| {
b["busy"].is_null() && b["queued"].as_array().is_some_and(|q| q.is_empty())
});
let captured = std::fs::read_to_string(&log).unwrap();
let prompts: Vec<&str> = captured.split("===EOM===").collect();
assert!(prompts.len() >= 2, "expected two prompts, got: {captured}");
let (notes, questions) = (prompts[0], prompts[1]);
assert!(
notes.contains("mnemonic style") && !notes.contains("vary the angle"),
"the notes prompt must carry only its own steer: {notes}"
);
assert!(
questions.contains("vary the angle") && !questions.contains("mnemonic style"),
"the questions prompt must carry only its own steer: {questions}"
);
}
fn fake_conversation_cli(scripts: &TempDir, replies: &[&str]) -> PathBuf {
for (i, reply) in replies.iter().enumerate() {
std::fs::write(scripts.path().join(format!("reply-{i}")), reply).unwrap();
}
let d = scripts.path().display();
let fake = scripts.path().join("fake-claude");
std::fs::write(
&fake,
format!(
"#!/bin/sh\nPATH=/usr/bin:/bin\n\
N=$(cat {d}/n 2>/dev/null || echo 0)\n\
echo \"$@\" >> {d}/args.log\n\
cat >> {d}/prompt-$N.log\n\
echo $((N+1)) > {d}/n\n\
if [ -f {d}/fail-$N ]; then exit 1; fi\n\
cat {d}/reply-$N\n"
),
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
fake
}
fn session_id_after(line: &str, flag: &str) -> String {
line.split_whitespace()
.skip_while(|w| *w != flag)
.nth(1)
.unwrap_or_else(|| panic!("no {flag} id in: {line}"))
.to_string()
}
#[test]
fn a_batch_reuses_one_claude_session_across_targets() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let reply = r#"{"0": ["w1","w2","w3"]}"#;
let fake = fake_conversation_cli(&scripts, &[reply, reply]);
let (base, _guard) = spawn_full_server(Some(&fake));
post_json(&base, "/api/augment/open", r#"{"deck":"choice.txt"}"#);
let resp = post_json(
&base,
"/api/augment/generate",
r#"{"targets":[{"target":"choices"},{"target":"questions"}]}"#,
);
assert_eq!(200, resp.status);
poll_until(&base, "/api/augment", |b| {
b["busy"].is_null() && b["queued"].as_array().is_some_and(|q| q.is_empty())
});
let args = std::fs::read_to_string(scripts.path().join("args.log")).unwrap();
let calls: Vec<&str> = args.lines().collect();
assert_eq!(2, calls.len(), "{args}");
assert!(calls[0].contains("--session-id"), "{args}");
assert!(calls[1].contains("--resume"), "{args}");
let id = session_id_after(calls[0], "--session-id");
assert!(
calls[1].contains(&id),
"one conversation across the batch: {args}"
);
let primer = std::fs::read_to_string(scripts.path().join("prompt-0.log")).unwrap();
let follow_up = std::fs::read_to_string(scripts.path().join("prompt-1.log")).unwrap();
assert!(
primer.contains("3 + 3"),
"the first call lists the cards: {primer}"
);
assert!(
follow_up.contains("already provided in this conversation"),
"{follow_up}"
);
assert!(
!follow_up.contains("3 + 3"),
"a follow-up must not re-list the cards: {follow_up}"
);
}
#[test]
fn a_failed_target_starts_a_fresh_session_for_the_rest_of_the_batch() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let reply = r#"{"0": ["w1","w2","w3"]}"#;
let fake = fake_conversation_cli(&scripts, &[reply, reply, reply]);
std::fs::write(scripts.path().join("fail-1"), "").unwrap();
let (base, _guard) = spawn_full_server(Some(&fake));
post_json(&base, "/api/augment/open", r#"{"deck":"choice.txt"}"#);
let resp = post_json(
&base,
"/api/augment/generate",
r#"{"targets":[{"target":"choices"},{"target":"questions"},{"target":"keypoints"}]}"#,
);
assert_eq!(200, resp.status);
poll_until(&base, "/api/augment", |b| {
b["busy"].is_null()
&& b["queued"].as_array().is_some_and(|q| q.is_empty())
&& b["failed"].as_array().is_some_and(|f| !f.is_empty())
});
let args = std::fs::read_to_string(scripts.path().join("args.log")).unwrap();
let calls: Vec<&str> = args.lines().collect();
assert_eq!(3, calls.len(), "{args}");
let first = session_id_after(calls[0], "--session-id");
assert!(calls[1].contains("--resume"), "{args}");
let third = session_id_after(calls[2], "--session-id");
assert_ne!(first, third, "a failed call must not be resumed: {args}");
let reprime = std::fs::read_to_string(scripts.path().join("prompt-2.log")).unwrap();
assert!(
reprime.contains("3 + 3"),
"the fresh session re-primes the roster: {reprime}"
);
}
#[test]
fn a_single_target_batch_stays_a_stateless_one_shot() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let fake = fake_conversation_cli(&scripts, &[r#"{"0": "a note"}"#]);
let (base, _guard) = spawn_full_server(Some(&fake));
post_json(&base, "/api/augment/open", r#"{"deck":"choice.txt"}"#);
let resp = post_json(
&base,
"/api/augment/generate",
r#"{"targets":[{"target":"notes"}]}"#,
);
assert_eq!(200, resp.status);
poll_until(&base, "/api/augment", |b| {
b["busy"].is_null() && b["done"].as_array().is_some_and(|d| !d.is_empty())
});
let args = std::fs::read_to_string(scripts.path().join("args.log")).unwrap();
assert!(
!args.contains("--session-id") && !args.contains("--resume"),
"one call gains nothing from a session: {args}"
);
let prompt = std::fs::read_to_string(scripts.path().join("prompt-0.log")).unwrap();
assert!(
prompt.contains("3 + 3"),
"the one-shot lists its cards: {prompt}"
);
}
fn write_workspace_fixture(dir: &Path) {
let ws = dir.join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"WS\"\n").unwrap();
std::fs::write(ws.join("m1.txt"), "# q1\n\ta1\n# q2\n\ta2\n").unwrap();
std::fs::write(ws.join("m2.txt"), "# q3\n\ta3\n# q4\n\ta4\n# q5\n\ta5\n").unwrap();
}
fn write_plain_folder_fixture(dir: &Path) {
let folder = dir.join("plainfolder");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("loose.txt"), "# q\n\ta\n").unwrap();
}
#[test]
fn augment_open_on_a_workspace_unions_member_cards_and_offers_the_icon_row() {
let (base, _guard) = spawn_full_server_fixture(None, write_workspace_fixture);
let resp = post_json(&base, "/api/augment/open", r#"{"deck":"ws"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(5, body["cards"], "union of both members: body: {body}");
let rows = body["rows"].as_array().unwrap();
let icon = rows
.iter()
.find(|r| r["kind"] == "icon")
.unwrap_or_else(|| panic!("a workspace open must offer the icon row: {body}"));
assert_eq!(0, icon["covered"], "no assets/icon.* yet: {body}");
assert_eq!(1, icon["eligible"], "body: {body}");
let resp = post_json(&base, "/api/augment/open", r#"{"deck":"sample.txt"}"#);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["rows"]
.as_array()
.unwrap()
.iter()
.all(|r| r["kind"] != "icon"),
"body: {body}"
);
}
#[test]
fn icon_target_generates_the_workspace_emblem_with_its_steer() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let log = scripts.path().join("prompts.log");
let reply = scripts.path().join("reply.svg");
std::fs::write(&reply, r#"<svg viewBox="0 0 24 24"><circle r="8"/></svg>"#).unwrap();
let fake = scripts.path().join("fake-claude");
std::fs::write(
&fake,
format!(
"#!/bin/sh\nPATH=/usr/bin:/bin\ncat >> {log}\ncat {reply}\n",
log = log.display(),
reply = reply.display()
),
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let (base, guard) = spawn_full_server_fixture(Some(&fake), write_workspace_fixture);
post_json(&base, "/api/augment/open", r#"{"deck":"ws"}"#);
let resp = post_json(
&base,
"/api/augment/generate",
r#"{"targets":[{"target":"icon","with":"a compass rose"}]}"#,
);
assert_eq!(200, resp.status);
let body = poll_until(&base, "/api/augment", |b| {
b["busy"].is_null() && b["queued"].as_array().is_some_and(|q| q.is_empty())
});
assert!(
body["done"].as_array().unwrap().iter().any(|t| t == "icon"),
"body: {body}"
);
let icon_path = guard.dir.path().join("ws/assets/icon.svg");
assert!(
icon_path.exists(),
"the emblem was written to the workspace"
);
let icon_row = body["rows"]
.as_array()
.unwrap()
.iter()
.find(|r| r["kind"] == "icon")
.unwrap()
.clone();
assert_eq!(1, icon_row["covered"], "coverage sees the new file: {body}");
let captured = std::fs::read_to_string(&log).unwrap();
assert!(
captured.contains("a compass rose"),
"the icon prompt must carry the card's steer: {captured}"
);
}
#[test]
fn post_api_exam_start_on_a_trace_deck_opens_directly_in_the_answering_phase() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(&base, "/api/exam/start", r#"{"deck":"trace.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("answering", body["phase"], "body: {body}");
assert_eq!(true, body["is_trace"], "body: {body}");
assert_eq!("trace.txt", body["deck"], "body: {body}");
assert_eq!(1, body["total"], "body: {body}");
assert_eq!(0, body["current"], "body: {body}");
assert!(body["question"].as_str().is_some(), "body: {body}");
}
#[test]
fn post_api_exam_close_returns_the_picker_state_dto() {
let (base, _guard) = spawn_full_server(None);
post_json(&base, "/api/exam/start", r#"{"deck":"trace.txt"}"#);
let resp = post_json(&base, "/api/exam/close", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["kind"], "body: {body}");
assert_eq!("select", body["phase"], "body: {body}");
}
#[test]
fn post_api_exam_start_with_an_unknown_deck_yields_400() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(&base, "/api/exam/start", r#"{"deck":"nope.txt"}"#);
assert_eq!(400, resp.status);
}
#[test]
fn post_api_exam_start_on_a_deck_with_no_exam_yields_409() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(&base, "/api/exam/start", r#"{"deck":"sample.txt"}"#);
assert_eq!(409, resp.status);
}
#[test]
fn get_api_exam_with_no_active_sitting_yields_409() {
let (base, _guard) = spawn_full_server(None);
let resp = http(&base, "GET", "/api/exam", &[], &[]);
assert_eq!(409, resp.status);
}
#[test]
fn exam_grade_on_a_trace_deck_walks_from_answering_to_a_passing_result_via_the_fake_backend() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let fake = fake_reply(
scripts.path(),
r#"{"verdict":"pass","feedback":"nice work retracing it","missed":[]}"#,
);
let (base, _guard) = spawn_full_server(Some(&fake));
post_json(&base, "/api/exam/start", r#"{"deck":"trace.txt"}"#);
let resp = post_json(
&base,
"/api/exam/grade",
r#"{"text":"it forwards the value hop by hop, first then second"}"#,
);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("grading", body["phase"], "body: {body}");
let body = poll_until(&base, "/api/exam", |b| b["phase"] != "grading");
assert_eq!("results", body["phase"], "body: {body}");
assert_eq!(true, body["passed"], "body: {body}");
let grades = body["grades"].as_array().unwrap();
assert_eq!(1, grades.len(), "body: {body}");
assert_eq!("PASS", grades[0]["verdict"], "body: {body}");
}
#[test]
fn selecting_a_trace_deck_returns_a_walk_through_the_real_classifier() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(&base, "/api/select", r#"{"deck":"trace.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("walk", body["kind"], "body: {body}");
}
#[test]
fn selecting_a_trace_deck_returns_a_walk_dto_not_a_review_state() {
let (base, _guard) = spawn_full_server(None);
let resp = post_json(&base, "/api/select", r#"{"deck":"trace.txt"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("walk", body["kind"], "body: {body}");
assert_eq!("predict", body["phase"], "body: {body}");
assert_eq!(false, body["auto_grade"], "body: {body}");
assert_eq!(1, body["current"], "body: {body}");
assert_eq!(2, body["total"], "body: {body}");
assert_eq!("Predict the first hop", body["prompt"], "body: {body}");
}
#[test]
fn walk_predict_then_self_grade_reveals_the_excerpt_and_advances_the_hop() {
let (base, _guard) = spawn_full_server(None);
post_json(&base, "/api/select", r#"{"deck":"trace.txt"}"#);
let resp = post_json(&base, "/api/walk/predict", r#"{"text":"my guess"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("reveal", body["phase"], "body: {body}");
assert_eq!("my guess", body["prediction"], "body: {body}");
assert_eq!("first", body["excerpt"]["lines"][0]["text"], "body: {body}");
let resp = post_json(&base, "/api/walk/grade", r#"{"delta":"n"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("predict", body["phase"], "body: {body}");
assert_eq!(2, body["current"], "body: {body}");
assert_eq!("passed", body["path"][0]["delta"], "body: {body}");
}
#[test]
fn walk_restart_resets_to_the_first_hop() {
let (base, _guard) = spawn_full_server(None);
post_json(&base, "/api/select", r#"{"deck":"trace.txt"}"#);
post_json(&base, "/api/walk/predict", r#"{"text":"my guess"}"#);
post_json(&base, "/api/walk/grade", r#"{"delta":"n"}"#);
let resp = post_json(&base, "/api/walk/restart", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("predict", body["phase"], "body: {body}");
assert_eq!(1, body["current"], "body: {body}");
}
#[test]
fn walk_leave_returns_to_the_picker_state_dto() {
let (base, _guard) = spawn_full_server(None);
post_json(&base, "/api/select", r#"{"deck":"trace.txt"}"#);
let resp = post_json(&base, "/api/walk/leave", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("review", body["kind"], "body: {body}");
assert_eq!("select", body["phase"], "body: {body}");
}
#[test]
fn get_api_walk_with_no_active_walk_yields_409() {
let (base, _guard) = spawn_full_server(None);
let resp = http(&base, "GET", "/api/walk", &[], &[]);
assert_eq!(409, resp.status);
}
#[test]
fn walk_predict_with_auto_grade_resolves_a_verdict_via_the_fake_backend() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let fake = fake_reply(scripts.path(), "PASSED — you got hop one right.\n");
let (base, _guard) = spawn_full_server(Some(&fake));
let select_resp = post_json(&base, "/api/select", r#"{"deck":"trace.txt"}"#);
let select_body: serde_json::Value = serde_json::from_slice(&select_resp.body).unwrap();
assert_eq!(true, select_body["auto_grade"], "body: {select_body}");
post_json(
&base,
"/api/walk/predict",
r#"{"text":"it forwards the line along"}"#,
);
let body = poll_until(&base, "/api/walk", |b| !b["thinking"].as_bool().unwrap());
assert_eq!(Some("passed"), body["verdict"].as_str(), "body: {body}");
assert!(
body["feedback"].as_str().unwrap().contains("hop one right"),
"body: {body}"
);
let resp = post_json(&base, "/api/walk/grade", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("predict", body["phase"], "body: {body}");
assert_eq!(2, body["current"], "body: {body}");
}
#[test]
fn post_api_share_surfaces_an_install_hint_when_wormhole_is_not_on_path() {
let empty = TempDir::new().unwrap();
with_empty_path(empty.path(), || {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/share", "{}");
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("error", body["phase"], "body: {body}");
let err = body["error"].as_str().expect("an error message");
assert!(
err.contains("magic-wormhole installed"),
"expected the install hint, got: {err}"
);
});
}
#[test]
fn post_api_receive_surfaces_an_install_hint_when_wormhole_is_not_on_path() {
let empty = TempDir::new().unwrap();
with_empty_path(empty.path(), || {
let (base, _guard) = spawn_test_server();
let resp = post_json(&base, "/api/receive", r#"{"code":"7-alpha-bravo"}"#);
assert_eq!(200, resp.status);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!("error", body["phase"], "body: {body}");
let err = body["error"].as_str().expect("an error message");
assert!(
err.contains("magic-wormhole installed"),
"expected the install hint, got: {err}"
);
});
}
#[test]
fn get_api_share_with_no_share_in_flight_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/share", &[], &[]);
assert_eq!(409, resp.status);
}
#[test]
fn get_api_receive_with_no_receive_in_flight_yields_409() {
let (base, _guard) = spawn_test_server();
let resp = http(&base, "GET", "/api/receive", &[], &[]);
assert_eq!(409, resp.status);
}
fn spawn_kids_server() -> (String, Guard) {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.txt"), FIXTURE_DECK).unwrap();
let store_path = dir.path().join("store.json");
let store = Store::open(&store_path).unwrap();
let recent = RecentDecks::load(dir.path().join("recent.json"));
let decks_dir = dir.path().to_path_buf();
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let server = Arc::new(serve::bind(addr).unwrap());
let port = server
.server_addr()
.to_ip()
.expect("bound to a loopback IP")
.port();
let base = format!("http://127.0.0.1:{port}");
let opts = review_options(&base, None);
let opts = ReviewOptions {
audience: Audience::Kids,
cfg: AssembleConfig {
trace_auto_grade: false,
pacing: Pacing {
max_new: 10,
limit: None,
},
instance_store: Some(store_path),
..opts.cfg
},
..opts
};
let stop_handle = Arc::clone(&server);
let handle = thread::spawn(move || {
let _ = serve::run_review(store, recent, decks_dir, server, opts);
});
(
base,
Guard {
server: stop_handle,
handle: Some(handle),
dir,
},
)
}
#[test]
fn ask_card_draft_then_create_round_trips_a_learner_edited_card_into_the_queue() {
let _lock = exec_lock();
let scripts = TempDir::new().unwrap();
let fake = fake_reply(scripts.path(), "# term?\n\tdefinition\n");
let (base, _guard) = spawn_full_server(Some(&fake));
select_fixture(&base);
let resp = post_json(&base, "/api/ask", r#"{"question":"why does this matter?"}"#);
assert_eq!(200, resp.status);
let body = poll_until(&base, "/api/ask", |b| !b["thinking"].as_bool().unwrap());
assert_eq!(
1,
body["transcript"].as_array().unwrap().len(),
"body: {body}"
);
let resp = post_json(&base, "/api/ask/card/draft", "{}");
assert_eq!(200, resp.status);
let body = poll_until(&base, "/api/ask", |b| !b["thinking"].as_bool().unwrap());
assert_eq!("term?", body["draft"]["front"], "body: {body}");
assert_eq!(
serde_json::json!(["definition"]),
body["draft"]["back"],
"body: {body}"
);
let resp = post_json(
&base,
"/api/ask/card/create",
r#"{"front":"edited term?","back":["edited definition"]}"#,
);
assert_eq!(
200,
resp.status,
"body: {}",
String::from_utf8_lossy(&resp.body)
);
let create_body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert!(create_body["id"].as_str().is_some(), "body: {create_body}");
let resp = post_json(&base, "/api/select", r#"{"deck":"sample.txt","cram":true}"#);
assert_eq!(200, resp.status);
let select_body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(3, select_body["remaining"], "body: {select_body}");
assert_eq!(
"edited term?", select_body["card"]["front"],
"body: {select_body}"
);
let state = http(&base, "GET", "/api/state", &[], &[]);
let state_body: serde_json::Value = serde_json::from_slice(&state.body).unwrap();
assert_eq!(
"edited term?", state_body["card"]["front"],
"body: {state_body}"
);
}
#[test]
fn ask_card_draft_and_create_are_refused_for_a_kids_audience() {
let (base, _guard) = spawn_kids_server();
let draft_resp = post_json(&base, "/api/ask/card/draft", "{}");
assert_eq!(403, draft_resp.status);
let create_resp = post_json(
&base,
"/api/ask/card/create",
r#"{"front":"f","back":["b"]}"#,
);
assert_eq!(403, create_resp.status);
}
#[test]
fn ask_card_create_with_a_back_matching_an_authored_card_yields_422() {
let (base, _guard) = spawn_test_server();
select_fixture(&base);
let resp = post_json(
&base,
"/api/ask/card/create",
r#"{"front":"what does 2 plus 2 equal?","back":["4"]}"#,
);
assert_eq!(422, resp.status);
}
#[test]
fn workspace_deadline_set_and_clear_round_trip_through_the_file_and_the_decks_readout() {
let (base, guard) = spawn_test_server_fixture(None, write_workspace_fixture);
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"ws","date":"2099-01-02"}"#,
);
assert_eq!(
200,
resp.status,
"body: {}",
String::from_utf8_lossy(&resp.body)
);
let local = std::fs::read_to_string(guard.dir().join("ws/alix.local.toml")).unwrap();
assert!(
local.contains("deadline = \"2099-01-02\""),
"local: {local}"
);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let ws = body["workspaces"]
.as_array()
.unwrap()
.iter()
.find(|w| w["name"] == "ws")
.unwrap_or_else(|| panic!("no `ws` workspace row in the response: {body}"));
assert_eq!("2099-01-02", ws["deadline"]["date"], "row: {ws}");
assert!(
ws["deadline"]["days_left"].as_i64().unwrap() > 0,
"row: {ws}"
);
assert!(ws["deadline"]["ready"].is_number(), "row: {ws}");
assert!(ws["deadline"]["total"].is_number(), "row: {ws}");
let decks_resp = http(&base, "GET", "/api/decks", &[], &[]);
let decks: serde_json::Value = serde_json::from_slice(&decks_resp.body).unwrap();
let ws = decks["workspaces"]
.as_array()
.unwrap()
.iter()
.find(|w| w["name"] == "ws")
.unwrap();
assert_eq!("2099-01-02", ws["deadline"]["date"], "row: {ws}");
let bad = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"ws","date":"tomorrow"}"#,
);
assert_eq!(400, bad.status);
let local = std::fs::read_to_string(guard.dir().join("ws/alix.local.toml")).unwrap();
assert!(
local.contains("deadline = \"2099-01-02\""),
"local: {local}"
);
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"ws","date":null}"#,
);
assert_eq!(200, resp.status);
let local = std::fs::read_to_string(guard.dir().join("ws/alix.local.toml")).unwrap();
assert!(!local.contains("deadline"), "local: {local}");
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
let ws = body["workspaces"]
.as_array()
.unwrap()
.iter()
.find(|w| w["name"] == "ws")
.unwrap();
assert!(ws["deadline"].is_null(), "row: {ws}");
}
#[test]
fn workspace_deadline_rejects_a_plain_deck_row_and_an_unknown_name() {
let (base, _guard) = spawn_test_server_fixture(None, write_workspace_fixture);
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"sample.txt","date":"2099-01-02"}"#,
);
assert_eq!(400, resp.status);
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"does-not-exist","date":"2099-01-02"}"#,
);
assert_eq!(400, resp.status);
}
#[test]
fn workspace_deadline_rejects_a_plain_folder_that_is_not_a_workspace() {
let (base, guard) = spawn_test_server_fixture(None, |dir| {
write_workspace_fixture(dir);
write_plain_folder_fixture(dir);
});
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"plainfolder","date":"2099-01-02"}"#,
);
assert_eq!(400, resp.status);
assert!(
!guard.dir().join("plainfolder/alix.local.toml").is_file(),
"a non-workspace folder must never get a deadline file written into it"
);
}
#[test]
fn workspace_deadline_with_a_missing_date_key_is_a_400_not_a_silent_clear() {
let (base, guard) = spawn_test_server_fixture(None, write_workspace_fixture);
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"ws","date":"2099-01-02"}"#,
);
assert_eq!(200, resp.status);
let resp = post_json(&base, "/api/workspace/deadline", r#"{"name":"ws"}"#);
assert_eq!(400, resp.status);
let local = std::fs::read_to_string(guard.dir().join("ws/alix.local.toml")).unwrap();
assert!(
local.contains("deadline = \"2099-01-02\""),
"a missing `date` key must not clear the deadline: local: {local}"
);
}
#[test]
fn workspace_deadline_returns_500_when_the_local_manifest_has_a_non_table_review_key() {
let (base, guard) = spawn_test_server_fixture(None, write_workspace_fixture);
std::fs::write(guard.dir().join("ws/alix.local.toml"), "review = 5\n").unwrap();
let resp = post_json(
&base,
"/api/workspace/deadline",
r#"{"name":"ws","date":"2099-01-02"}"#,
);
assert_eq!(500, resp.status);
}