feat(research): draft placeholder refs ({} / null) read as open slots in the unwired probe
A campaign draft can now validate-so-far AND report its open ref slots
(0106 fieldtest F4): open_slots_campaign treats an absent ref, {} and
null uniformly as the open slot (strategies[i].ref with the two-arm
hint; process.ref with the content-id-only hint). Draft affordance of
the probe only — the strict parse tier keeps refusing placeholders (a
placeholder is not a valid document form; canonical bytes stay
untouched).
RED-first (tdd-author handoff): library pin + CLI --unwired e2e
observed failing (probe reported 'no open slots' over placeholder
refs), then the two ref predicates routed through ref_slot_is_open.
Gates: workspace 995/0, clippy -D warnings clean.
closes #195
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -836,6 +836,18 @@ pub fn open_slots_process(text: &str) -> Result<Vec<OpenSlot>, 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<Vec<OpenSlot>, 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<Vec<OpenSlot>, 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<Vec<OpenSlot>, 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user