Compare commits
6 Commits
da37f596f2
...
fb28b831f3
| Author | SHA1 | Date | |
|---|---|---|---|
| fb28b831f3 | |||
| 6b994cead3 | |||
| a5ca0e7481 | |||
| edb8c37916 | |||
| 0a70f67711 | |||
| 27ace153f2 |
@@ -250,6 +250,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"process register needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let doc = parse_valid_process(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = process_to_json(&doc);
|
||||
@@ -262,6 +271,28 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve `id` against a document store's full candidate content-id list —
|
||||
/// mirroring the exact-first / unique-match / else-refuse shape
|
||||
/// `aura_runner::reproduce::load_family` established for #298's family/run
|
||||
/// handle. Called only once the caller's own exact-match lookup has already
|
||||
/// missed, so a filter match here is always a strict, shorter prefix (ids
|
||||
/// are fixed-length hashes; a full-length miss cannot be a prefix of
|
||||
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
|
||||
/// caller's existing not-found prose applies unchanged. `Err`: two or more
|
||||
/// candidates share the prefix — refused by name, listing every candidate,
|
||||
/// rather than guessed.
|
||||
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
|
||||
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
|
||||
match matches.as_slice() {
|
||||
[] => Ok(None),
|
||||
[only] => Ok(Some((*only).clone())),
|
||||
many => {
|
||||
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
|
||||
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
@@ -273,15 +304,23 @@ fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
||||
));
|
||||
}
|
||||
let registry = env.registry();
|
||||
match registry.get_process(id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
// `print!`, not `println!`: `bytes` is the content-addressed canonical
|
||||
// form the store keyed `id` on — the CLI is a transport and must not
|
||||
// frame the canonical bytes with a trailing newline (#164, mirroring
|
||||
// `graph_construct::build_cmd`).
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
|
||||
// `print!`, not `println!`: `bytes` is the content-addressed canonical
|
||||
// form the store keyed `id` on — the CLI is a transport and must not
|
||||
// frame the canonical bytes with a trailing newline (#164, mirroring
|
||||
// `graph_construct::build_cmd`).
|
||||
print!("{bytes}");
|
||||
return Ok(());
|
||||
}
|
||||
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(id, &candidates)? {
|
||||
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
|
||||
},
|
||||
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
|
||||
}
|
||||
}
|
||||
@@ -383,6 +422,14 @@ fn prefix_hint(id: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault`
|
||||
/// sibling (unlike processes' `ref_fault_prose`), so this is the single
|
||||
/// source both the exact-miss and prefix-resolved-but-then-missing arms
|
||||
/// route through, keeping the two copies from drifting.
|
||||
fn campaign_not_found_prose(id: &str) -> String {
|
||||
format!("campaign {id} not found in the project store{}", prefix_hint(id))
|
||||
}
|
||||
|
||||
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
match f {
|
||||
RefFault::ProcessNotFound(id) => {
|
||||
@@ -580,6 +627,15 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"campaign register needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let doc = parse_valid_campaign(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = campaign_to_json(&doc);
|
||||
@@ -603,14 +659,22 @@ fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
|
||||
));
|
||||
}
|
||||
let registry = env.registry();
|
||||
match registry.get_campaign(id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
// `print!`, not `println!`: see `show_process` — the CLI must not
|
||||
// frame the canonical bytes with a trailing newline (#164).
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(format!("campaign {id} not found in the project store{}", prefix_hint(id))),
|
||||
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
|
||||
// `print!`, not `println!`: see `show_process` — the CLI must not
|
||||
// frame the canonical bytes with a trailing newline (#164).
|
||||
print!("{bytes}");
|
||||
return Ok(());
|
||||
}
|
||||
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(id, &candidates)? {
|
||||
Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(campaign_not_found_prose(id)),
|
||||
},
|
||||
None => Err(campaign_not_found_prose(id)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,4 +861,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #302: the ambiguity arm of `resolve_id_prefix` — content ids are
|
||||
/// hash-derived, not choosable, so two documents sharing a printed
|
||||
/// prefix cannot be constructed honestly through an E2E fixture
|
||||
/// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the
|
||||
/// unique-match arm end-to-end instead). This unit test is the only pin
|
||||
/// the ambiguous-refusal arm gets: a prefix shared by more than one
|
||||
/// candidate refuses rather than guesses, and the refusal names every
|
||||
/// sharing candidate so the author can pick the intended one.
|
||||
#[test]
|
||||
fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() {
|
||||
let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()];
|
||||
|
||||
let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse");
|
||||
assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}");
|
||||
assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}");
|
||||
assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}");
|
||||
|
||||
// Companion sanity for the two other arms at the same seam: a unique
|
||||
// prefix resolves, and no matching prefix reports as unresolved
|
||||
// rather than fabricating one.
|
||||
assert_eq!(
|
||||
resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"),
|
||||
Some("9f3a0000".to_string())
|
||||
);
|
||||
assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6707,3 +6707,86 @@ fn show_is_scoped_to_its_own_document_store() {
|
||||
"campaign show stderr: {campaign_stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#302): `show` mirrors the prefix resolution `reproduce` gained
|
||||
/// with #298 — a unique prefix of a registered content id, in the 8-hex form
|
||||
/// every surface prints (family ids, run records), resolves to the registered
|
||||
/// document, and `show` prints EXACTLY the bytes the full-id invocation
|
||||
/// prints (the #164 byte-discipline: the stored canonical bytes, no framing).
|
||||
/// One document per store makes any prefix of its id unique by construction.
|
||||
/// Deliberately NOT pinned here: the ambiguous-prefix refusal (listing the
|
||||
/// candidate ids) — content ids are hash-derived, not choosable, so two
|
||||
/// documents sharing a printed prefix cannot be constructed honestly at this
|
||||
/// layer; that arm belongs beside the resolution seam, where candidate id
|
||||
/// lists are plain strings.
|
||||
#[test]
|
||||
fn show_resolves_a_unique_prefix_to_the_full_id_bytes() {
|
||||
let (cwd, _g) = fresh_project();
|
||||
|
||||
fn aura(cwd: &Path, args: &[&str]) -> std::process::Output {
|
||||
Command::new(BIN).args(args).current_dir(cwd).output().expect("spawn aura")
|
||||
}
|
||||
|
||||
const PREFIX_PROBE_PROCESS: &str = r#"{"format_version":1,"kind":"process","name":"prefix-probe","pipeline":[{"block":"std::sweep","metric":"sqn_normalized","select":"argmax"}]}"#;
|
||||
const PREFIX_PROBE_CAMPAIGN: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "prefix-probe",
|
||||
"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"] }
|
||||
}"#;
|
||||
|
||||
for (verb, file, doc) in [
|
||||
("process", "probe.process.json", PREFIX_PROBE_PROCESS),
|
||||
("campaign", "probe.campaign.json", PREFIX_PROBE_CAMPAIGN),
|
||||
] {
|
||||
std::fs::write(cwd.join(file), doc).expect("write document");
|
||||
let register = aura(&cwd, &[verb, "register", file]);
|
||||
assert_eq!(
|
||||
register.status.code(),
|
||||
Some(0),
|
||||
"{verb} register must succeed: {}",
|
||||
String::from_utf8_lossy(®ister.stderr)
|
||||
);
|
||||
let register_out = String::from_utf8_lossy(®ister.stdout).into_owned();
|
||||
let marker = format!("registered {verb} ");
|
||||
let id = register_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&marker))
|
||||
.expect("register line")
|
||||
.trim_start_matches(marker.as_str())
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("content id")
|
||||
.to_string();
|
||||
assert!(id.len() > 8, "content id longer than the printed 8-hex prefix form: {id}");
|
||||
|
||||
let full = aura(&cwd, &[verb, "show", &id]);
|
||||
assert_eq!(
|
||||
full.status.code(),
|
||||
Some(0),
|
||||
"{verb} show <full id> must succeed: {}",
|
||||
String::from_utf8_lossy(&full.stderr)
|
||||
);
|
||||
assert!(!full.stdout.is_empty(), "{verb} show <full id> prints the document bytes");
|
||||
|
||||
// The headline: the 8-hex prefix — unique in this one-document store —
|
||||
// resolves, and the output is byte-identical to the full-id output.
|
||||
let prefix = &id[..8];
|
||||
let short = aura(&cwd, &[verb, "show", prefix]);
|
||||
assert_eq!(
|
||||
short.status.code(),
|
||||
Some(0),
|
||||
"{verb} show {prefix} (unique prefix of {id}) must resolve: {}",
|
||||
String::from_utf8_lossy(&short.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
short.stdout, full.stdout,
|
||||
"{verb} show via unique prefix must print the identical document bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,8 @@ fn process_introspect_metrics_plus_another_mode_is_usage_exit_2() {
|
||||
|
||||
#[test]
|
||||
fn process_register_stores_content_addressed_under_runs_root() {
|
||||
let dir = temp_cwd("process-register");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
write_doc(&dir, "p.process.json", PROCESS_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
@@ -596,6 +597,42 @@ fn campaign_validate_outside_project_skips_referential_tier() {
|
||||
assert!(out.contains("referential checks skipped (no Aura.toml found up from "));
|
||||
}
|
||||
|
||||
/// #305: `register` outside a project (no Aura.toml up from the cwd) refuses
|
||||
/// up front — exit 1, prose naming the invoked verb and the missing Aura.toml
|
||||
/// (the honest-degradation guard family `show`, `campaign runs`, and the
|
||||
/// dissolved run verbs already share) — and leaves NO store. Today the missing
|
||||
/// `env.provenance()` guard lets `Env::registry()`'s relative `runs/` fallback
|
||||
/// silently materialise `./runs/` in the innocent cwd and register the
|
||||
/// document into a store no project owns, exit 0.
|
||||
#[test]
|
||||
fn register_outside_a_project_refuses_and_leaves_no_store() {
|
||||
for (verb, file, doc) in [
|
||||
("process", "p.process.json", PROCESS_DOC),
|
||||
("campaign", "c.campaign.json", CAMPAIGN_DOC),
|
||||
] {
|
||||
let dir = temp_cwd(&format!("{verb}-register-outside-project"));
|
||||
write_doc(&dir, file, doc);
|
||||
let (out, code) = run_code_in(&dir, &[verb, "register", file]);
|
||||
assert_eq!(
|
||||
code,
|
||||
Some(1),
|
||||
"{verb} register outside a project must refuse; stdout/stderr: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(&format!("{verb} register needs a project")),
|
||||
"{verb} register's refusal must name the invoked verb: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("no Aura.toml found up from"),
|
||||
"{verb} register's refusal must name the missing Aura.toml: {out}"
|
||||
);
|
||||
assert!(
|
||||
!dir.join("runs").exists(),
|
||||
"{verb} register refused outside a project must leave no ./runs/ store"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_validate_refuses_empty_axis_prose_exit_1() {
|
||||
let dir = temp_cwd("campaign-validate-empty-axis");
|
||||
@@ -1031,7 +1068,8 @@ fn campaign_introspect_content_id_prints_hash() {
|
||||
|
||||
#[test]
|
||||
fn campaign_register_stores_content_addressed_under_runs_root() {
|
||||
let dir = temp_cwd("campaign-register");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
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}");
|
||||
@@ -1166,7 +1204,8 @@ fn wrong_kind_refusal_names_the_kind_key() {
|
||||
/// have already made.
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
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"]);
|
||||
@@ -1187,7 +1226,8 @@ fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||||
/// would silently persist an unenforceable risk regime.
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid-risk");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let bad = CAMPAIGN_DOC.replacen(
|
||||
"\"seed\": 1,",
|
||||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 0, \"k\": 2.0 } } ],",
|
||||
@@ -1214,7 +1254,8 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
||||
/// precedent).
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid-cost");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let bad = CAMPAIGN_DOC.replacen(
|
||||
"\"seed\": 1,",
|
||||
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
|
||||
|
||||
@@ -189,6 +189,28 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every content id currently stored for one document kind — the
|
||||
/// filename stems under `doc_dir(kind)` (`doc_path`'s write-one/read-one
|
||||
/// mapping, read back), for prefix resolution (#302). A missing store
|
||||
/// dir (nothing registered yet) is treat-as-empty, not an error, the
|
||||
/// same discipline `find_blueprint_by_identity`'s scan applies to a
|
||||
/// missing blueprints dir.
|
||||
fn list_doc_ids(&self, kind: &str) -> Result<Vec<String>, RegistryError> {
|
||||
let entries = match fs::read_dir(self.doc_dir(kind)) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(RegistryError::Io(e)),
|
||||
};
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
if let Some(id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Store a canonical process document; returns its content id.
|
||||
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
||||
self.put_doc("processes", canonical_json)
|
||||
@@ -199,6 +221,12 @@ impl Registry {
|
||||
self.get_doc("processes", content_id)
|
||||
}
|
||||
|
||||
/// Every content id currently registered in the process store — the
|
||||
/// candidate list `show`'s prefix resolution (#302) filters against.
|
||||
pub fn list_process_ids(&self) -> Result<Vec<String>, RegistryError> {
|
||||
self.list_doc_ids("processes")
|
||||
}
|
||||
|
||||
/// Store a canonical campaign document; returns its content id.
|
||||
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
||||
self.put_doc("campaigns", canonical_json)
|
||||
@@ -209,6 +237,12 @@ impl Registry {
|
||||
self.get_doc("campaigns", content_id)
|
||||
}
|
||||
|
||||
/// Every content id currently registered in the campaign store — the
|
||||
/// candidate list `show`'s prefix resolution (#302) filters against.
|
||||
pub fn list_campaign_ids(&self) -> Result<Vec<String>, RegistryError> {
|
||||
self.list_doc_ids("campaigns")
|
||||
}
|
||||
|
||||
/// The store path a process document with this content id lives at —
|
||||
/// the same single mapping the put/get pair routes through, exposed so
|
||||
/// consumers never re-derive the layout.
|
||||
|
||||
@@ -295,10 +295,17 @@ fn select_from(
|
||||
) -> Result<SelectRule, String> {
|
||||
let s = require_str(m, "select", block)?;
|
||||
// SelectRule's own derived Deserialize (which carries the #[serde(rename)]
|
||||
// wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare:
|
||||
// no second hardcoded copy of the three select-rule wire strings.
|
||||
serde_json::from_value(serde_json::Value::String(s.clone()))
|
||||
.map_err(|_| format!("block {block}: unknown select rule \"{s}\""))
|
||||
// wire strings) is the type oracle for *parsing*, mirroring
|
||||
// kind_tag/scalar_from_bare. The refusal's roster text below is sourced
|
||||
// from slot_kind_label(SlotKind::SelectRule) rather than a second
|
||||
// hardcoded copy of the three wire strings: CLI describe output and the
|
||||
// document refusal stay in sync by construction (add a SelectRule
|
||||
// variant, update one string).
|
||||
serde_json::from_value(serde_json::Value::String(s.clone())).map_err(|_| {
|
||||
let roster =
|
||||
slot_kind_label(SlotKind::SelectRule).trim_start_matches("select rule: ");
|
||||
format!("block {block}: unknown select rule \"{s}\" (accepted: {roster})")
|
||||
})
|
||||
}
|
||||
|
||||
fn mode_from(
|
||||
@@ -1381,6 +1388,52 @@ mod tests {
|
||||
assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_))));
|
||||
}
|
||||
|
||||
/// #301 (fieldtest cycle-300 df_3): the unknown-select-rule refusal must
|
||||
/// teach the accepted vocabulary, not just refuse — an author who learned
|
||||
/// bare `plateau` at the CLI pastes it into a document and needs the
|
||||
/// roster (argmax | plateau:mean | plateau:worst) in the refusal prose to
|
||||
/// self-serve the fix. The schema stays strict: bare "plateau" remains
|
||||
/// refused in documents (#300 F7 — it is a CLI-only argv alias), and the
|
||||
/// prose stays sparse — the roster only, no alias-bridging sentence.
|
||||
#[test]
|
||||
fn unknown_select_rule_refusal_enumerates_accepted_rules() {
|
||||
let bare_plateau = PROCESS_FIXTURE.replacen("plateau:worst", "plateau", 1);
|
||||
let err =
|
||||
parse_process(&bare_plateau).expect_err("bare plateau stays refused in documents");
|
||||
let DocError::Malformed(msg) = err else {
|
||||
panic!("expected DocError::Malformed, got {err:?}");
|
||||
};
|
||||
// The existing contract survives: the offending value stays named, quoted.
|
||||
assert!(
|
||||
msg.contains("unknown select rule \"plateau\""),
|
||||
"offending value no longer named: {msg}"
|
||||
);
|
||||
// The new behaviour: the refusal enumerates the accepted rules.
|
||||
for accepted in ["argmax", "plateau:mean", "plateau:worst"] {
|
||||
assert!(
|
||||
msg.contains(accepted),
|
||||
"accepted rule {accepted:?} not enumerated in the refusal: {msg}"
|
||||
);
|
||||
}
|
||||
// Precise and sparse: the roster alone rescues the paste case — no
|
||||
// alias-hint sentence bridging plateau -> plateau:mean.
|
||||
assert!(!msg.contains("alias"), "refusal grew an alias hint: {msg}");
|
||||
}
|
||||
|
||||
/// The refusal derives its roster from slot_kind_label by trimming the
|
||||
/// "select rule: " prefix. That trim is a positional contract: if the
|
||||
/// label's wording ever drifts, trim_start_matches degrades to a no-op
|
||||
/// and the refusal grows a redundant prefix without any test noticing.
|
||||
/// This pin makes that drift loud.
|
||||
#[test]
|
||||
fn select_rule_label_keeps_the_prefix_the_refusal_trims() {
|
||||
assert!(
|
||||
slot_kind_label(SlotKind::SelectRule).starts_with("select rule: "),
|
||||
"slot_kind_label(SelectRule) no longer starts with \"select rule: \" — \
|
||||
update select_from's roster derivation alongside the label"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
|
||||
Reference in New Issue
Block a user