qa-spec 0.5.0

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

use qa_spec::{
    FormSpec,
    render::{
        RenderStatus, build_render_payload, build_render_payload_with_i18n, render_card,
        render_json_ui, render_text,
    },
};

fn fixture(name: &str) -> &'static str {
    match name {
        "simple_form" => include_str!("../tests/fixtures/simple_form.json"),
        "graph_flow" => include_str!("../tests/fixtures/graph_flow.json"),
        _ => panic!("unknown fixture {}", name),
    }
}

#[test]
fn render_text_includes_next_question() {
    let spec: FormSpec = serde_json::from_str(fixture("simple_form")).expect("deserialize");
    let ctx = json!({});
    let answers = json!({});
    let payload = build_render_payload(&spec, &ctx, &answers);

    assert_eq!(payload.status, RenderStatus::NeedInput);
    assert_eq!(payload.next_question_id.as_deref(), Some("q1"));

    let text = render_text(&payload);
    assert!(text.contains("Next question"));
    assert!(text.contains("Visible questions"));
}

#[test]
fn render_json_ui_exposes_structure() {
    let spec: FormSpec = serde_json::from_str(fixture("simple_form")).expect("deserialize");
    let ctx = json!({});
    let answers = json!({ "q1": "test-corp" });
    let payload = build_render_payload(&spec, &ctx, &answers);

    let ui = render_json_ui(&payload);
    assert_eq!(ui["form_id"], "example-form");
    assert_eq!(ui["progress"]["total"], 2);
    let questions = ui["questions"].as_array().expect("questions array");
    assert!(questions.iter().any(|q| q["id"] == "q1"));
    assert!(matches!(questions[0]["visible"].as_bool(), Some(true)));
}

#[test]
fn render_card_includes_patch_action() {
    let spec: FormSpec = serde_json::from_str(fixture("simple_form")).expect("deserialize");
    let ctx = json!({});
    let answers = json!({});
    let payload = build_render_payload(&spec, &ctx, &answers);

    let card = render_card(&payload);
    assert_eq!(card["version"], "1.3");
    let actions = card["actions"].as_array().expect("actions");
    assert_eq!(actions[0]["type"], "Action.Submit");
    assert_eq!(actions[0]["data"]["qa"]["mode"], "patch");
}

#[test]
fn render_card_uses_choice_input_for_enum() {
    let spec: FormSpec = serde_json::from_value(json!({
        "id": "enum-form",
        "title": "Enum Form",
        "version": "1.0",
        "questions": [
            {
                "id": "q_enum",
                "type": "enum",
                "title": "Choose option",
                "choices": ["alpha", "beta"],
                "required": true
            }
        ]
    }))
    .expect("deserialize");
    let ctx = json!({});
    let answers = json!({ "q1": "example-q1" });
    let payload = build_render_payload(&spec, &ctx, &answers);

    let card = render_card(&payload);
    let body = card["body"].as_array().expect("body");
    let container = body
        .iter()
        .find(|item| item["type"] == "Container")
        .expect("question container");
    let items = container["items"].as_array().expect("items");
    assert!(
        items
            .iter()
            .any(|item| item["type"].as_str() == Some("Input.ChoiceSet"))
    );
}

#[test]
fn render_payload_uses_resolved_i18n_when_provided() {
    let spec: FormSpec = serde_json::from_value(json!({
        "id": "i18n-form",
        "title": "I18n Form",
        "version": "1.0",
        "questions": [
            {
                "id": "q1",
                "type": "string",
                "title": "Fallback title",
                "title_i18n": { "key": "q1.title" },
                "description_i18n": { "key": "q1.description" },
                "required": true
            }
        ]
    }))
    .expect("deserialize");
    let mut resolved = BTreeMap::new();
    resolved.insert("q1.title".into(), "Localized title".into());
    resolved.insert("q1.description".into(), "Localized description".into());

    let payload = build_render_payload_with_i18n(&spec, &json!({}), &json!({}), Some(&resolved));
    let question = payload.questions.first().expect("question exists");
    assert_eq!(question.title, "Localized title");
    assert_eq!(
        question.description.as_deref(),
        Some("Localized description")
    );
}

#[test]
fn render_payload_uses_requested_then_default_locale_then_raw_fallback() {
    let spec: FormSpec = serde_json::from_value(json!({
        "id": "locale-form",
        "title": "Locale Form",
        "version": "1.0",
        "presentation": {
            "default_locale": "pt-BR"
        },
        "questions": [
            {
                "id": "q1",
                "type": "string",
                "title": "Raw fallback",
                "title_i18n": { "key": "q1.title" },
                "required": true
            }
        ]
    }))
    .expect("deserialize");
    let mut resolved = BTreeMap::new();
    resolved.insert("en-US:q1.title".into(), "English title".into());
    resolved.insert("pt-BR:q1.title".into(), "Titulo".into());

    let payload_en = build_render_payload_with_i18n(
        &spec,
        &json!({ "locale": "en-US" }),
        &json!({}),
        Some(&resolved),
    );
    assert_eq!(payload_en.questions[0].title, "English title");

    let payload_default =
        build_render_payload_with_i18n(&spec, &json!({}), &json!({}), Some(&resolved));
    assert_eq!(payload_default.questions[0].title, "Titulo");

    let payload_raw = build_render_payload_with_i18n(
        &spec,
        &json!({ "locale": "fr-FR" }),
        &json!({}),
        Some(&BTreeMap::new()),
    );
    assert_eq!(payload_raw.questions[0].title, "Raw fallback");
}