feat(cli): 0106 tasks 11-12 — aura campaign verb family + workspace gates
The campaign half of research_docs.rs: validate (intrinsic always; referential tier when a project env is discovered, with the explicit skip line outside one), introspect (--vocabulary/--block/--unwired/ --content-id, same exactly-one-of usage guard), register via the registry's campaign_path accessor, and RefFault prose (Display-free, unit-pinned). Five binary seam tests (skip line, empty-axis prose + exit 1, section vocabulary, spec-example open slots, content-addressed registration) reuse the file's temp_cwd helper — the plan's tempfile::tempdir() snippet deviated from the crate's zero-extra-dep test convention and was adapted, disclosed in the loop's report. The referential CLI branch inside a real loadable project stays fieldtest territory by the plan's own scope note (project::load needs a built dylib); its logic is pinned by the aura-registry tests. Gates: cargo test --workspace 916/0 (885 pre-cycle + 31 new), clippy -D warnings clean, cargo doc --no-deps 0 warnings. The worked spec example runs end-to-end headless: author both documents as text, validate with per-tier report lines, introspect vocabularies and open slots, obtain stable content ids, register into the store — no Rust compile, no run executed. closes #189
This commit is contained in:
@@ -3763,6 +3763,8 @@ enum Command {
|
||||
New(NewCmd),
|
||||
/// Validate, introspect, and register process documents (methodology).
|
||||
Process(research_docs::ProcessCmd),
|
||||
/// Validate, introspect, and register campaign documents (experiment intent).
|
||||
Campaign(research_docs::CampaignCmd),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -4712,6 +4714,7 @@ fn main() {
|
||||
Command::Reproduce(a) => dispatch_reproduce(a, &env),
|
||||
Command::New(a) => dispatch_new(a, &env),
|
||||
Command::Process(a) => research_docs::process_cmd(a, &env),
|
||||
Command::Campaign(a) => research_docs::campaign_cmd(a, &env),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! CLI surface for the research-artifact documents: the `aura process` verb
|
||||
//! family (the `aura campaign` family lands in a later task). Presentation
|
||||
//! layer only — parsing/validation/introspection live in aura-research;
|
||||
//! stores and the referential tier in aura-registry.
|
||||
//! CLI surface for the research-artifact documents: the `aura process` and
|
||||
//! `aura campaign` verb families. Presentation layer only —
|
||||
//! parsing/validation/introspection live in aura-research; stores and the
|
||||
//! referential tier in aura-registry.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aura_research::{
|
||||
describe_block, open_slots_process, parse_process, process_to_json, process_vocabulary,
|
||||
slot_kind_label, validate_process, DocError, DocFault, DocKind, ProcessDoc, StageBlock,
|
||||
campaign_to_json, campaign_vocabulary, describe_block, open_slots_campaign,
|
||||
open_slots_process, parse_campaign, parse_process, process_to_json, process_vocabulary,
|
||||
slot_kind_label, validate_campaign, validate_process, CampaignDoc, DocError, DocFault,
|
||||
DocKind, ProcessDoc, StageBlock,
|
||||
};
|
||||
use aura_registry::RefFault;
|
||||
|
||||
use crate::project::Env;
|
||||
|
||||
@@ -223,3 +226,179 @@ fn print_open_slots(slots: &[aura_research::OpenSlot]) {
|
||||
println!("no open slots");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub struct CampaignCmd {
|
||||
#[command(subcommand)]
|
||||
pub sub: CampaignSub,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
pub enum CampaignSub {
|
||||
/// Validate a campaign document (intrinsic + referential inside a project).
|
||||
Validate { file: PathBuf },
|
||||
/// Introspect the campaign vocabulary or a document.
|
||||
Introspect(DocIntrospectCmd),
|
||||
/// Register a valid campaign document into the store under the runs root.
|
||||
Register { file: PathBuf },
|
||||
}
|
||||
|
||||
fn ref_fault_prose(f: &RefFault) -> String {
|
||||
match f {
|
||||
RefFault::ProcessNotFound(id) => format!("process {id} not found in the project store"),
|
||||
RefFault::StrategyNotFound(id) => format!("strategy {id} not found in the blueprint store"),
|
||||
RefFault::IdentityUnmatched(id) => format!("identity id {id} matches no stored blueprint"),
|
||||
RefFault::StrategyUnloadable { id, error } => {
|
||||
format!("strategy {id} cannot be loaded: {error}")
|
||||
}
|
||||
RefFault::AxisNotInParamSpace { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
||||
}
|
||||
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `aura campaign …` — the dispatch entry main.rs calls.
|
||||
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
let result = match &cmd.sub {
|
||||
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
||||
CampaignSub::Introspect(i) => {
|
||||
guard_one_mode(i, "campaign");
|
||||
introspect_campaign(i)
|
||||
}
|
||||
CampaignSub::Register { file } => register_campaign(file, env),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_valid_campaign(file: &PathBuf) -> Result<CampaignDoc, String> {
|
||||
let text = read_doc(file)?;
|
||||
let doc = parse_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
|
||||
let faults = validate_campaign(&doc);
|
||||
if !faults.is_empty() {
|
||||
return Err(fault_block(
|
||||
"campaign document invalid:",
|
||||
faults.iter().map(doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
let doc = parse_valid_campaign(file)?;
|
||||
let axes: usize = doc.strategies.iter().map(|s| s.axes.len()).sum();
|
||||
let points: usize = doc
|
||||
.strategies
|
||||
.iter()
|
||||
.map(|s| s.axes.values().map(|a| a.values.len()).product::<usize>())
|
||||
.sum();
|
||||
println!(
|
||||
"campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} window(s)",
|
||||
doc.strategies.len(),
|
||||
axes,
|
||||
points,
|
||||
doc.data.windows.len()
|
||||
);
|
||||
if env.provenance().is_some() {
|
||||
let resolve = |t: &str| env.resolve(t);
|
||||
let faults = env
|
||||
.registry()
|
||||
.validate_campaign_refs(&doc, &resolve)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !faults.is_empty() {
|
||||
return Err(fault_block(
|
||||
"campaign references do not resolve:",
|
||||
faults.iter().map(ref_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
println!("campaign document valid (referential): all references resolve, axes are in the param space");
|
||||
} else {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
println!("referential checks skipped (no Aura.toml found up from {cwd})");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
let doc = parse_valid_campaign(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = campaign_to_json(&doc);
|
||||
let registry = env.registry();
|
||||
let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?;
|
||||
println!(
|
||||
"registered campaign content:{id} ({})",
|
||||
registry.campaign_path(&id).display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
||||
if cmd.vocabulary {
|
||||
for b in campaign_vocabulary() {
|
||||
println!("{:<18} {}", b.id, b.doc);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(id) = &cmd.block {
|
||||
return describe_one_block(id);
|
||||
}
|
||||
if let Some(file) = &cmd.unwired {
|
||||
let text = read_doc(file)?;
|
||||
let slots =
|
||||
open_slots_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
|
||||
print_open_slots(&slots);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(file) = &cmd.content_id {
|
||||
let doc = parse_valid_campaign(file)
|
||||
.map_err(|m| format!("an invalid document has no canonical form — {m}"))?;
|
||||
println!("content:{}", aura_research::content_id_of(&campaign_to_json(&doc)));
|
||||
return Ok(());
|
||||
}
|
||||
unreachable!("guard_one_mode enforces one mode")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ref_fault_prose_is_debug_free() {
|
||||
let cases = [
|
||||
ref_fault_prose(&RefFault::ProcessNotFound("4e2d".into())),
|
||||
ref_fault_prose(&RefFault::StrategyNotFound("9f3a".into())),
|
||||
ref_fault_prose(&RefFault::IdentityUnmatched("7b1c".into())),
|
||||
ref_fault_prose(&RefFault::StrategyUnloadable {
|
||||
id: "9f3a".into(),
|
||||
error: "UnknownKnob(\"fast\")".into(),
|
||||
}),
|
||||
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
}),
|
||||
ref_fault_prose(&RefFault::AxisKindMismatch {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "fast".into(),
|
||||
}),
|
||||
];
|
||||
assert_eq!(cases[0], "process 4e2d not found in the project store");
|
||||
assert_eq!(cases[1], "strategy 9f3a not found in the blueprint store");
|
||||
assert_eq!(cases[2], "identity id 7b1c matches no stored blueprint");
|
||||
assert_eq!(cases[3], "strategy 9f3a cannot be loaded: UnknownKnob(\"fast\")");
|
||||
assert_eq!(cases[4], "strategy 9f3a: axis \"nope\" is not in the param space");
|
||||
assert!(cases[5].contains("declares a kind that is not the param's kind"));
|
||||
for c in &cases {
|
||||
assert!(
|
||||
!c.contains("NotFound") && !c.contains("Mismatch") && !c.contains("Unmatched") && !c.contains("NotInParamSpace"),
|
||||
"Debug leak: {c}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! author uses.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A fresh, unique working directory for a process test that persists
|
||||
/// content-addressed documents under `./runs/` (so `aura process register`
|
||||
@@ -136,3 +137,285 @@ fn process_register_stores_content_addressed_under_runs_root() {
|
||||
.expect("id");
|
||||
assert!(dir.join("runs").join("processes").join(format!("{id}.json")).is_file());
|
||||
}
|
||||
|
||||
/// The demo-project fixture (Aura.toml present), built once — mirrors
|
||||
/// `project_load.rs`'s `built_fixture` (a separate test binary, so it needs
|
||||
/// its own `OnceLock`, but `cargo build` is idempotent).
|
||||
fn built_project() -> &'static PathBuf {
|
||||
static BUILT: OnceLock<PathBuf> = OnceLock::new();
|
||||
BUILT.get_or_init(|| {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project");
|
||||
let out = std::process::Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the fixture project");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
/// A scratch filesystem entry this test writes under the git-tracked
|
||||
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
|
||||
/// removed on drop — including during a mid-test panic — so a failed
|
||||
/// assertion never leaks scratch docs into tracked working-tree state.
|
||||
enum ScratchPath {
|
||||
File(PathBuf),
|
||||
Dir(PathBuf),
|
||||
}
|
||||
|
||||
struct ScratchGuard(Vec<ScratchPath>);
|
||||
|
||||
impl Drop for ScratchGuard {
|
||||
fn drop(&mut self) {
|
||||
for p in &self.0 {
|
||||
match p {
|
||||
ScratchPath::File(p) => {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
ScratchPath::Dir(p) => {
|
||||
let _ = std::fs::remove_dir_all(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `aura campaign validate` referential tier (the campaign family's
|
||||
/// headline addition over the intrinsic-only process family), exercised at
|
||||
/// the CLI seam inside a real project: a fully-resolved document prints the
|
||||
/// "valid (referential)" success line, and an unresolved reference prints
|
||||
/// the "do not resolve" fault block through `ref_fault_prose`. The
|
||||
/// constituents (`validate_campaign_refs`, `ref_fault_prose`) are unit-
|
||||
/// tested elsewhere; this pins that `aura campaign validate` actually wires
|
||||
/// them together end to end.
|
||||
#[test]
|
||||
fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||||
let dir = built_project();
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let _cleanup = ScratchGuard(vec![
|
||||
ScratchPath::Dir(runs_dir.clone()),
|
||||
ScratchPath::File(dir.join("seed.process.json")),
|
||||
ScratchPath::File(dir.join("ref-ok.campaign.json")),
|
||||
ScratchPath::File(dir.join("ref-bad.campaign.json")),
|
||||
]);
|
||||
|
||||
// Seed one real, content-addressed, open-param blueprint into the
|
||||
// project's own store via a real sweep (mirrors `project_load.rs`'s
|
||||
// anchor test).
|
||||
let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (sweep_out, sweep_code) = run_code_in(
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
&open_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
"campaign-ref-seed",
|
||||
],
|
||||
);
|
||||
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
|
||||
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
|
||||
.expect("blueprints dir")
|
||||
.next()
|
||||
.expect("one stored blueprint")
|
||||
.expect("dir entry")
|
||||
.path()
|
||||
.file_stem()
|
||||
.expect("stem")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
// Register a valid process into the same project store.
|
||||
write_doc(dir, "seed.process.json", PROCESS_DOC);
|
||||
let (proc_out, proc_code) = run_code_in(dir, &["process", "register", "seed.process.json"]);
|
||||
assert_eq!(proc_code, Some(0), "seed process register failed: {proc_out}");
|
||||
let proc_id = proc_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered process content:"))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process content:")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.to_string();
|
||||
|
||||
// "fast.length": the axis name is the RAW composite's `param_space` name.
|
||||
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
|
||||
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
|
||||
// "sma_signal." prefix `aura sweep --axis` binds against.
|
||||
let campaign = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "ref-check",
|
||||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
|
||||
"seed": 1,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#,
|
||||
bp = bp_id,
|
||||
proc = proc_id
|
||||
);
|
||||
write_doc(dir, "ref-ok.campaign.json", &campaign);
|
||||
let (out, code) = run_code_in(dir, &["campaign", "validate", "ref-ok.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
assert!(out.contains(
|
||||
"campaign document valid (referential): all references resolve, axes are in the param space"
|
||||
));
|
||||
|
||||
// An unresolved process reference: the fault block + `ref_fault_prose`
|
||||
// mapping, prose exit 1, at the same CLI seam.
|
||||
let bad = campaign.replace(&proc_id, "0000000000000000000000000000000000000000000000000000000000000000");
|
||||
write_doc(dir, "ref-bad.campaign.json", &bad);
|
||||
let (out2, code2) = run_code_in(dir, &["campaign", "validate", "ref-bad.campaign.json"]);
|
||||
assert_eq!(code2, Some(1));
|
||||
assert!(out2.contains("campaign references do not resolve:"), "stdout/stderr: {out2}");
|
||||
assert!(out2.contains("not found in the project store"), "stdout/stderr: {out2}");
|
||||
|
||||
// `_cleanup` (a `ScratchGuard`) removes the scratch docs and `runs/` on
|
||||
// drop, at the end of this scope — including on a mid-test panic.
|
||||
}
|
||||
|
||||
const CAMPAIGN_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "screen",
|
||||
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||||
"axes": { "fast": { "kind": "I64", "values": [8] } } } ],
|
||||
"process": { "ref": { "content_id": "4e2d" } },
|
||||
"seed": 1,
|
||||
"presentation": { "persist_taps": [], "emit": ["family_table"] }
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn campaign_validate_outside_project_skips_referential_tier() {
|
||||
let dir = temp_cwd("campaign-validate-outside-project");
|
||||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "c.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
assert!(out.contains("campaign document valid (intrinsic):"));
|
||||
assert!(out.contains("referential checks skipped (no Aura.toml found up from "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_validate_refuses_empty_axis_prose_exit_1() {
|
||||
let dir = temp_cwd("campaign-validate-empty-axis");
|
||||
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
|
||||
write_doc(&dir, "bad.campaign.json", &bad);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]);
|
||||
assert_eq!(code, Some(1));
|
||||
assert!(out.contains("axes.fast: an axis is a non-empty finite set"));
|
||||
assert!(!out.contains("EmptyAxis"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_introspect_vocabulary_lists_sections() {
|
||||
let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]);
|
||||
assert_eq!(code, Some(0));
|
||||
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation"] {
|
||||
assert!(out.contains(id), "vocabulary misses {id}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
||||
let dir = temp_cwd("campaign-introspect-unwired");
|
||||
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
|
||||
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||||
"axes": { "slow": { "kind": "I64", "values": [] } } } ],
|
||||
"seed": 1,
|
||||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||||
write_doc(&dir, "draft.campaign.json", draft);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(out.contains("open slot: process.ref (required, one of: content_id, identity_id)"));
|
||||
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
|
||||
}
|
||||
|
||||
/// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s
|
||||
/// `--content-id` branch: pins that `campaign introspect --content-id` wires
|
||||
/// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end
|
||||
/// (the process branch was covered; this one was not).
|
||||
#[test]
|
||||
fn campaign_introspect_content_id_prints_hash() {
|
||||
let dir = temp_cwd("campaign-introspect-content-id");
|
||||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--content-id", "c.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
let line = out.lines().find(|l| l.starts_with("content:")).expect("id line");
|
||||
assert_eq!(line.len(), "content:".len() + 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_register_stores_content_addressed_under_runs_root() {
|
||||
let dir = temp_cwd("campaign-register");
|
||||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
let line = out.lines().find(|l| l.starts_with("registered campaign content:")).expect("line");
|
||||
let id = line
|
||||
.trim_start_matches("registered campaign content:")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id");
|
||||
assert!(dir.join("runs").join("campaigns").join(format!("{id}.json")).is_file());
|
||||
}
|
||||
|
||||
/// `guard_one_mode` is shared, family-parameterized code (`process` and
|
||||
/// `campaign` both route through it). The process family already pins the
|
||||
/// zero-flags arm; this pins that the campaign family gets its own family
|
||||
/// name threaded into the usage line, not a copy-pasted "process" string.
|
||||
#[test]
|
||||
fn campaign_introspect_no_flag_usage_names_the_campaign_family() {
|
||||
let (out, code) = run_code(&["campaign", "introspect"]);
|
||||
assert_eq!(code, Some(2), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("Usage: aura campaign introspect --vocabulary | --block <ID> | --unwired <FILE> | --content-id <FILE>"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `guard_one_mode` requires *exactly* one mode: the zero-flags arm is
|
||||
/// pinned elsewhere, but nothing previously exercised the over-selection
|
||||
/// arm (`selected_modes() != 1` is not just `== 0`). Two modes at once must
|
||||
/// refuse with the same usage-exit-2 idiom, not silently pick one.
|
||||
#[test]
|
||||
fn campaign_introspect_two_flags_is_usage_exit_2() {
|
||||
let (out, code) = run_code(&["campaign", "introspect", "--vocabulary", "--block", "std::data"]);
|
||||
assert_eq!(code, Some(2), "stdout/stderr: {out}");
|
||||
assert!(out.contains("Usage: aura campaign introspect"), "stdout/stderr: {out}");
|
||||
}
|
||||
|
||||
/// Register must be a gate, not a passthrough: an invalid document is
|
||||
/// refused with prose (exit 1) and — the property that matters for a
|
||||
/// content-addressed store — no file is ever written under `runs/campaigns/`
|
||||
/// for it. A store that could contain unvalidated documents would make
|
||||
/// every downstream reader re-derive the checks register was supposed to
|
||||
/// have already made.
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid");
|
||||
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
|
||||
write_doc(&dir, "bad.campaign.json", &bad);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
||||
assert!(out.contains("axes.fast: an axis is a non-empty finite set"), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
!dir.join("runs").join("campaigns").exists(),
|
||||
"register must not create a store entry for an invalid document"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user