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
24static VEGA_LITE_SCHEMA_JSON: &str = include_str!("../schemas/vega-lite-v6.json");
27
28const MAX_GENERATION_ATTEMPTS: usize = 2;
30const MAX_REPORTED_VALIDATION_ERRORS: usize = 5;
33const MAX_VALIDATION_ERROR_CHARS: usize = 300;
34
35const 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
52pub 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
56pub const DATA_SAMPLE_HEADING: &str = "\n\nSample of the data file contents:\n";
59
60pub const EXISTING_SPEC_HEADING: &str = "\n\nExisting specification to edit:\n";
62
63fn response_format() -> LLMRequestResponseFormatParam {
70 LLMRequestResponseFormatParam::JsonObject
71}
72
73pub 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 pub language: Option<String>,
82}
83
84fn 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 _ => sanitize_refs(entry),
101 }
102 }
103 }
104 serde_json::Value::Array(items) => items.iter_mut().for_each(sanitize_refs),
105 _ => {}
106 }
107}
108
109fn 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
128fn 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#[derive(Clone, Copy)]
154pub struct DataFile<'a> {
155 pub url: &'a str,
156 pub format: Option<&'a str>,
157}
158
159const PLACEHOLDER_DATA_FILE: DataFile<'static> = DataFile {
163 url: "chart-data",
164 format: None,
165};
166
167fn 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
190fn 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
210const MAX_UNION_DEPTH: usize = 4;
212
213fn 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
224fn 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 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
262fn 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 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 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
323pub 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 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 #[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 #[test]
467 fn a_rejected_specification_names_the_property_that_is_wrong() {
468 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 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 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 #[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}