qa-spec 0.5.0

Core QA specification models and validation utilities.
Documentation
use serde_json::json;

use qa_spec::{
    FormSpec, StoreContext, execute_plan_effects, plan_next, plan_submit_all, plan_submit_patch,
};

fn planning_fixture() -> FormSpec {
    serde_json::from_value(json!({
        "id": "runner-form",
        "title": "Runner",
        "version": "1.0",
        "questions": [
            { "id": "q1", "type": "string", "title": "Question 1", "required": true }
        ],
        "store": [
            { "target": "state", "path": "/applied", "value": true }
        ]
    }))
    .expect("fixture should deserialize")
}

#[test]
fn plan_submit_patch_is_pure_and_collects_effects() {
    let spec = planning_fixture();
    let ctx = json!({ "state": {} });
    let answers = json!({});

    let invalid = plan_submit_patch(&spec, &ctx, &answers, "q1", json!(true));
    assert_eq!(invalid.plan_version, 1);
    assert_eq!(invalid.form_id, "runner-form");
    assert!(!invalid.validation.valid);
    assert!(invalid.effects.is_empty());
    assert!(!invalid.errors.is_empty());

    let valid = plan_submit_patch(&spec, &ctx, &answers, "q1", json!("ok"));
    assert!(valid.validation.valid);
    assert_eq!(valid.validated_patch["q1"], "ok");
    assert_eq!(valid.effects.len(), 1);
    assert!(valid.errors.is_empty());
}

#[test]
fn plan_next_builds_a_plan_without_side_effect_application() {
    let spec = planning_fixture();
    let ctx = json!({});
    let next_plan = plan_next(&spec, &ctx, &json!({ "q1": "present" }));
    assert_eq!(next_plan.plan_version, 1);
    assert!(next_plan.validation.valid);
    assert_eq!(next_plan.validated_patch["q1"], "present");
}

#[test]
fn execute_plan_effects_applies_only_for_valid_plan() {
    let spec = planning_fixture();
    let ctx = json!({ "state": {} });

    let invalid_plan = plan_submit_all(&spec, &ctx, &json!({}));
    let mut invalid_store = StoreContext::from_value(&ctx);
    execute_plan_effects(
        &invalid_plan,
        &mut invalid_store,
        spec.secrets_policy.as_ref(),
        false,
    )
    .expect("invalid plan should be a no-op");
    assert!(invalid_store.state.get("applied").is_none());

    let valid_plan = plan_submit_all(&spec, &ctx, &json!({ "q1": "done" }));
    let mut valid_store = StoreContext::from_value(&ctx);
    execute_plan_effects(
        &valid_plan,
        &mut valid_store,
        spec.secrets_policy.as_ref(),
        false,
    )
    .expect("valid plan should apply effects");
    assert_eq!(valid_store.state["applied"], true);
}