diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 9890c87..9b481dc 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -421,6 +421,33 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() { assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)")); } +/// #195 (fieldtest 0106 F4): the consumer view of the placeholder-ref probe. +/// A draft that spells its open refs as `"ref": {}` / `"ref": null` — the +/// natural drafting placeholders — has `--unwired` enumerate BOTH as open slots +/// (exit 0), instead of the old cryptic serde refusal / silent "no open slots". +#[test] +fn campaign_introspect_unwired_reports_placeholder_refs() { + let dir = temp_cwd("campaign-introspect-unwired-placeholder"); + let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", + "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, + "strategies": [ { "ref": null, + "axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ], + "process": { "ref": {} }, + "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), "stdout/stderr: {out}"); + assert!( + out.contains("open slot: strategies[0].ref (required, one of: content_id, identity_id)"), + "strategy `ref: null` placeholder not enumerated: {out}" + ); + assert!( + out.contains("open slot: process.ref (required, content id of a process document)"), + "process `ref: {{}}` placeholder not enumerated: {out}" + ); +} + /// 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 diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index c79dce3..da5fb75 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -836,6 +836,18 @@ pub fn open_slots_process(text: &str) -> Result, DocError> { } /// List the open slots of a (possibly partial) campaign document. +/// Whether a `ref` slot in a DRAFT reads as open: absent, or one of the two +/// natural placeholder spellings `{}` / `null` (#195). A draft affordance of +/// the open-slot probe only — the strict parse tier keeps refusing +/// placeholders (they are not a valid document form). +fn ref_slot_is_open(r: Option<&serde_json::Value>) -> bool { + match r { + None | Some(serde_json::Value::Null) => true, + Some(serde_json::Value::Object(m)) => m.is_empty(), + Some(_) => false, + } +} + pub fn open_slots_campaign(text: &str) -> Result, DocError> { let v: serde_json::Value = serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?; @@ -862,7 +874,7 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { } Some(list) => { for (i, s) in list.iter().enumerate() { - if s.get("ref").is_none() { + if ref_slot_is_open(s.get("ref")) { slots.push(open( format!("strategies[{i}].ref"), "required, one of: content_id, identity_id", @@ -891,7 +903,7 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { } } } - if v.get("process").and_then(|p| p.get("ref")).is_none() { + if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) { // deliberately narrower than the strategy-ref hint: validate_campaign // refuses an identity_id process ref, so the guide must not offer it slots.push(open("process.ref", "required, content id of a process document")); @@ -1402,4 +1414,38 @@ mod tests { assert!(matches!(open_slots_process("nope"), Err(DocError::NotJson(_)))); } + + /// Property: in the open-slot probe, a ref placeholder — `"ref": {}` or + /// `"ref": null` — is a drafting affordance that reports the SAME open slot + /// (same path, same existing hint) the omitted-key form reports, on both a + /// strategy entry and `process.ref`, and never aborts the probe. It lets one + /// draft file both validate-so-far and enumerate its open slots (fieldtest + /// 0106 F4 / #195). This pins only the probe; the strict `parse_campaign` + /// tier keeps refusing placeholders (canonical-form byte stability outranks + /// draft leniency) and is deliberately not exercised here. + #[test] + fn open_slots_report_placeholder_refs_as_open_slots() { + let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", + "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, + "strategies": [ { "ref": null, + "axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ], + "process": { "ref": {} }, + "seed": 1, + "presentation": { "persist_taps": [], "emit": [] } }"#; + // Errors on neither placeholder form (the probe stays a probe). + let slots = open_slots_campaign(draft).expect("placeholder refs must not abort the probe"); + // The `null` strategy ref reports the strategy-ref open slot with its + // existing both-id-forms hint... + assert!( + slots.iter().any(|s| s.path == "strategies[0].ref" + && s.hint == "required, one of: content_id, identity_id"), + "strategy `ref: null` placeholder not reported as an open slot: {slots:?}" + ); + // ...and the `{}` process ref reports the narrower content-id-only hint. + assert!( + slots.iter().any(|s| s.path == "process.ref" + && s.hint == "required, content id of a process document"), + "process `ref: {{}}` placeholder not reported as an open slot: {slots:?}" + ); + } }