Skip to main content

artifactize_core/review/
form.rs

1//! Owner fields for one verdict, as a person enters them. A Human eval's result schemas may
2//! use only shapes a form can enter ([`check`] refuses any other when the declaration loads):
3//! an object whose properties are strings, integers, numbers, booleans, string choices and
4//! constants, lists of strings, and lists of flat objects of those scalars (repeated groups).
5
6use serde_json::{Map, Value, json};
7
8/// Annotations allowed on any schema the form accepts.
9const NOTES: [&str; 3] = ["title", "description", "$comment"];
10/// Keys of the result object itself, and of each object in a list of groups.
11const 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"];
22/// What a Human result schema may hold, as a declaration error says it.
23const 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/// A key as the form edits with it; each interface maps its own key events onto these.
28#[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    /// A key the form does not use.
43    Other,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub enum Input {
48    /// A `const` value: filled in and fixed.
49    Fixed(Value),
50    /// `None` until chosen.
51    Bool(Option<bool>),
52    /// String `enum` options and the chosen index.
53    Choice(Vec<String>, Option<usize>),
54    Text(String),
55    Integer(String),
56    Number(String),
57    /// An array of strings, one entry per item; empty entries are left out of the result.
58    List(Vec<String>),
59    /// An array of flat objects: one group of fields per item. `fields` is an empty item;
60    /// an item with nothing entered is left out of the result.
61    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    /// The type and constraints, then any description.
72    pub hint: String,
73    pub input: Input,
74}
75
76/// One row a terminal form moves through: a field, a field of one group, or a group list's
77/// `+ Add`. Constants are filled in and get no row.
78#[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    /// The JSON value to submit, named `label` in errors; `None` leaves an empty optional
91    /// field out.
92    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    /// Nothing is entered yet.
140    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    /// What the form shows for the current value.
154    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    /// Append pasted or typed text, bounded like the Human result.
177    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            // A list types into its last item; Enter starts the next, Backspace on an empty
216            // item removes it.
217            (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
259/// Something is entered in a group.
260fn 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    /// The selected row of [`Form::rows`].
269    pub selected: usize,
270    /// The last local or `human::submit` error.
271    pub error: Option<String>,
272}
273
274impl Form {
275    /// The form of `verdict` for its owner schema, or none. A schema a form cannot enter is
276    /// refused, as [`check`] refuses it when the declaration loads.
277    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    /// The complete result: owner fields plus the chosen verdict.
292    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    /// The rows a person moves through, in order.
307    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    /// The selected row, if the form has any.
333    pub fn row(&self) -> Option<Row> {
334        self.rows().get(self.selected).copied()
335    }
336
337    /// The field a row edits.
338    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    /// Add an empty item to the list of field `index`: an empty text to a list of strings, an
360    /// empty group to a list of groups, which the selection moves into.
361    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    /// Remove item `item` of the list of field `index`; one empty item always stays to type in.
378    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    /// Remove the group the selection is in.
402    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    /// Bounded paste follows the existing Human result limit, without splitting UTF-8.
409    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    /// A key typed into the form. Enter never submits: on `+ Add` it adds a group, in a list
421    /// it starts the next item. Keys with Control or Alt are not the form's.
422    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
455/// Whether a Human eval may declare `schema` as a result schema: only shapes a form can
456/// enter. The error names the offending key and what is allowed instead.
457pub fn check(schema: &Map<String, Value>) -> Result<(), String> {
458    fields(schema, "").map(drop)
459}
460
461/// The fields of an object schema at `path` (empty for the result itself).
462fn 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            // An object below the result is a group, whose fields are scalars.
530            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
541/// The input of the property at `path`, and its hint. Inside a group (`nested`) only scalars
542/// are allowed.
543fn 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
609/// A list of strings, or of groups: `items` is a string schema or a flat object schema.
610fn 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
646/// The type and constraints of a property, then any description.
647fn 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        // Properties come in name order: findings, kind; line, problem, tag.
765        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}