Skip to main content

headless_lms_chatbot/
chart_spec_generation.rs

1use std::sync::OnceLock;
2
3use crate::{
4    azure_chatbot::azure::protocol::{
5        InputItem, LLMRequest, LLMRequestParams, LLMRequestResponseFormatParam, NonThinkingParams,
6        RequestTextOptions, ThinkingParams,
7    },
8    chatbot_error::chatbot_err,
9    content_cleaner::calculate_safe_token_limit,
10    llm_utils::{
11        APIInputMessage, MessageContent, estimate_tokens, make_blocking_llm_request,
12        model_is_thinking, parse_text_completion,
13    },
14    prelude::{ChatbotError, ChatbotErrorType, ChatbotResult},
15};
16use headless_lms_base::config::ApplicationConfiguration;
17use headless_lms_base::error::backend_error::BackendError;
18use headless_lms_models::{
19    application_task_default_language_models::TaskLMSpec,
20    chatbot_conversation_message_messages::MessageRole,
21};
22use headless_lms_utils::url_encoding::percent_encode_fragment;
23
24/// Vendored copy of https://vega.github.io/schema/vega-lite/v6.json, used to validate
25/// generated specs without network access. All of its `$ref`s are internal.
26static VEGA_LITE_SCHEMA_JSON: &str = include_str!("../schemas/vega-lite-v6.json");
27
28/// How many validation attempts the model gets in total (first answer + repair rounds).
29const MAX_GENERATION_ATTEMPTS: usize = 2;
30/// Caps for how much validation detail is echoed back to the model and the teacher; union
31/// schemas like Vega-Lite's can produce very many, very long errors.
32const MAX_REPORTED_VALIDATION_ERRORS: usize = 5;
33const MAX_VALIDATION_ERROR_CHARS: usize = 300;
34
35/// System prompt for generating a Vega-Lite chart specification for the CMS chart block.
36const SYSTEM_PROMPT: &str = r#"You are helping course staff create a chart for course materials.
37
38Your task is to produce a single, complete, valid Vega-Lite specification based on the teacher's request.
39
40Rules:
41- Set "$schema" to "https://vega.github.io/schema/vega-lite/v6.json".
42- Never include a "data" or "datasets" property anywhere in the specification, and never use a data generator such as "sequence", "graticule" or "sphere". The teacher's data file is attached to your specification afterwards, so a chart that carried data of its own would be showing invented numbers as course material.
43- When a data sample is provided, use the field names exactly as they appear in the sample and pick encoding types (quantitative, nominal, ordinal, temporal) that match the sampled values.
44- Include a concise "description" field that describes the chart for screen readers.
45- Write every human-readable piece of text in the chart in the target language given below: the "description", the chart "title", and every axis, legend, and encoding "title". Keep field names and other data values unchanged. If no target language is given, use the language of the teacher's request.
46- Do not set "width", "height", or "autosize"; the rendering environment controls sizing.
47- If an existing specification is provided, treat the request as an edit to it: change only what the request requires and preserve the rest.
48- Prefer simple, readable charts over decorative complexity.
49
50Your whole answer must be the Vega-Lite specification as a JSON object, with no wrapper object, no explanation and no code fences."#;
51
52/// User prompt prefix. Also how the test-mode mock Azure API recognises a request from this
53/// feature, since a JSON-mode request carries no schema name to identify it by.
54pub const USER_PROMPT_PREFIX: &str = "Create or edit a Vega-Lite chart specification according to the request below. Return the specification itself as JSON and nothing else.";
55
56/// Introduces the sample of the teacher's data file. Also how the test-mode mock Azure API finds
57/// the sample, which it needs to answer with the field names the teacher's file actually has.
58pub const DATA_SAMPLE_HEADING: &str = "\n\nSample of the data file contents:\n";
59
60/// Introduces the specification being edited, and so also ends [`DATA_SAMPLE_HEADING`]'s section.
61pub const EXISTING_SPEC_HEADING: &str = "\n\nExisting specification to edit:\n";
62
63/// The format the chart spec LLM is asked to answer in.
64///
65/// A plain JSON object rather than a named schema: Azure's structured output accepts only a
66/// restricted subset of JSON Schema, which cannot describe a Vega-Lite specification. Handing the
67/// specification back as the whole answer means the model writes it in the shape it is written in
68/// everywhere else, and that the answer is guaranteed to parse.
69fn response_format() -> LLMRequestResponseFormatParam {
70    LLMRequestResponseFormatParam::JsonObject
71}
72
73/// Input payload for chart spec generation.
74pub struct ChartSpecGenerationInput {
75    pub prompt: String,
76    pub current_spec: Option<String>,
77    pub data_url: Option<String>,
78    pub data_format: Option<String>,
79    pub data_sample: Option<String>,
80    /// Language for all human-readable chart text (a BCP 47 code, e.g. the course's `language_code`).
81    pub language: Option<String>,
82}
83
84/// Percent-encodes every `$ref` string in the schema.
85///
86/// Vega-Lite definition names (e.g. `MarkPropDef<(Gradient|string|null)>`) contain characters
87/// RFC 3986 forbids in a URI fragment, and the validator's meta-schema check rejects `$ref`s
88/// pointing at them. Fragments are percent-decoded before JSON-pointer evaluation, so encoded
89/// refs still resolve to the original definition keys.
90fn sanitize_refs(value: &mut serde_json::Value) {
91    match value {
92        serde_json::Value::Object(map) => {
93            for (key, entry) in map.iter_mut() {
94                match entry {
95                    serde_json::Value::String(reference) if key == "$ref" => {
96                        *reference = percent_encode_fragment(reference);
97                    }
98                    // A `$ref` key whose value isn't a string is an ordinary schema node (e.g. a
99                    // property literally named `$ref`), so keep descending into it.
100                    _ => sanitize_refs(entry),
101                }
102            }
103        }
104        serde_json::Value::Array(items) => items.iter_mut().for_each(sanitize_refs),
105        _ => {}
106    }
107}
108
109/// The Vega-Lite validator built from the vendored schema; built once per process.
110fn vega_lite_validator() -> ChatbotResult<&'static jsonschema::Validator> {
111    static VALIDATOR: OnceLock<Result<jsonschema::Validator, String>> = OnceLock::new();
112    VALIDATOR
113        .get_or_init(|| {
114            let mut schema: serde_json::Value =
115                serde_json::from_str(VEGA_LITE_SCHEMA_JSON).map_err(|e| e.to_string())?;
116            sanitize_refs(&mut schema);
117            jsonschema::validator_for(&schema).map_err(|e| e.to_string())
118        })
119        .as_ref()
120        .map_err(|e| {
121            chatbot_err!(
122                ChatbotMessageSuggestError,
123                format!("The bundled Vega-Lite schema could not be loaded: {e}")
124            )
125        })
126}
127
128/// Names the first data source anywhere in the spec, or None when it carries none.
129///
130/// The model is not allowed to supply data at all -- [attach_data_file] adds the teacher's file
131/// afterwards -- so any data source here is one the model produced. Enforced in code rather than
132/// left to the prompt, because a chart is read as fact in course material and numbers invented to
133/// satisfy the request would be misinformation. Recursive because Vega-Lite allows `data` on any
134/// view and inside transforms, so a layered or concatenated chart can carry it far from the top
135/// level.
136fn find_data_source(value: &serde_json::Value) -> Option<&'static str> {
137    match value {
138        serde_json::Value::Object(fields) => {
139            if fields.contains_key("datasets") {
140                return Some("a \"datasets\" section");
141            }
142            if fields.contains_key("data") {
143                return Some("a \"data\" property");
144            }
145            fields.values().find_map(find_data_source)
146        }
147        serde_json::Value::Array(items) => items.iter().find_map(find_data_source),
148        _ => None,
149    }
150}
151
152/// The data file a chart reads, as the request named it.
153#[derive(Clone, Copy)]
154pub struct DataFile<'a> {
155    pub url: &'a str,
156    pub format: Option<&'a str>,
157}
158
159/// Stands in for the teacher's file while a spec that has none is validated. The Vega-Lite schema
160/// requires a data source on a top-level view, so a chart still waiting for its file could not be
161/// checked at all otherwise. Never leaves [parse_and_validate_spec].
162const PLACEHOLDER_DATA_FILE: DataFile<'static> = DataFile {
163    url: "chart-data",
164    format: None,
165};
166
167/// The spec with every data source taken out, or None when it isn't parseable JSON.
168///
169/// The model is asked to return no data, so the specification it edits is sent without any: it then
170/// has nothing to copy forward, and an inline dataset in the teacher's spec does not eat the
171/// context window either.
172fn without_data(spec_string: &str) -> Option<String> {
173    let mut spec: serde_json::Value = serde_json::from_str(spec_string).ok()?;
174    remove_data_sources(&mut spec);
175    serde_json::to_string_pretty(&spec).ok()
176}
177
178fn remove_data_sources(value: &mut serde_json::Value) {
179    match value {
180        serde_json::Value::Object(fields) => {
181            fields.remove("data");
182            fields.remove("datasets");
183            fields.values_mut().for_each(remove_data_sources);
184        }
185        serde_json::Value::Array(items) => items.iter_mut().for_each(remove_data_sources),
186        _ => {}
187    }
188}
189
190/// Points the spec at the data file the request named.
191///
192/// The chart then reads exactly the file the teacher attached, whether the model wrote the spec
193/// from scratch or repaired one that had drifted. Sub-views inherit a top-level data source, so
194/// this reaches every view of a layered or concatenated chart too.
195fn attach_data_file(spec: &mut serde_json::Value, url: &str, format: Option<&str>) {
196    let Some(fields) = spec.as_object_mut() else {
197        return;
198    };
199    let mut data = serde_json::Map::new();
200    data.insert(
201        "url".to_string(),
202        serde_json::Value::String(url.to_string()),
203    );
204    if let Some(format) = format {
205        data.insert("format".to_string(), serde_json::json!({ "type": format }));
206    }
207    fields.insert("data".to_string(), serde_json::Value::Object(data));
208}
209
210/// How many levels of union to follow when explaining why a specification was rejected.
211const MAX_UNION_DEPTH: usize = 4;
212
213/// The branch errors of a union keyword, if this failure is one.
214fn union_branches<'a>(
215    error: &'a jsonschema::ValidationError<'_>,
216) -> Option<&'a Vec<Vec<jsonschema::ValidationError<'static>>>> {
217    match error.kind() {
218        jsonschema::error::ValidationErrorKind::AnyOf { context }
219        | jsonschema::error::ValidationErrorKind::OneOfNotValid { context } => Some(context),
220        _ => None,
221    }
222}
223
224/// Describes a validation failure in terms a model can act on, into `out`.
225///
226/// The Vega-Lite schema is one union of every kind of chart, so any mistake anywhere is reported at
227/// the root as "not valid under any of the schemas listed in the 'anyOf' keyword", naming neither
228/// the property nor its location. The branch that produced the fewest errors is the kind of chart
229/// the specification was trying to be, so following it -- and any union nested inside it -- reaches
230/// the property that is actually wrong.
231fn describe_validation_error(
232    error: &jsonschema::ValidationError<'_>,
233    depth: usize,
234    out: &mut Vec<String>,
235) {
236    let closest = union_branches(error).and_then(|branches| {
237        branches
238            .iter()
239            .filter(|branch| !branch.is_empty())
240            .min_by_key(|branch| branch.len())
241    });
242    if let (true, Some(closest)) = (depth < MAX_UNION_DEPTH, closest) {
243        for branch_error in closest {
244            describe_validation_error(branch_error, depth + 1, out);
245        }
246        return;
247    }
248    out.push(
249        // `masked()` omits the failing instance from the message; the raw Display would start with
250        // the whole (possibly huge) spec and truncate the reason away.
251        format!(
252            "{} (at instance path \"{}\")",
253            error.masked(),
254            error.instance_path()
255        )
256        .chars()
257        .take(MAX_VALIDATION_ERROR_CHARS)
258        .collect(),
259    );
260}
261
262/// Parses one LLM answer, rejects any data the model supplied, attaches `data_file` and validates
263/// the result against the Vega-Lite schema. The error string describes the failure in a form
264/// suitable for feeding back to the model.
265fn parse_and_validate_spec(
266    completion_content: &str,
267    validator: &jsonschema::Validator,
268    data_file: Option<DataFile<'_>>,
269) -> Result<serde_json::Value, String> {
270    let mut spec: serde_json::Value = serde_json::from_str(completion_content)
271        .map_err(|e| format!("The specification is not valid JSON: {e}"))?;
272    if !spec.is_object() {
273        return Err("The specification must be a JSON object.".to_string());
274    }
275    if let Some(offence) = find_data_source(&spec) {
276        return Err(format!(
277            "The specification must not contain any data, but it has {offence}. Leave the data out \
278             entirely; the teacher's data file is attached to the specification afterwards."
279        ));
280    }
281    let attached = data_file.unwrap_or(PLACEHOLDER_DATA_FILE);
282    attach_data_file(&mut spec, attached.url, attached.format);
283    let mut errors: Vec<String> = Vec::new();
284    for error in validator.iter_errors(&spec) {
285        describe_validation_error(&error, 0, &mut errors);
286        if errors.len() >= MAX_REPORTED_VALIDATION_ERRORS {
287            break;
288        }
289    }
290    errors.truncate(MAX_REPORTED_VALIDATION_ERRORS);
291    if !errors.is_empty() {
292        // A nested `spec` is valid only under facet or repeat, so on an already-rejected
293        // specification, one without either means the model wrapped its answer. Checking on the
294        // failure path never turns away a specification the schema accepts.
295        if spec.get("spec").is_some() && spec.get("facet").is_none() && spec.get("repeat").is_none()
296        {
297            return Err(
298                "The answer must be the Vega-Lite specification itself, not an object wrapping it."
299                    .to_string(),
300            );
301        }
302        return Err(format!(
303            "The specification does not conform to the Vega-Lite v6 JSON Schema: {}",
304            errors.join("; ")
305        ));
306    }
307    if data_file.is_none() {
308        // Leave a chart that has no file yet without one, so the block can say so.
309        spec.as_object_mut().map(|fields| fields.remove("data"));
310    }
311    Ok(spec)
312}
313
314fn text_message(role: MessageRole, content: String) -> APIInputMessage {
315    APIInputMessage {
316        message_type: InputItem::Message {
317            role,
318            content: MessageContent::Text(content),
319        },
320    }
321}
322
323/// Generate a Vega-Lite chart specification from a teacher's prompt using an LLM with
324/// structured JSON output. The result is validated against the Vega-Lite v6 JSON Schema;
325/// on failure the validation errors are sent back to the model for one repair round.
326/// Returns the specification pretty-printed as a JSON string.
327pub async fn generate_chart_spec(
328    app_config: &ApplicationConfiguration,
329    task_lm: TaskLMSpec,
330    input: &ChartSpecGenerationInput,
331) -> ChatbotResult<String> {
332    let validator = vega_lite_validator()?;
333    let data_file = input.data_url.as_deref().map(|url| DataFile {
334        url,
335        format: input.data_format.as_deref(),
336    });
337
338    let mut user_message_content = format!(
339        "{USER_PROMPT_PREFIX}\n\nRequest:\n{prompt}",
340        prompt = input.prompt
341    );
342    if let Some(language) = &input.language {
343        user_message_content
344            .push_str("\n\nTarget language for all human-readable chart text (BCP 47 code): ");
345        user_message_content.push_str(language);
346    }
347    if let Some(format) = &input.data_format {
348        user_message_content.push_str("\n\nFormat of the data file that will be attached: ");
349        user_message_content.push_str(format);
350    }
351    if let Some(sample) = &input.data_sample {
352        user_message_content.push_str(DATA_SAMPLE_HEADING);
353        user_message_content.push_str(sample);
354    }
355    if let Some(current_spec) = &input.current_spec {
356        user_message_content.push_str(EXISTING_SPEC_HEADING);
357        // A specification too broken to parse is sent as it stands: repairing it is the request.
358        user_message_content
359            .push_str(&without_data(current_spec).unwrap_or_else(|| current_spec.clone()));
360    }
361
362    let mut estimated_tokens =
363        estimate_tokens(SYSTEM_PROMPT) + estimate_tokens(&user_message_content);
364    let token_budget =
365        calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
366
367    if estimated_tokens > token_budget {
368        return Err(chatbot_err!(
369            ChatbotMessageSuggestError,
370            "The chart generation input is too long for the AI model's context window.".to_string()
371        ));
372    }
373
374    let (params, max_output_tokens) = if model_is_thinking(task_lm.model_type) {
375        (
376            LLMRequestParams::GPTThinking(ThinkingParams { reasoning: None }),
377            Some(8000),
378        )
379    } else {
380        (
381            LLMRequestParams::GPTNonThinking(NonThinkingParams {
382                temperature: None,
383                top_p: None,
384                frequency_penalty: None,
385                presence_penalty: None,
386            }),
387            Some(6000),
388        )
389    };
390
391    let mut messages = vec![
392        text_message(MessageRole::System, SYSTEM_PROMPT.to_string()),
393        text_message(MessageRole::User, user_message_content),
394    ];
395    let mut last_failure = String::new();
396
397    for attempt in 0..MAX_GENERATION_ATTEMPTS {
398        let chat_request = LLMRequest {
399            max_output_tokens,
400            text: Some(RequestTextOptions {
401                verbosity: None,
402                format: Some(response_format()),
403            }),
404            ..LLMRequest::new(task_lm.model.to_owned(), messages.clone(), params.clone())
405        };
406
407        let completion = make_blocking_llm_request(chat_request, app_config).await?;
408        let completion_content = parse_text_completion(completion)?;
409
410        match parse_and_validate_spec(&completion_content, validator, data_file) {
411            Ok(spec) => {
412                return serde_json::to_string_pretty(&spec).map_err(|e| {
413                    chatbot_err!(
414                        SerdeJson,
415                        "Failed to serialize the generated chart specification.".to_string(),
416                        e
417                    )
418                });
419            }
420            Err(failure) => {
421                last_failure = failure;
422                if attempt + 1 < MAX_GENERATION_ATTEMPTS {
423                    let correction = format!(
424                        "The returned specification was rejected: {last_failure}\n\nReturn the corrected, complete Vega-Lite specification. Follow the same JSON output format."
425                    );
426                    estimated_tokens +=
427                        estimate_tokens(&completion_content) + estimate_tokens(&correction);
428                    if estimated_tokens > token_budget {
429                        break;
430                    }
431                    messages.push(text_message(MessageRole::Assistant, completion_content));
432                    messages.push(text_message(MessageRole::User, correction));
433                }
434            }
435        }
436    }
437
438    Err(chatbot_err!(
439        ChatbotMessageSuggestError,
440        format!("The AI could not produce a valid Vega-Lite specification. {last_failure}")
441    ))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn validator() -> &'static jsonschema::Validator {
449        vega_lite_validator().expect("the vendored Vega-Lite schema should compile")
450    }
451
452    /// The wire shape is the contract with Azure: JSON mode takes no name and no schema, and a
453    /// schema smuggled back in would be rejected by the API rather than ignored.
454    #[test]
455    fn the_response_format_asks_only_for_a_json_object() {
456        let serialized =
457            serde_json::to_value(response_format()).expect("The response format serializes");
458
459        assert_eq!(serialized, serde_json::json!({"type": "json_object"}));
460    }
461
462    /// The schema is one union of every kind of chart, so without following the closest branch the
463    /// only thing reported is a failure of the root `anyOf`, which names neither the property nor
464    /// where it is. A model cannot repair that, and the wasted round doubles how long the teacher
465    /// waits.
466    #[test]
467    fn a_rejected_specification_names_the_property_that_is_wrong() {
468        // `legend` belongs to a field encoding; this colour channel is a value with a condition.
469        let spec = serde_json::json!({
470            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
471            "mark": "bar",
472            "encoding": {
473                "y": {"field": "category", "type": "nominal", "sort": "-x"},
474                "x": {"field": "value", "type": "quantitative"},
475                "color": {
476                    "condition": {"test": "datum.value > 50", "value": "#e45756"},
477                    "value": "#4c78a8",
478                    "legend": null
479                }
480            }
481        });
482
483        let err = parse_and_validate_spec(&spec.to_string(), validator(), Some(DATA_FILE))
484            .expect_err("the legend on a value encoding is invalid");
485
486        assert!(err.contains("/encoding/color"), "{err}");
487        assert!(
488            !err.contains("instance path \"\""),
489            "reported at the root: {err}"
490        );
491    }
492
493    #[test]
494    fn accepts_a_valid_spec() {
495        // The shape the mock LLM endpoint answers with.
496        let spec = serde_json::json!({
497            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
498            "description": "Mock AI generated bar chart",
499            "data": {"url": "/data.json", "format": {"type": "json"}},
500            "mark": "bar",
501            "encoding": {
502                "x": {"field": "category", "type": "nominal"},
503                "y": {"field": "value", "type": "quantitative"}
504            }
505        });
506        assert!(validator().iter_errors(&spec).next().is_none());
507    }
508
509    #[test]
510    fn rejects_an_invalid_spec() {
511        let spec = serde_json::json!({"mark": 123, "encoding": "not an object"});
512        assert!(validator().iter_errors(&spec).next().is_some());
513    }
514
515    #[test]
516    fn validation_errors_do_not_echo_the_spec() {
517        let content =
518            serde_json::json!({"mark": 123, "sentinel": "sentinel-value-xyz"}).to_string();
519        let err = parse_and_validate_spec(&content, validator(), Some(DATA_FILE))
520            .expect_err("should be rejected");
521        assert!(
522            !err.contains("sentinel-value-xyz"),
523            "error echoed the spec: {err}"
524        );
525    }
526
527    #[test]
528    fn parse_and_validate_reports_a_non_json_spec() {
529        let result = parse_and_validate_spec("this is not json", validator(), Some(DATA_FILE));
530        assert!(result.is_err());
531        assert!(
532            result
533                .expect_err("should be an error")
534                .contains("not valid JSON")
535        );
536    }
537
538    const DATA_FILE: DataFile<'static> = DataFile {
539        url: "/uploads/data.csv",
540        format: Some("csv"),
541    };
542
543    /// A chart as the model must now produce it: no data of any kind.
544    fn dataless_spec() -> serde_json::Value {
545        serde_json::json!({
546            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
547            "mark": "bar",
548            "encoding": {"x": {"field": "a", "type": "quantitative"}}
549        })
550    }
551
552    #[test]
553    fn accepts_a_specification_that_carries_no_data() {
554        assert_eq!(find_data_source(&dataless_spec()), None);
555    }
556
557    #[test]
558    fn accepts_multiple_views_that_carry_no_data() {
559        let spec = serde_json::json!({ "hconcat": [dataless_spec(), dataless_spec()] });
560        assert_eq!(find_data_source(&spec), None);
561    }
562
563    #[test]
564    fn rejects_inline_data_values() {
565        let spec = serde_json::json!({"data": {"values": [{"a": 1}]}, "mark": "bar"});
566        assert!(find_data_source(&spec).is_some());
567    }
568
569    #[test]
570    fn rejects_a_data_file_the_model_chose_itself() {
571        let spec =
572            serde_json::json!({"data": {"url": "http://example.com/data.csv"}, "mark": "bar"});
573        assert!(find_data_source(&spec).is_some());
574    }
575
576    #[test]
577    fn rejects_data_hidden_in_a_layer() {
578        let spec = serde_json::json!({
579            "layer": [dataless_spec(), {"data": {"values": [{"a": 1}]}, "mark": "line"}]
580        });
581        assert!(find_data_source(&spec).is_some());
582    }
583
584    #[test]
585    fn rejects_data_hidden_in_a_transform() {
586        let spec = serde_json::json!({
587            "transform": [{
588                "lookup": "a",
589                "from": {"data": {"values": [{"a": 1, "b": 2}]}, "key": "a", "fields": ["b"]}
590            }],
591            "mark": "bar"
592        });
593        assert!(find_data_source(&spec).is_some());
594    }
595
596    #[test]
597    fn rejects_a_datasets_section() {
598        let spec = serde_json::json!({
599            "datasets": {"invented": [{"a": 1}]},
600            "data": {"name": "invented"},
601            "mark": "bar"
602        });
603        assert!(find_data_source(&spec).is_some());
604    }
605
606    #[test]
607    fn rejects_generated_data() {
608        for generator in ["sequence", "graticule", "sphere"] {
609            let spec = serde_json::json!({ "data": { generator: {} }, "mark": "bar" });
610            assert!(find_data_source(&spec).is_some(), "{generator} was allowed");
611        }
612    }
613
614    #[test]
615    fn parse_and_validate_rejects_an_answer_that_brings_its_own_data() {
616        let spec = serde_json::json!({
617            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
618            "data": {"values": [{"a": 1}]},
619            "mark": "bar",
620            "encoding": {"x": {"field": "a", "type": "quantitative"}}
621        });
622        let err = parse_and_validate_spec(&spec.to_string(), validator(), Some(DATA_FILE))
623            .expect_err("should be rejected");
624
625        assert!(err.contains("must not contain any data"), "{err}");
626    }
627
628    #[test]
629    fn attaches_the_data_file_with_its_format() {
630        let mut spec = dataless_spec();
631
632        attach_data_file(&mut spec, "/uploads/data.csv", Some("csv"));
633
634        assert_eq!(
635            spec["data"],
636            serde_json::json!({"url": "/uploads/data.csv", "format": {"type": "csv"}})
637        );
638    }
639
640    #[test]
641    fn attaches_a_data_file_of_unknown_format() {
642        let mut spec = dataless_spec();
643
644        attach_data_file(&mut spec, "/uploads/data", None);
645
646        assert_eq!(spec["data"], serde_json::json!({"url": "/uploads/data"}));
647    }
648
649    #[test]
650    fn attaching_replaces_whatever_data_was_there() {
651        let mut spec = serde_json::json!({"data": {"values": [{"a": 1}]}, "mark": "bar"});
652
653        attach_data_file(&mut spec, "/uploads/data.csv", Some("csv"));
654
655        assert_eq!(
656            spec["data"],
657            serde_json::json!({"url": "/uploads/data.csv", "format": {"type": "csv"}})
658        );
659    }
660
661    #[test]
662    fn strips_every_data_source_from_the_specification_being_edited() {
663        let spec = serde_json::json!({
664            "data": {"url": "/uploads/data.csv"},
665            "datasets": {"named": [{"a": 1}]},
666            "layer": [{"data": {"values": [{"a": 1}]}, "mark": "bar"}],
667            "mark": "line"
668        });
669
670        let stripped = without_data(&spec.to_string()).expect("should be parseable");
671
672        assert_eq!(
673            find_data_source(&serde_json::from_str(&stripped).unwrap()),
674            None
675        );
676        assert!(
677            stripped.contains("line"),
678            "the chart itself was lost: {stripped}"
679        );
680    }
681
682    #[test]
683    fn leaves_an_unparseable_specification_to_be_sent_as_it_stands() {
684        assert_eq!(without_data("{ not json"), None);
685    }
686
687    #[test]
688    fn parse_and_validate_attaches_the_teacher_s_data_file() {
689        let spec =
690            parse_and_validate_spec(&dataless_spec().to_string(), validator(), Some(DATA_FILE))
691                .expect("should be accepted");
692
693        assert_eq!(
694            spec["data"],
695            serde_json::json!({"url": "/uploads/data.csv", "format": {"type": "csv"}})
696        );
697    }
698
699    #[test]
700    fn parse_and_validate_leaves_a_chart_with_no_file_yet_without_data() {
701        let spec = parse_and_validate_spec(&dataless_spec().to_string(), validator(), None)
702            .expect("should be accepted");
703
704        assert_eq!(spec.get("data"), None, "the placeholder leaked out: {spec}");
705    }
706
707    #[test]
708    fn parse_and_validate_accepts_a_valid_answer() {
709        let spec = r#"{"$schema":"https://vega.github.io/schema/vega-lite/v6.json","mark":"bar","encoding":{"x":{"field":"a","type":"quantitative"}}}"#;
710        let result = parse_and_validate_spec(spec, validator(), Some(DATA_FILE));
711        assert!(result.is_ok(), "expected valid, got: {result:?}");
712    }
713
714    #[test]
715    fn parse_and_validate_rejects_an_answer_that_wraps_the_specification() {
716        let wrapped = serde_json::json!({ "spec": dataless_spec() }).to_string();
717
718        let err = parse_and_validate_spec(&wrapped, validator(), Some(DATA_FILE))
719            .expect_err("should be rejected");
720
721        assert!(err.contains("not an object wrapping it"), "{err}");
722    }
723
724    #[test]
725    fn parse_and_validate_rejects_a_wrapper_that_carries_more_than_the_specification() {
726        let wrapped = serde_json::json!({
727            "spec": dataless_spec(),
728            "explanation": "Here is the chart you asked for."
729        })
730        .to_string();
731
732        let err = parse_and_validate_spec(&wrapped, validator(), Some(DATA_FILE))
733            .expect_err("should be rejected");
734
735        assert!(err.contains("not an object wrapping it"), "{err}");
736    }
737
738    /// A facet's nested `spec` is not a wrapper, so a fault inside one must be reported as itself.
739    #[test]
740    fn parse_and_validate_reports_the_fault_inside_a_faceted_answer() {
741        let faceted = serde_json::json!({
742            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
743            "facet": {"field": "group", "type": "nominal"},
744            "spec": {"mark": 123}
745        })
746        .to_string();
747
748        let err = parse_and_validate_spec(&faceted, validator(), Some(DATA_FILE))
749            .expect_err("a numeric mark is invalid");
750
751        assert!(err.contains("does not conform"), "{err}");
752        assert!(!err.contains("not an object wrapping it"), "{err}");
753    }
754
755    #[test]
756    fn parse_and_validate_accepts_a_faceted_answer() {
757        let faceted = serde_json::json!({
758            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
759            "facet": {"field": "group", "type": "nominal"},
760            "columns": 2,
761            "spec": {
762                "mark": "bar",
763                "encoding": {"x": {"field": "a", "type": "quantitative"}}
764            }
765        })
766        .to_string();
767
768        let result = parse_and_validate_spec(&faceted, validator(), Some(DATA_FILE));
769
770        assert!(result.is_ok(), "expected valid, got: {result:?}");
771    }
772
773    #[test]
774    fn parse_and_validate_accepts_a_repeated_answer() {
775        let repeated = serde_json::json!({
776            "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
777            "repeat": ["a", "b"],
778            "spec": {
779                "mark": "line",
780                "encoding": {"y": {"field": {"repeat": "repeat"}, "type": "quantitative"}}
781            }
782        })
783        .to_string();
784
785        let result = parse_and_validate_spec(&repeated, validator(), Some(DATA_FILE));
786
787        assert!(result.is_ok(), "expected valid, got: {result:?}");
788    }
789
790    #[test]
791    fn parse_and_validate_rejects_an_answer_that_is_not_an_object() {
792        let err = parse_and_validate_spec("[1, 2]", validator(), Some(DATA_FILE))
793            .expect_err("should be rejected");
794
795        assert!(err.contains("must be a JSON object"), "{err}");
796    }
797}