mod common; use std::process::{Command, Output}; use weksa::{InventoryStore, PacketStore}; fn weksa(dir: &std::path::Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_weksa")) .current_dir(dir) .args([ "--corpus", "./c.cc", "--inventory", "./i.cc", "--entities", "./e.cc", ]) .args(args) .output() .unwrap() } /// Admits the cast entities the tests cite. fn cast_entities(dir: &std::path::Path, slugs: &[&str]) { for slug in slugs { let id = format!("entity:{slug}"); let form = format!("en-US={}", slug.to_uppercase()); let put = weksa( dir, &[ "entity", "put", &id, "--form", &form, "--origin", "cast", "--persona", slug, ], ); assert!(put.status.success(), "{}", out(&put)); } } fn out(o: &Output) -> String { String::from_utf8_lossy(&o.stdout).into_owned() + &String::from_utf8_lossy(&o.stderr) } const ADD: [&str; 14] = [ "corpus", "add", "--slug", "nibu", "--text", "Mind the hatch.", "--speaker", "entity:nibu", "--group", "scene-1", "--admitted-on", "2026-10-29", "--source", "cast-authored", ]; #[test] fn seed_add_dump_split_withdraw() { let d = tempfile::tempdir().unwrap(); cast_entities(d.path(), &["nibu"]); let seeded = weksa(d.path(), &["inventory", "seed"]); assert!(seeded.status.success(), "{}", out(&seeded)); assert!(out(&seeded).contains("seeded rev:1")); let again = weksa(d.path(), &["inventory", "seed"]); assert!( !again.status.success(), "a second seed is refused: {}", out(&again) ); let dump = out(&weksa(d.path(), &["inventory", "dump"])); assert!( dump.contains("rev:1") && dump.contains("speech_act:5") && dump.contains("warn"), "{dump}" ); // A cast-authored item needs its persona and fixture. let missing = weksa(d.path(), &ADD); assert!( !missing.status.success() && out(&missing).contains("--persona"), "{}", out(&missing) ); let mut add = ADD.to_vec(); add.extend(["--persona", "nibu", "--fixture", "f.yaml"]); let added = weksa(d.path(), &add); assert!( added.status.success() && out(&added).contains("Added item:nibu:1"), "{}", out(&added) ); // The same text under a new key is refused (S6); another text is not. let copy = weksa(d.path(), &add); assert!( !copy.status.success() && out(©).contains("S6TextHeld"), "{}", out(©) ); let mut other = add.clone(); other[5] = "Mind the other hatch."; let second = weksa(d.path(), &other); assert!( out(&second).contains("Added item:nibu:2"), "{}", out(&second) ); let dump = out(&weksa(d.path(), &["corpus", "dump"])); assert!( dump.contains("item:nibu:1") && dump.contains("Mind the hatch.") && dump.contains("CastAuthored"), "{dump}" ); let split = out(&weksa(d.path(), &["split", "item:nibu:1"])); assert!( ["Train", "Validation", "Test"] .iter() .any(|s| split.trim() == *s), "{split}" ); assert!(!weksa(d.path(), &["split", "item:nibu:9"]).status.success()); let withdrawn = weksa(d.path(), &["corpus", "withdraw", "item:nibu:1"]); assert!(withdrawn.status.success()); assert!(out(&weksa(d.path(), &["corpus", "dump"])).contains("withdrawn")); } #[test] fn model_written_and_public_domain_sources_name_their_provenance() { let d = tempfile::tempdir().unwrap(); cast_entities(d.path(), &["s", "player"]); let base = [ "corpus", "add", "--slug", "x", "--speaker", "entity:s", "--group", "g", "--admitted-on", "2026-10-29", ]; // S6 refuses one text under two keys, so each run writes its own. let runs = std::cell::Cell::new(0); let run = |extra: &[&str]| { runs.set(runs.get() + 1); let text = format!("line {}", runs.get()); let mut args = base.to_vec(); args.extend_from_slice(&["--text", &text]); args.extend_from_slice(extra); weksa(d.path(), &args) }; let no_family = run(&["--source", "model-written"]); assert!( !no_family.status.success() && out(&no_family).contains("--model-family"), "{}", out(&no_family) ); let not_an_enum_value = run(&["--source", "model-written", "--model-family", "m"]); assert!( !not_an_enum_value.status.success(), "{}", out(¬_an_enum_value) ); let model = run(&[ "--source", "model-written", "--model-family", "open-ai", "--generator", "g", ]); assert!(model.status.success(), "{}", out(&model)); let dump = weksa(d.path(), &["corpus", "dump"]); assert!( out(&dump).contains("OpenAi") && out(&dump).contains("generator: Some(\"g\")"), "{}", out(&dump) ); let no_work = run(&["--source", "public-domain", "--author", "a"]); assert!( !no_work.status.success() && out(&no_work).contains("--work"), "{}", out(&no_work) ); let pd = run(&[ "--source", "public-domain", "--work", "w", "--author", "a", "--edition-url", "https://example.org/w", ]); assert!(pd.status.success(), "{}", out(&pd)); let bad_turn = run(&[ "--source", "operator-written", "--prior-turn", "no equals sign", ]); assert!( !bad_turn.status.success() && out(&bad_turn).contains("speaker=text"), "{}", out(&bad_turn) ); let turn = run(&[ "--source", "operator-written", "--prior-turn", "entity:player=hello", ]); assert!(turn.status.success(), "{}", out(&turn)); } #[test] fn validate_reports_each_packet() { let d = tempfile::tempdir().unwrap(); let w = common::world(); let full = w.dir.path().join("i.cc"); let packets = d.path().join("p.cc"); let packet = common::good(); PacketStore::open(&packets) .unwrap() .admit(packet.clone(), &w.inventory, &w.corpus, &w.entities) .unwrap(); let validate = |inventory: &std::path::Path, extra: &[&str]| { Command::new(env!("CARGO_BIN_EXE_weksa")) .arg("--inventory") .arg(inventory) .arg("validate") .arg(&packets) .args(extra) .output() .unwrap() }; let ok = validate(&full, &[]); assert!( ok.status.success() && out(&ok).contains(&format!("ok {}", packet.id)), "{}", out(&ok) ); assert!(validate(&full, &[packet.id.as_str()]).status.success()); assert!(!validate(&full, &["packet:missing"]).status.success()); // Against an inventory that only reaches revision 1 the same packet is refused, by rule. let short = d.path().join("short.cc"); let (rev1, entries1) = weksa::inventory::seed(); InventoryStore::open(&short) .unwrap() .admit_revision(rev1, entries1) .unwrap(); let refused = validate(&short, &[]); assert!( !refused.status.success() && out(&refused).contains("V10"), "{}", out(&refused) ); } #[test] fn schema_writes_the_published_files() { let d = tempfile::tempdir().unwrap(); let o = weksa(d.path(), &["schema", "--out", "s"]); assert!(o.status.success(), "{}", out(&o)); let mut names: Vec = std::fs::read_dir(d.path().join("s")) .unwrap() .map(|e| e.unwrap().file_name().into_string().unwrap()) .collect(); names.sort(); assert_eq!( names, [ "weksa.corpus_item.schema.json", "weksa.entity.schema.json", "weksa.interlingua_packet.schema.json", "weksa.inventory_entry.schema.json", "weksa.inventory_revision.schema.json" ] ); } #[test] fn entity_put_and_name_spans() { let d = tempfile::tempdir().unwrap(); let put = |args: &[&str]| { let mut all = vec!["entity", "put", "entity:kay", "--origin", "cast"]; all.extend_from_slice(args); weksa(d.path(), &all) }; let no_persona = put(&["--form", "en-US=Kay"]); assert!( !no_persona.status.success() && out(&no_persona).contains("--persona"), "{}", out(&no_persona) ); let first = put(&["--form", "en-US=Kay", "--persona", "kay"]); assert!( first.status.success() && out(&first).contains("Added entity:kay"), "{}", out(&first) ); // Forms append; a language's forms keep the order given. let grown = put(&[ "--form", "en-US=Kay", "--form", "en-US=Kay's", "--persona", "kay", ]); assert!(grown.status.success(), "{}", out(&grown)); let shrunk = put(&["--form", "en-US=Kay", "--persona", "kay"]); assert!( !shrunk.status.success() && out(&shrunk).contains("EntityNotAppendOnly"), "{}", out(&shrunk) ); let dump = out(&weksa(d.path(), &["entity", "dump"])); assert!( dump.contains("entity:kay") && dump.contains("Kay's"), "{dump}" ); cast_entities(d.path(), &["nibu"]); let add = |range: &str, text: &str| { weksa( d.path(), &[ "corpus", "add", "--slug", "kay", "--text", text, "--speaker", "entity:nibu", "--group", "g", "--admitted-on", "2026-10-29", "--source", "operator-written", "--name", range, ], ) }; let named = add("0..3=entity:kay", "Kay left."); assert!(named.status.success(), "{}", out(&named)); let unequal = add("0..3=entity:kay", "Kai left."); assert!( !unequal.status.success() && out(&unequal).contains("NameSpanNotAForm"), "{}", out(&unequal) ); let malformed = add("0-3", "Kay again."); assert!( !malformed.status.success() && out(&malformed).contains("start..end=entity"), "{}", out(&malformed) ); let dump = out(&weksa(d.path(), &["corpus", "dump"])); assert!( dump.contains("names:") && dump.contains("entity:kay"), "{dump}" ); } #[test] fn entity_origin_flags_must_match_origin() { let d = tempfile::tempdir().unwrap(); let put = |args: &[&str]| { let mut all = vec!["entity", "put", "entity:kay", "--form", "en-US=Kay"]; all.extend_from_slice(args); weksa(d.path(), &all) }; for mismatched in [ &["--origin", "live", "--persona", "kay"][..], &["--origin", "live", "--work", "edda"], &["--origin", "cast", "--persona", "kay", "--work", "edda"], &[ "--origin", "public-domain", "--persona", "kay", "--work", "edda", ], &["--origin", "public-domain", "--persona", "kay"], ] { let o = put(mismatched); assert!(!o.status.success(), "{mismatched:?}: {}", out(&o)); } assert!(!out(&weksa(d.path(), &["entity", "dump"])).contains("entity:kay")); assert!(put(&["--origin", "live"]).status.success()); let work = weksa( d.path(), &[ "entity", "put", "entity:brokkr", "--form", "en-US=Brokkr", "--origin", "public-domain", "--work", "edda", ], ); assert!(work.status.success(), "{}", out(&work)); assert!(out(&weksa(d.path(), &["entity", "dump"])).contains("PublicDomain")); } #[test] fn intake_normalizes_to_nfc() { let d = tempfile::tempdir().unwrap(); // A decomposed form and decomposed text meet as the same composed bytes. let put = weksa( d.path(), &[ "entity", "put", "entity:rene", "--form", "fr=Rene\u{301}", "--origin", "live", ], ); assert!(put.status.success(), "{}", out(&put)); cast_entities(d.path(), &["nibu"]); let add = weksa( d.path(), &[ "corpus", "add", "--slug", "rene", "--text", "Rene\u{301} part.", "--language", "fr", "--speaker", "entity:nibu", "--group", "g", "--admitted-on", "2026-10-29", "--source", "operator-written", "--name", "0..5=entity:rene", ], ); assert!(add.status.success(), "{}", out(&add)); let dump = out(&weksa(d.path(), &["corpus", "dump"])); assert!(dump.contains("Ren\u{e9} part."), "{dump}"); }