qa-spec 0.5.0

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

use qa_spec::spec::form::FormPresentation;
use qa_spec::{
    QuestionSpec, QuestionType, ResolutionMode, TemplateContext, TemplateEngine,
    register_default_helpers,
};

fn build_sample_form() -> qa_spec::FormSpec {
    qa_spec::FormSpec {
        id: "sample".into(),
        title: "{{payload.title}}".into(),
        version: "1.0.0".into(),
        description: Some("desc {{payload.title}}".into()),
        presentation: Some(FormPresentation {
            intro: Some("intro {{answers.name}}".into()),
            theme: Some("theme-default".into()),
            default_locale: None,
        }),
        progress_policy: None,
        secrets_policy: None,
        store: vec![],
        validations: vec![],
        includes: vec![],
        questions: vec![QuestionSpec {
            id: "q1".into(),
            kind: QuestionType::String,
            title: "Name {{answers.name}}".into(),
            title_i18n: None,
            description: Some("desc {{payload.title}}".into()),
            description_i18n: None,
            required: true,
            choices: None,
            default_value: Some("{{default payload.default \"fallback\"}}".into()),
            secret: false,
            visible_if: None,
            constraint: None,
            list: None,
            policy: Default::default(),
            computed: None,
            computed_overridable: false,
        }],
    }
}

#[test]
fn form_spec_resolution_replaces_templates() {
    let engine = TemplateEngine::new(ResolutionMode::Strict);
    let ctx = TemplateContext::default()
        .with_payload(json!({"title": "Wizard", "default": "preset"}))
        .with_answers(json!({"name": "Greentic"}));
    let resolved = engine
        .resolve_form_spec(&build_sample_form(), &ctx)
        .expect("resolve spec");

    assert_eq!(resolved.title, "Wizard");
    assert_eq!(resolved.description.as_deref(), Some("desc Wizard"));
    let presentation = resolved.presentation.expect("presentation exists");
    assert_eq!(presentation.intro, Some("intro Greentic".into()));
    let question = &resolved.questions[0];
    assert_eq!(question.title, "Name Greentic");
    assert_eq!(question.default_value.as_deref(), Some("preset"));
}

#[test]
fn resolve_string_relaxed_keeps_missing_tokens() {
    let engine = TemplateEngine::new(ResolutionMode::Relaxed);
    let ctx = TemplateContext::default();
    let resolved = engine
        .resolve_form_spec(
            &qa_spec::FormSpec {
                id: "missing".into(),
                title: "{{payload.missing}}".into(),
                version: "1.0".into(),
                description: None,
                presentation: None,
                progress_policy: None,
                secrets_policy: None,
                store: vec![],
                validations: vec![],
                includes: vec![],
                questions: vec![],
            },
            &ctx,
        )
        .expect("resolve");
    assert_eq!(resolved.title, "{{payload.missing}}");
}

#[test]
fn default_helper_prefers_truthy_values() {
    let mut handlebars = Handlebars::new();
    register_default_helpers(&mut handlebars);
    let context = json!({"payload": {"name": "Greentic"}});
    let rendered = handlebars
        .render_template("{{default payload.name \"fallback\"}}", &context)
        .expect("rendered");
    assert_eq!(rendered, "Greentic");
}