1use serde_json::{Map, Value, json};
7
8const NOTES: [&str; 3] = ["title", "description", "$comment"];
10const OBJECT: [&str; 4] = ["type", "properties", "required", "additionalProperties"];
12const STRING: [&str; 3] = ["type", "minLength", "maxLength"];
13const NUMERIC: [&str; 6] = [
14 "type",
15 "minimum",
16 "maximum",
17 "exclusiveMinimum",
18 "exclusiveMaximum",
19 "multipleOf",
20];
21const ARRAY: [&str; 4] = ["type", "items", "minItems", "maxItems"];
22const SHAPES: &str = "a Human result field is a string (with minLength or maxLength), an \
24integer or number, a boolean, a string enum, a const, an array of strings, or an array of \
25objects whose properties are those scalars";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Key {
30 Char(char),
31 Backspace,
32 Delete,
33 Left,
34 Right,
35 Up,
36 Down,
37 Home,
38 End,
39 Enter,
40 Tab,
41 BackTab,
42 Other,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub enum Input {
48 Fixed(Value),
50 Bool(Option<bool>),
52 Choice(Vec<String>, Option<usize>),
54 Text(String),
55 Integer(String),
56 Number(String),
57 List(Vec<String>),
59 Groups {
62 fields: Vec<Field>,
63 items: Vec<Vec<Field>>,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq)]
68pub struct Field {
69 pub name: String,
70 pub required: bool,
71 pub hint: String,
73 pub input: Input,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Row {
80 Field(usize),
81 Item {
82 field: usize,
83 item: usize,
84 sub: usize,
85 },
86 Add(usize),
87}
88
89impl Field {
90 fn value(&self, label: &str) -> Result<Option<Value>, String> {
93 let number = |text: &str, integer: bool| {
94 let value: Value = serde_json::from_str(text.trim()).unwrap_or_default();
95 let valid = if integer {
96 value.is_i64() || value.is_u64()
97 } else {
98 value.is_number()
99 };
100 let kind = if integer { "an integer" } else { "a number" };
101 valid
102 .then_some(value)
103 .ok_or_else(|| format!("{label}: enter {kind}."))
104 };
105 Ok(match &self.input {
106 Input::Fixed(value) => Some(value.clone()),
107 Input::Bool(value) => value.map(Value::Bool),
108 Input::Choice(options, index) => {
109 index.map(|index| Value::String(options[index].clone()))
110 }
111 Input::Text(text) => (self.required || !text.is_empty()).then(|| json!(text)),
112 Input::Integer(text) | Input::Number(text) if text.trim().is_empty() => None,
113 Input::Integer(text) => Some(number(text, true)?),
114 Input::Number(text) => Some(number(text, false)?),
115 Input::List(items) => {
116 let items: Vec<_> = items
117 .iter()
118 .filter(|item| !item.trim().is_empty())
119 .collect();
120 (self.required || !items.is_empty()).then(|| json!(items))
121 }
122 Input::Groups { items, .. } => {
123 let mut values = Vec::new();
124 for (at, item) in items.iter().enumerate().filter(|(_, item)| entered(item)) {
125 let mut object = Map::new();
126 for field in item {
127 let label = format!("{label} {} · {}", at + 1, field.name);
128 if let Some(value) = field.value(&label)? {
129 object.insert(field.name.clone(), value);
130 }
131 }
132 values.push(Value::Object(object));
133 }
134 (self.required || !values.is_empty()).then_some(Value::Array(values))
135 }
136 })
137 }
138
139 fn empty(&self) -> bool {
141 match &self.input {
142 Input::Fixed(_) => true,
143 Input::Bool(value) => value.is_none(),
144 Input::Choice(_, index) => index.is_none(),
145 Input::Text(text) | Input::Integer(text) | Input::Number(text) => {
146 text.trim().is_empty()
147 }
148 Input::List(items) => items.iter().all(|item| item.trim().is_empty()),
149 Input::Groups { items, .. } => !items.iter().any(|item| entered(item)),
150 }
151 }
152
153 pub fn display(&self) -> String {
155 match &self.input {
156 Input::Fixed(value) => format!("{value} (fixed)"),
157 Input::Bool(None) | Input::Choice(_, None) => "–".into(),
158 Input::Bool(Some(value)) => value.to_string(),
159 Input::Choice(options, Some(index)) => {
160 Value::String(options[*index].clone()).to_string()
161 }
162 Input::Text(text) | Input::Integer(text) | Input::Number(text) => text.clone(),
163 Input::List(items) if items.iter().all(String::is_empty) => "–".into(),
164 Input::List(items) => items.join("; "),
165 Input::Groups { items, .. } => format!("{} item(s)", items.len()),
166 }
167 }
168
169 fn text_like(&self) -> bool {
170 matches!(
171 self.input,
172 Input::Text(_) | Input::Integer(_) | Input::Number(_) | Input::List(_)
173 )
174 }
175
176 fn paste(&mut self, text: &str) -> Result<(), String> {
178 let fits = |size: usize| size.saturating_add(text.len()) <= crate::human::MAX_RESULT_BYTES;
179 let too_big = || {
180 format!(
181 "Human fields exceed {} bytes.",
182 crate::human::MAX_RESULT_BYTES
183 )
184 };
185 match &mut self.input {
186 Input::List(items) => {
187 if !fits(items.iter().map(String::len).sum()) {
188 return Err(too_big());
189 }
190 match items.last_mut() {
191 Some(item) => item.push_str(text),
192 None => items.push(text.to_owned()),
193 }
194 }
195 Input::Text(value) | Input::Integer(value) | Input::Number(value) => {
196 if !fits(value.len()) {
197 return Err(too_big());
198 }
199 value.push_str(text);
200 }
201 _ => {}
202 }
203 Ok(())
204 }
205
206 fn key(&mut self, key: Key) {
207 let required = self.required;
208 match (&mut self.input, key) {
209 (Input::Text(text) | Input::Integer(text) | Input::Number(text), Key::Char(c)) => {
210 text.push(c)
211 }
212 (Input::Text(text) | Input::Integer(text) | Input::Number(text), Key::Backspace) => {
213 text.pop();
214 }
215 (Input::List(items), Key::Char(c)) => match items.last_mut() {
218 Some(item) => item.push(c),
219 None => items.push(c.to_string()),
220 },
221 (Input::List(items), Key::Enter) => items.push(String::new()),
222 (Input::List(items), Key::Backspace) => {
223 if items.last().is_some_and(String::is_empty) && items.len() > 1 {
224 items.pop();
225 } else if let Some(item) = items.last_mut() {
226 item.pop();
227 }
228 }
229 (Input::Bool(value), Key::Char('t' | 'y')) => *value = Some(true),
230 (Input::Bool(value), Key::Char('f' | 'n')) => *value = Some(false),
231 (Input::Bool(value), Key::Char(' ') | Key::Left | Key::Right) => {
232 *value = match value {
233 None => Some(true),
234 Some(true) => Some(false),
235 Some(false) if required => Some(true),
236 Some(false) => None,
237 }
238 }
239 (Input::Choice(options, index), Key::Char(' ') | Key::Right) => {
240 *index = match *index {
241 None => Some(0),
242 Some(i) if i + 1 < options.len() => Some(i + 1),
243 Some(_) if required => Some(0),
244 Some(_) => None,
245 }
246 }
247 (Input::Choice(options, index), Key::Left) => {
248 *index = match *index {
249 Some(0) if !required => None,
250 Some(0) | None => Some(options.len() - 1),
251 Some(i) => Some(i - 1),
252 }
253 }
254 _ => {}
255 }
256 }
257}
258
259fn entered(item: &[Field]) -> bool {
261 item.iter().any(|field| !field.empty())
262}
263
264#[derive(Debug, Clone, PartialEq)]
265pub struct Form {
266 pub verdict: crate::runtime::Verdict,
267 pub fields: Vec<Field>,
268 pub selected: usize,
270 pub error: Option<String>,
272}
273
274impl Form {
275 pub fn new(verdict: crate::runtime::Verdict, schema: Option<&Value>) -> Result<Self, String> {
278 let fields = match schema {
279 None => Vec::new(),
280 Some(Value::Object(schema)) => fields(schema, "")?,
281 Some(_) => return Err("A Human result schema must be a JSON object.".into()),
282 };
283 Ok(Self {
284 verdict,
285 fields,
286 selected: 0,
287 error: None,
288 })
289 }
290
291 pub fn result(&self) -> Result<Value, String> {
293 let mut object = Map::new();
294 for field in &self.fields {
295 if let Some(value) = field.value(&field.name)? {
296 object.insert(field.name.clone(), value);
297 }
298 }
299 if object.contains_key("verdict") {
300 return Err("Choose the verdict in the review, not inside the owner fields.".into());
301 }
302 object.insert("verdict".into(), json!(self.verdict));
303 Ok(Value::Object(object))
304 }
305
306 pub fn rows(&self) -> Vec<Row> {
308 let mut rows = Vec::new();
309 for (index, field) in self.fields.iter().enumerate() {
310 match &field.input {
311 Input::Fixed(_) => {}
312 Input::Groups { items, .. } => {
313 for (item, fields) in items.iter().enumerate() {
314 let subs = fields.iter().enumerate();
315 rows.extend(
316 subs.filter(|(_, sub)| !matches!(sub.input, Input::Fixed(_)))
317 .map(|(sub, _)| Row::Item {
318 field: index,
319 item,
320 sub,
321 }),
322 );
323 }
324 rows.push(Row::Add(index));
325 }
326 _ => rows.push(Row::Field(index)),
327 }
328 }
329 rows
330 }
331
332 pub fn row(&self) -> Option<Row> {
334 self.rows().get(self.selected).copied()
335 }
336
337 pub fn field(&self, row: Row) -> Option<&Field> {
339 match row {
340 Row::Field(index) | Row::Add(index) => self.fields.get(index),
341 Row::Item { field, item, sub } => match &self.fields.get(field)?.input {
342 Input::Groups { items, .. } => items.get(item)?.get(sub),
343 _ => None,
344 },
345 }
346 }
347
348 fn field_mut(&mut self, row: Row) -> Option<&mut Field> {
349 match row {
350 Row::Field(index) => self.fields.get_mut(index),
351 Row::Add(_) => None,
352 Row::Item { field, item, sub } => match &mut self.fields.get_mut(field)?.input {
353 Input::Groups { items, .. } => items.get_mut(item)?.get_mut(sub),
354 _ => None,
355 },
356 }
357 }
358
359 pub fn add(&mut self, index: usize) {
362 match self.fields.get_mut(index).map(|field| &mut field.input) {
363 Some(Input::List(items)) => items.push(String::new()),
364 Some(Input::Groups { fields, items }) => {
365 items.push(fields.clone());
366 let item = items.len() - 1;
367 if let Some(at) = self.rows().iter().position(
368 |row| matches!(row, Row::Item { field, item: i, .. } if *field == index && *i == item),
369 ) {
370 self.selected = at;
371 }
372 }
373 _ => {}
374 }
375 }
376
377 pub fn remove(&mut self, index: usize, item: usize) {
379 match self.fields.get_mut(index).map(|field| &mut field.input) {
380 Some(Input::List(items)) => {
381 if item < items.len() {
382 items.remove(item);
383 }
384 if items.is_empty() {
385 items.push(String::new());
386 }
387 }
388 Some(Input::Groups { fields, items }) => {
389 if item < items.len() {
390 items.remove(item);
391 }
392 if items.is_empty() {
393 items.push(fields.clone());
394 }
395 }
396 _ => return,
397 }
398 self.selected = self.selected.min(self.rows().len().saturating_sub(1));
399 }
400
401 pub fn remove_selected(&mut self) {
403 if let Some(Row::Item { field, item, .. }) = self.row() {
404 self.remove(field, item);
405 }
406 }
407
408 pub fn paste(&mut self, text: &str) {
410 let Some(row) = self.row() else {
411 return;
412 };
413 if let Some(field) = self.field_mut(row)
414 && let Err(error) = field.paste(text)
415 {
416 self.error = Some(error);
417 }
418 }
419
420 pub fn inline_key(&mut self, key: Key, modified: bool) {
423 if modified {
424 return;
425 }
426 let row = self.row();
427 let text = row
428 .and_then(|row| self.field(row))
429 .is_some_and(Field::text_like);
430 match (key, row) {
431 (Key::Char(character), _) if text => self.paste(&character.to_string()),
432 (Key::Enter | Key::Char(' '), Some(Row::Add(index))) => self.add(index),
433 _ => self.key(key),
434 }
435 }
436
437 pub fn key(&mut self, key: Key) {
438 let count = self.rows().len();
439 match key {
440 Key::Up | Key::BackTab if count > 0 => {
441 self.selected = (self.selected + count - 1) % count
442 }
443 Key::Down | Key::Tab if count > 0 => self.selected = (self.selected + 1) % count,
444 _ => {
445 if let Some(row) = self.row()
446 && let Some(field) = self.field_mut(row)
447 {
448 field.key(key);
449 }
450 }
451 }
452 }
453}
454
455pub fn check(schema: &Map<String, Value>) -> Result<(), String> {
458 fields(schema, "").map(drop)
459}
460
461fn fields(schema: &Map<String, Value>, path: &str) -> Result<Vec<Field>, String> {
463 let at = |key: &str| {
464 if path.is_empty() {
465 key.to_owned()
466 } else {
467 format!("{path}.{key}")
468 }
469 };
470 if let Some(key) = schema
471 .keys()
472 .find(|key| !OBJECT.contains(&key.as_str()) && !NOTES.contains(&key.as_str()))
473 {
474 let what = if path.is_empty() {
475 "A Human result schema"
476 } else {
477 "An object in a Human result list"
478 };
479 return Err(format!(
480 "{what} may use only type, properties, required and additionalProperties (with \
481 title, description and $comment); `{}` is not allowed.",
482 at(key)
483 ));
484 }
485 if let Some(kind) = schema.get("type")
486 && kind != "object"
487 {
488 return Err(format!("`{}` must be \"object\".", at("type")));
489 }
490 if schema
491 .get("additionalProperties")
492 .is_some_and(|value| !value.is_boolean())
493 {
494 return Err(format!(
495 "`{}` must be true or false in a Human result schema.",
496 at("additionalProperties")
497 ));
498 }
499 let empty = Map::new();
500 let properties = match schema.get("properties") {
501 None => &empty,
502 Some(Value::Object(properties)) => properties,
503 Some(_) => return Err(format!("`{}` must be an object.", at("properties"))),
504 };
505 let required: Vec<&str> = match schema.get("required") {
506 None => Vec::new(),
507 Some(Value::Array(names)) => names
508 .iter()
509 .map(Value::as_str)
510 .collect::<Option<_>>()
511 .ok_or_else(|| format!("`{}` must list property names.", at("required")))?,
512 Some(_) => return Err(format!("`{}` must list property names.", at("required"))),
513 };
514 if let Some(name) = required
515 .iter()
516 .find(|name| !properties.contains_key(**name))
517 {
518 return Err(format!(
519 "`{}` names `{name}`, which `{}` does not declare, so a reviewer could never enter it.",
520 at("required"),
521 at("properties")
522 ));
523 }
524 properties
525 .iter()
526 .map(|(name, property)| {
527 let nested = !path.is_empty();
528 let path = at(&format!("properties.{name}"));
529 let (input, hint) = input(property, &path, nested)?;
531 Ok(Field {
532 name: name.clone(),
533 required: required.contains(&name.as_str()),
534 hint,
535 input,
536 })
537 })
538 .collect()
539}
540
541fn input(property: &Value, path: &str, nested: bool) -> Result<(Input, String), String> {
544 let refuse = |detail: &str| Err(format!("`{path}`: {detail}; {SHAPES}."));
545 let Some(property) = property.as_object() else {
546 return refuse("a property schema must be an object");
547 };
548 let only = |keys: &[&str]| match property
549 .keys()
550 .find(|key| !keys.contains(&key.as_str()) && !NOTES.contains(&key.as_str()))
551 {
552 Some(key) => Err(format!(
553 "`{path}`: `{key}` is not allowed here; {SHAPES}, and only {} may be used on it.",
554 keys.join(", ")
555 )),
556 None => Ok(()),
557 };
558 let kind = property.get("type");
559 let input = if let Some(value) = property.get("const") {
560 only(&["const", "type"])?;
561 Input::Fixed(value.clone())
562 } else if let Some(options) = property.get("enum") {
563 only(&["enum", "type"])?;
564 let strings: Option<Vec<String>> = options.as_array().and_then(|options| {
565 options
566 .iter()
567 .map(|option| option.as_str().map(str::to_owned))
568 .collect()
569 });
570 match strings {
571 Some(strings) if !strings.is_empty() && kind.is_none_or(|kind| kind == "string") => {
572 Input::Choice(strings, None)
573 }
574 _ => return refuse("an enum lists one or more strings"),
575 }
576 } else {
577 if kind.is_some_and(|kind| !kind.is_string()) {
578 return refuse("`type` names a single type");
579 }
580 match kind.and_then(Value::as_str) {
581 Some("boolean") => {
582 only(&["type"])?;
583 Input::Bool(None)
584 }
585 Some("string") => {
586 only(&STRING)?;
587 Input::Text(String::new())
588 }
589 Some("integer") => {
590 only(&NUMERIC)?;
591 Input::Integer(String::new())
592 }
593 Some("number") => {
594 only(&NUMERIC)?;
595 Input::Number(String::new())
596 }
597 Some("array") if nested => return refuse("a list cannot hold another list"),
598 Some("array") => {
599 only(&ARRAY)?;
600 array(property, path)?
601 }
602 Some(other) => return refuse(&format!("type \"{other}\" is not one of them")),
603 None => return refuse("a property needs a type, an enum or a const"),
604 }
605 };
606 Ok((input, hint(property)))
607}
608
609fn array(property: &Map<String, Value>, path: &str) -> Result<Input, String> {
611 let path = format!("{path}.items");
612 let items = match property.get("items") {
613 Some(Value::Object(items)) => items,
614 _ => {
615 return Err(format!(
616 "`{path}` must be a string schema or an object schema; {SHAPES}."
617 ));
618 }
619 };
620 match items.get("type").and_then(Value::as_str) {
621 Some("string") => {
622 if let Some(key) = items
623 .keys()
624 .find(|key| !STRING.contains(&key.as_str()) && !NOTES.contains(&key.as_str()))
625 {
626 return Err(format!(
627 "`{path}`: `{key}` is not allowed here; the items of a list of strings may \
628 use only minLength and maxLength."
629 ));
630 }
631 Ok(Input::List(vec![String::new()]))
632 }
633 Some("object") => {
634 let fields = fields(items, &path)?;
635 Ok(Input::Groups {
636 items: vec![fields.clone()],
637 fields,
638 })
639 }
640 _ => Err(format!(
641 "`{path}` must be a string schema or an object schema; {SHAPES}."
642 )),
643 }
644}
645
646fn hint(property: &Map<String, Value>) -> String {
648 let constraints = property
649 .iter()
650 .filter(|(key, _)| !["type", "const", "enum", "items"].contains(&key.as_str()))
651 .filter(|(key, _)| !NOTES.contains(&key.as_str()))
652 .map(|(key, value)| format!("{key} {value}"));
653 let kind = property
654 .get("type")
655 .and_then(Value::as_str)
656 .map(str::to_owned);
657 let hint = kind
658 .into_iter()
659 .chain(constraints)
660 .collect::<Vec<_>>()
661 .join(", ");
662 match property.get("description").and_then(Value::as_str) {
663 Some(description) if hint.is_empty() => description.to_owned(),
664 Some(description) => format!("{hint} — {description}"),
665 None => hint,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::runtime::Verdict;
673
674 fn schema(value: Value) -> Map<String, Value> {
675 value.as_object().unwrap().clone()
676 }
677
678 #[test]
679 fn human_schemas_take_scalars_lists_of_strings_and_lists_of_flat_objects() {
680 let accepted = json!({
681 "type": "object",
682 "title": "Review",
683 "properties": {
684 "approved": {"const": true},
685 "comment": {"type": "string", "maxLength": 200, "description": "Why"},
686 "count": {"type": "integer", "minimum": 0},
687 "score": {"type": "number"},
688 "ok": {"type": "boolean"},
689 "level": {"enum": ["low", "high"]},
690 "changes": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1},
691 "findings": {"type": "array", "maxItems": 5, "items": {
692 "type": "object",
693 "properties": {"line": {"type": "integer"}, "problem": {"type": "string"}},
694 "required": ["problem"],
695 "additionalProperties": false
696 }}
697 },
698 "required": ["approved"],
699 "additionalProperties": false
700 });
701 check(&schema(accepted)).unwrap();
702 for (refused, says) in [
703 (json!({"oneOf": []}), "`oneOf` is not allowed"),
704 (json!({"type": "array"}), "`type` must be \"object\""),
705 (
706 json!({"properties": {"a": {"type": "object"}}}),
707 "`properties.a`: type \"object\"",
708 ),
709 (
710 json!({"properties": {"a": {"type": "string", "pattern": "x"}}}),
711 "`pattern` is not allowed",
712 ),
713 (
714 json!({"properties": {"a": {"enum": [1, 2]}}}),
715 "an enum lists one or more strings",
716 ),
717 (
718 json!({"properties": {"a": {"type": "array", "items": {"type": "integer"}}}}),
719 "`properties.a.items` must be",
720 ),
721 (
722 json!({"properties": {"a": {"type": "array", "items": {"type": "object", "properties": {"b": {"type": "array", "items": {"type": "string"}}}}}}}),
723 "`properties.a.items.properties.b`: a list cannot hold another list",
724 ),
725 (
726 json!({"properties": {"a": {"type": "string"}}, "required": ["b"]}),
727 "names `b`",
728 ),
729 (
730 json!({"additionalProperties": {"type": "string"}}),
731 "true or false",
732 ),
733 ] {
734 let error = check(&schema(refused.clone())).unwrap_err();
735 assert!(error.contains(says), "{refused}: {error}");
736 }
737 }
738
739 fn groups_form() -> Form {
740 Form::new(
741 Verdict::Red,
742 Some(&json!({
743 "type": "object",
744 "properties": {
745 "kind": {"const": "review"},
746 "findings": {"type": "array", "items": {
747 "type": "object",
748 "properties": {
749 "tag": {"const": "x"},
750 "line": {"type": "integer"},
751 "problem": {"type": "string"}
752 },
753 "required": ["problem"]
754 }}
755 },
756 "required": ["findings"]
757 })),
758 )
759 .unwrap()
760 }
761
762 #[test]
763 fn groups_are_rows_to_type_into_with_an_add_row_and_empty_groups_are_left_out() {
764 let mut form = groups_form();
766 assert_eq!(
767 form.rows(),
768 [
769 Row::Item {
770 field: 0,
771 item: 0,
772 sub: 0
773 },
774 Row::Item {
775 field: 0,
776 item: 0,
777 sub: 1
778 },
779 Row::Add(0),
780 ]
781 );
782 form.inline_key(Key::Char('7'), false);
783 form.key(Key::Down);
784 form.paste("typo");
785 form.key(Key::Down);
786 form.inline_key(Key::Enter, false);
787 assert_eq!(
788 form.row(),
789 Some(Row::Item {
790 field: 0,
791 item: 1,
792 sub: 0
793 })
794 );
795 assert_eq!(
796 form.result().unwrap(),
797 json!({"kind": "review", "verdict": "RED", "findings": [
798 {"tag": "x", "line": 7, "problem": "typo"}
799 ]})
800 );
801 form.paste("x");
802 assert_eq!(
803 form.result().unwrap_err(),
804 "findings 2 · line: enter an integer."
805 );
806 form.remove_selected();
807 form.remove(0, 0);
808 assert_eq!(form.rows().len(), 3, "one empty group always stays");
809 assert_eq!(
810 form.result().unwrap(),
811 json!({"kind": "review", "verdict": "RED", "findings": []})
812 );
813 }
814
815 #[test]
816 fn a_list_of_strings_adds_and_removes_items_and_keeps_one_to_type_in() {
817 let mut form = Form::new(
818 Verdict::Green,
819 Some(&json!({"properties": {"notes": {"type": "array", "items": {"type": "string"}}}})),
820 )
821 .unwrap();
822 form.paste("a");
823 form.add(0);
824 form.paste("b");
825 assert_eq!(
826 form.fields[0].input,
827 Input::List(vec!["a".into(), "b".into()])
828 );
829 form.remove(0, 0);
830 assert_eq!(
831 form.result().unwrap(),
832 json!({"verdict": "GREEN", "notes": ["b"]})
833 );
834 form.remove(0, 0);
835 assert_eq!(form.fields[0].input, Input::List(vec![String::new()]));
836 assert_eq!(form.result().unwrap(), json!({"verdict": "GREEN"}));
837 }
838
839 #[test]
840 fn a_schema_a_form_cannot_enter_is_refused_rather_than_shown() {
841 let error = Form::new(
842 Verdict::Green,
843 Some(&json!({"properties": {"a": {"type": "object"}}})),
844 )
845 .unwrap_err();
846 assert!(error.contains("properties.a"), "{error}");
847 assert_eq!(
848 Form::new(Verdict::Green, None).unwrap().result().unwrap(),
849 json!({"verdict": "GREEN"})
850 );
851 }
852}