Skip to main content

perspective_viewer/config/
column_config_schema.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use super::{
19    CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts, NumberSeriesStyleDefaultConfig,
20};
21use crate::utils::{CssKind, GradientStopSpec, canonicalize_gradient_stops};
22
23/// The full schema for one column at one point in time. Plugins may return
24/// different schemas for the same column based on the column's current
25/// stored value (e.g. to hide dependent fields), so this is re-queried on
26/// every field update.
27#[derive(Clone, Debug, Default, Deserialize, Serialize)]
28pub struct ColumnConfigSchema {
29    pub fields: Vec<ControlSpec>,
30}
31
32impl ColumnConfigSchema {
33    /// Union of every JSON key any control in this schema knows how to
34    /// read or write. Used to build the schema-filtered view of
35    /// `columns_config` passed to `plugin.restore()` — keys not in this
36    /// set are "ghost" state from a different plugin and stay invisible
37    /// to the active one.
38    pub fn active_keys(&self) -> HashSet<String> {
39        let mut out = HashSet::new();
40        for spec in &self.fields {
41            for k in spec.serialized_keys() {
42                out.insert(k.to_string());
43            }
44        }
45        out
46    }
47
48    pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
49        fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
50            for spec in fields {
51                match spec {
52                    ControlSpec::Group { fields, .. } => collect(fields, out),
53                    leaf => out.push(leaf),
54                }
55            }
56        }
57
58        let mut out = vec![];
59        collect(&self.fields, &mut out);
60        out
61    }
62}
63
64/// Discriminated union of widget kinds the viewer can render. Composite
65/// variants wrap an existing rich Yew component and carry only the
66/// component's `*DefaultConfig`. Primitive variants render generic scalar
67/// widgets and carry their own `key` inline; the visible label is
68/// resolved at CSS time via `--psp-label--<key>--content`.
69#[derive(Clone, Debug, Deserialize, Serialize)]
70#[serde(tag = "kind")]
71pub enum ControlSpec {
72    Enum {
73        key: String,
74        variants: Vec<EnumVariant>,
75        default: String,
76    },
77
78    /// A CSS `font-family` picked from the generic keywords plus the families
79    /// the host can enumerate, with optional size, bold and italic inputs
80    /// each bound to its own key.
81    Font {
82        key: String,
83        default: String,
84
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        size: Option<FontSize>,
87
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        bold: Option<FontToggle>,
90
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        italic: Option<FontToggle>,
93    },
94    /// A 3×3 anchor picker whose value is one of the nine [`Alignment`]
95    /// tokens.
96    Alignment {
97        key: String,
98
99        /// The cell shown as the unmodified value and elided from serialized
100        /// configs, or `None` when an unset key stands for something no cell
101        /// can show.
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        default: Option<Alignment>,
104
105        /// Only the four corner cells are selectable.
106        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
107        corners: bool,
108    },
109    Bool {
110        key: String,
111        default: bool,
112    },
113    Number {
114        key: String,
115        default: f64,
116
117        /// If `true`, always serialize this values even if it is the default.
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        include: Option<bool>,
120
121        #[serde(default, skip_serializing_if = "Option::is_none")]
122        min: Option<f64>,
123
124        #[serde(default, skip_serializing_if = "Option::is_none")]
125        max: Option<f64>,
126
127        #[serde(default, skip_serializing_if = "Option::is_none")]
128        step: Option<f64>,
129    },
130    String {
131        key: String,
132        default: String,
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        placeholder: Option<String>,
135    },
136    Color {
137        key: String,
138        default: String,
139    },
140    Palette {
141        key: String,
142        default: String,
143
144        #[serde(default, skip_serializing_if = "Option::is_none")]
145        max: Option<usize>,
146    },
147    GradientStops {
148        key: String,
149        default: String,
150
151        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
152        discrete: bool,
153    },
154    DatetimeFormat {
155        /// Plugin-declared default `date_format`, shown by the editor in
156        /// unedited fields and elided from serialized configs.
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        default: Option<DatetimeFormatType>,
159    },
160    NumberSeriesStyle {
161        default: NumberSeriesStyleDefaultConfig,
162    },
163    Symbols {
164        default: KeyValueOpts,
165    },
166    NumberFormat {
167        /// Plugin-declared default format, keyed like `number_format`
168        /// itself.
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        default: Option<CustomNumberFormatConfig>,
171    },
172    AggregateDepth,
173
174    Group {
175        key: String,
176        #[serde(default)]
177        fields: Vec<ControlSpec>,
178    },
179}
180
181/// One cell of a 3×3 anchor grid: a corner `top-left`/`top-right`/
182/// `bottom-left`/`bottom-right`, an edge `top`/`left`/`right`/`bottom`, or
183/// `center`.
184#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum Alignment {
187    TopLeft,
188    Top,
189    TopRight,
190    Left,
191    Center,
192    Right,
193    BottomLeft,
194    Bottom,
195    BottomRight,
196}
197
198impl Alignment {
199    /// Every cell in row-major order.
200    pub const ALL: [Alignment; 9] = [
201        Alignment::TopLeft,
202        Alignment::Top,
203        Alignment::TopRight,
204        Alignment::Left,
205        Alignment::Center,
206        Alignment::Right,
207        Alignment::BottomLeft,
208        Alignment::Bottom,
209        Alignment::BottomRight,
210    ];
211    pub const CORNERS: [Alignment; 4] = [
212        Alignment::TopLeft,
213        Alignment::TopRight,
214        Alignment::BottomLeft,
215        Alignment::BottomRight,
216    ];
217
218    pub fn is_corner(self) -> bool {
219        Self::CORNERS.contains(&self)
220    }
221
222    /// The serialized token.
223    pub fn as_str(self) -> &'static str {
224        match self {
225            Alignment::TopLeft => "top-left",
226            Alignment::Top => "top",
227            Alignment::TopRight => "top-right",
228            Alignment::Left => "left",
229            Alignment::Center => "center",
230            Alignment::Right => "right",
231            Alignment::BottomLeft => "bottom-left",
232            Alignment::Bottom => "bottom",
233            Alignment::BottomRight => "bottom-right",
234        }
235    }
236
237    pub fn parse(src: &str) -> Option<Self> {
238        Self::ALL.into_iter().find(|x| x.as_str() == src)
239    }
240
241    /// `Top Left`-style text for accessible names.
242    pub fn humanized(self) -> String {
243        self.as_str()
244            .split('-')
245            .map(|word| {
246                let mut chars = word.chars();
247                match chars.next() {
248                    Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
249                    None => String::new(),
250                }
251            })
252            .collect::<Vec<_>>()
253            .join(" ")
254    }
255}
256
257/// One boolean style toggle of a [`ControlSpec::Font`] control: the
258/// config key it writes and the value that counts as "not set".
259#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
260pub struct FontToggle {
261    pub key: String,
262    #[serde(default)]
263    pub default: bool,
264}
265
266/// The size input of a [`ControlSpec::Font`] control: a number (CSS px)
267/// under its own key, elided from serialized configs at `default`.
268#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
269pub struct FontSize {
270    pub key: String,
271    pub default: f64,
272
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub min: Option<f64>,
275
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub max: Option<f64>,
278
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub step: Option<f64>,
281}
282
283#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
284pub struct EnumVariant {
285    pub value: String,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub label: Option<String>,
288}
289
290/// Fit `stops` to a `discrete` field's fixed pair: an over-length value
291/// keeps only its two end colors, pinned to `0`/`1`.
292pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
293    let stops = canonicalize_gradient_stops(stops);
294    match (stops.first(), stops.last()) {
295        (Some(first), Some(last)) if stops.len() > 2 => vec![
296            GradientStopSpec {
297                color: first.color.clone(),
298                offset: 0.0,
299            },
300            GradientStopSpec {
301                color: last.color.clone(),
302                offset: 1.0,
303            },
304        ],
305        _ => stops,
306    }
307}
308
309impl ColumnConfigSchema {
310    pub fn canonicalize(self) -> Self {
311        self.canonicalize_defaults().group_format_controls()
312    }
313
314    pub fn group_format_controls(mut self) -> Self {
315        fn is_format(spec: &ControlSpec) -> bool {
316            matches!(
317                spec,
318                ControlSpec::NumberFormat { .. } | ControlSpec::DatetimeFormat { .. }
319            )
320        }
321
322        fn walk(fields: &mut Vec<ControlSpec>) {
323            for spec in fields.iter_mut() {
324                if let ControlSpec::Group { key, fields } = spec
325                    && key != "format"
326                {
327                    walk(fields);
328                }
329            }
330
331            let first = fields.iter().position(is_format);
332            if let Some(first) = first {
333                let mut formats = vec![];
334                let mut i = first;
335                while i < fields.len() {
336                    if is_format(&fields[i]) {
337                        formats.push(fields.remove(i));
338                    } else {
339                        i += 1;
340                    }
341                }
342
343                fields.insert(first, ControlSpec::Group {
344                    key: "format".to_owned(),
345                    fields: formats,
346                });
347            }
348        }
349
350        walk(&mut self.fields);
351        self
352    }
353
354    /// Canonicalize every CSS-valued default at schema ingest, dropping
355    /// (and logging) fields whose default fails its kind's reader.
356    pub fn canonicalize_defaults(mut self) -> Self {
357        fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
358            fields.retain_mut(|spec| {
359                let (kind, key, default) = match spec {
360                    ControlSpec::Group { fields, .. } => {
361                        canonicalize_specs(fields);
362                        return !fields.is_empty();
363                    },
364                    ControlSpec::Alignment {
365                        key,
366                        default: Some(default),
367                        corners: true,
368                    } if !default.is_corner() => {
369                        tracing::error!(
370                            "Dropping `{key}` — default `{}` is not a corner",
371                            default.as_str()
372                        );
373
374                        return false;
375                    },
376                    ControlSpec::Color { key, default } => (CssKind::Color, key, default),
377                    ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
378                    ControlSpec::GradientStops { key, default, .. } => {
379                        (CssKind::Gradient, key, default)
380                    },
381                    _ => return true,
382                };
383
384                match kind.canonicalize(default) {
385                    Ok(canonical) => {
386                        *default = canonical;
387                        true
388                    },
389                    Err(error) => {
390                        tracing::error!("Dropping `{key}` — invalid schema default: {error}");
391                        false
392                    },
393                }
394            });
395        }
396
397        canonicalize_specs(&mut self.fields);
398        self
399    }
400
401    /// The variants of the [`ControlSpec::Enum`] owning `key`, if any.
402    pub fn enum_variants_of(&self, key: &str) -> Option<&[EnumVariant]> {
403        self.leaf_fields().into_iter().find_map(|spec| match spec {
404            ControlSpec::Enum {
405                key: k, variants, ..
406            } if k == key => Some(variants.as_slice()),
407            _ => None,
408        })
409    }
410
411    /// The CSS kind of the control owning `key`, if it is CSS-valued.
412    pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
413        self.leaf_fields().into_iter().find_map(|spec| match spec {
414            ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
415            ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
416            ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
417            _ => None,
418        })
419    }
420}
421
422impl ControlSpec {
423    /// Top-level JSON keys this control owns when its value is serialized
424    /// into a column's config map. For primitives this is just `[key]`;
425    /// for composites it's the set of fields the wrapped sub-struct
426    /// flattens. Used by [`ColumnConfigSchema::active_keys`] to filter the
427    /// `columns_config` blob passed to `plugin.restore()`.
428    pub fn serialized_keys(&self) -> Vec<&str> {
429        match self {
430            ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
431            ControlSpec::Font {
432                key,
433                size,
434                bold,
435                italic,
436                ..
437            } => [
438                Some(key.as_str()),
439                size.as_ref().map(|s| s.key.as_str()),
440                bold.as_ref().map(|t| t.key.as_str()),
441                italic.as_ref().map(|t| t.key.as_str()),
442            ]
443            .into_iter()
444            .flatten()
445            .collect(),
446            ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
447            ControlSpec::Symbols { .. } => vec!["symbols"],
448            ControlSpec::NumberFormat { .. } => vec!["number_format"],
449            ControlSpec::AggregateDepth => vec!["aggregate_depth"],
450            ControlSpec::Enum { key, .. }
451            | ControlSpec::Alignment { key, .. }
452            | ControlSpec::Bool { key, .. }
453            | ControlSpec::Number { key, .. }
454            | ControlSpec::String { key, .. }
455            | ControlSpec::Color { key, .. }
456            | ControlSpec::Palette { key, .. }
457            | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
458            ControlSpec::Group { fields, .. } => {
459                fields.iter().flat_map(|f| f.serialized_keys()).collect()
460            },
461        }
462    }
463}
464
465/// One UI-emitted change to a single schema field. The emitting widget
466/// declares which top-level keys the update is allowed to write
467/// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`])
468/// and a partial new sub-state (`value`).
469#[derive(Clone, Debug, Deserialize, Serialize)]
470pub struct ColumnConfigFieldUpdate {
471    pub keys: Vec<String>,
472    pub value: serde_json::Map<String, Value>,
473}
474
475/// Filter a per-column config map to only the keys advertised by the
476/// active plugin's schema. Foreign keys (left over from a previous plugin)
477/// stay in the unfiltered presentation state but never reach `restore()`.
478pub fn filter_to_schema(
479    config: &serde_json::Map<String, Value>,
480    active_keys: &HashSet<String>,
481) -> serde_json::Map<String, Value> {
482    config
483        .iter()
484        .filter(|(k, _)| active_keys.contains(k.as_str()))
485        .map(|(k, v)| (k.clone(), v.clone()))
486        .collect()
487}
488
489#[cfg(test)]
490mod tests {
491    use serde_json::json;
492
493    use super::*;
494
495    fn color(key: &str, default: &str) -> ControlSpec {
496        ControlSpec::Color {
497            key: key.to_owned(),
498            default: default.to_owned(),
499        }
500    }
501
502    fn flag(key: &str) -> ControlSpec {
503        ControlSpec::Bool {
504            key: key.to_owned(),
505            default: false,
506        }
507    }
508
509    fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
510        ControlSpec::Group {
511            key: key.to_owned(),
512            fields,
513        }
514    }
515
516    #[test]
517    fn group_deserializes_recursively() {
518        let schema: ColumnConfigSchema = serde_json::from_value(json!({
519            "fields": [{
520                "kind": "Group",
521                "key": "legend",
522                "fields": [
523                    { "kind": "Bool", "key": "legend_on", "default": false },
524                    {
525                        "kind": "Group",
526                        "key": "inner",
527                        "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
528                    }
529                ]
530            }]
531        }))
532        .unwrap();
533
534        let keys = schema.active_keys();
535        assert_eq!(
536            keys,
537            HashSet::from(["legend_on".to_owned(), "color".to_owned()])
538        );
539
540        let leaves = schema.leaf_fields();
541        assert_eq!(leaves.len(), 2);
542        assert!(
543            leaves
544                .iter()
545                .all(|s| !matches!(s, ControlSpec::Group { .. }))
546        );
547    }
548
549    #[test]
550    fn grouped_schema_is_equivalent_to_flat() {
551        let flat = ColumnConfigSchema {
552            fields: vec![flag("stack"), color("color", "#0366d6")],
553        };
554
555        let grouped = ColumnConfigSchema {
556            fields: vec![group("series", vec![
557                flag("stack"),
558                color("color", "#0366d6"),
559            ])],
560        };
561
562        assert_eq!(flat.active_keys(), grouped.active_keys());
563        assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
564        assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
565    }
566
567    #[test]
568    fn enum_variants_see_through_groups() {
569        let mode = ControlSpec::Enum {
570            key: "fg_mode".to_owned(),
571            default: "color".to_owned(),
572            variants: vec![
573                EnumVariant {
574                    value: "disabled".to_owned(),
575                    label: None,
576                },
577                EnumVariant {
578                    value: "color".to_owned(),
579                    label: None,
580                },
581            ],
582        };
583
584        let grouped = ColumnConfigSchema {
585            fields: vec![group("color", vec![mode, flag("flag")])],
586        };
587
588        let values: Vec<&str> = grouped
589            .enum_variants_of("fg_mode")
590            .unwrap()
591            .iter()
592            .map(|v| v.value.as_str())
593            .collect();
594
595        assert_eq!(values, vec!["disabled", "color"]);
596        assert!(grouped.enum_variants_of("flag").is_none());
597        assert!(grouped.enum_variants_of("missing").is_none());
598    }
599
600    #[test]
601    fn format_controls_group_and_merge() {
602        let schema = ColumnConfigSchema {
603            fields: vec![
604                ControlSpec::NumberFormat { default: None },
605                flag("flag"),
606                ControlSpec::DatetimeFormat { default: None },
607            ],
608        }
609        .group_format_controls();
610
611        assert_eq!(schema.fields.len(), 2);
612        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
613            panic!("expected format group first");
614        };
615
616        assert_eq!(key, "format");
617        assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
618        assert!(matches!(fields[1], ControlSpec::DatetimeFormat { .. }));
619        assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
620
621        assert_eq!(
622            schema.active_keys(),
623            HashSet::from([
624                "number_format".to_owned(),
625                "date_format".to_owned(),
626                "flag".to_owned()
627            ])
628        );
629    }
630
631    #[test]
632    fn font_owns_its_size_and_toggle_keys() {
633        let spec = ControlSpec::Font {
634            key: "font_family".to_owned(),
635            default: "inherit".to_owned(),
636            size: Some(FontSize {
637                key: "font_size".to_owned(),
638                default: 12.0,
639                min: None,
640                max: None,
641                step: None,
642            }),
643            bold: Some(FontToggle {
644                key: "bold".to_owned(),
645                default: false,
646            }),
647            italic: None,
648        };
649
650        assert_eq!(spec.serialized_keys(), vec![
651            "font_family",
652            "font_size",
653            "bold"
654        ]);
655    }
656
657    #[test]
658    fn format_grouping_recurses_but_never_double_wraps() {
659        let schema = ColumnConfigSchema {
660            fields: vec![
661                group("format", vec![ControlSpec::NumberFormat { default: None }]),
662                group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
663                    default: None,
664                }]),
665            ],
666        }
667        .group_format_controls();
668
669        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
670            panic!("expected group");
671        };
672
673        assert_eq!(key, "format");
674        assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
675
676        let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
677            panic!("expected group");
678        };
679
680        assert!(matches!(
681            &fields[1],
682            ControlSpec::Group { key, fields }
683                if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
684        ));
685    }
686
687    #[test]
688    fn format_controls_deserialize_without_default_payload() {
689        let schema: ColumnConfigSchema = serde_json::from_value(json!({
690            "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
691        }))
692        .unwrap();
693
694        assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
695            default: None
696        }));
697        assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
698            default: None
699        }));
700    }
701
702    #[test]
703    fn number_format_default_payload_deserializes_flattened_families() {
704        let schema: ColumnConfigSchema = serde_json::from_value(json!({
705            "fields": [{
706                "kind": "NumberFormat",
707                "default": {
708                    "notation": "compact",
709                    "compactDisplay": "short",
710                    "minimumFractionDigits": 0,
711                    "maximumFractionDigits": 1
712                }
713            }]
714        }))
715        .unwrap();
716
717        let ControlSpec::NumberFormat {
718            default: Some(default),
719        } = &schema.fields[0]
720        else {
721            panic!("expected NumberFormat with default");
722        };
723
724        assert_eq!(
725            default._notation,
726            Some(crate::config::Notation::Compact(
727                crate::config::CompactDisplay::Short
728            ))
729        );
730        assert_eq!(default._style, None);
731        assert_eq!(default.minimum_fraction_digits, Some(0.));
732        assert_eq!(default.maximum_fraction_digits, Some(1.));
733        assert_eq!(
734            schema.active_keys(),
735            HashSet::from(["number_format".to_owned()])
736        );
737    }
738
739    #[test]
740    fn datetime_format_default_payload_deserializes_simple_arm() {
741        let schema: ColumnConfigSchema = serde_json::from_value(json!({
742            "fields": [{
743                "kind": "DatetimeFormat",
744                "default": { "dateStyle": "medium", "timeStyle": "disabled" }
745            }]
746        }))
747        .unwrap();
748
749        let ControlSpec::DatetimeFormat {
750            default: Some(DatetimeFormatType::Simple(simple)),
751        } = &schema.fields[0]
752        else {
753            panic!("expected DatetimeFormat with Simple default");
754        };
755
756        assert_eq!(
757            simple.date_style,
758            crate::config::SimpleDatetimeFormat::Medium
759        );
760        assert_eq!(
761            simple.time_style,
762            crate::config::SimpleDatetimeFormat::Disabled
763        );
764    }
765
766    #[test]
767    fn alignment_tokens_round_trip() {
768        for align in Alignment::ALL {
769            assert_eq!(Alignment::parse(align.as_str()), Some(align));
770            assert_eq!(serde_json::to_value(align).unwrap(), json!(align.as_str()));
771        }
772
773        for bad in ["middle", "left-top", "top-center", "middle-left", ""] {
774            assert_eq!(Alignment::parse(bad), None, "{bad}");
775        }
776
777        assert_eq!(Alignment::ALL.iter().filter(|x| x.is_corner()).count(), 4);
778        assert_eq!(Alignment::TopLeft.humanized(), "Top Left");
779        assert_eq!(Alignment::Center.humanized(), "Center");
780    }
781
782    #[test]
783    fn alignment_deserializes_with_optional_default_and_corners() {
784        let schema: ColumnConfigSchema = serde_json::from_value(json!({
785            "fields": [
786                { "kind": "Alignment", "key": "align" },
787                { "kind": "Alignment", "key": "legend_anchor", "default": "top-right", "corners": true }
788            ]
789        }))
790        .unwrap();
791
792        assert!(matches!(&schema.fields[0], ControlSpec::Alignment {
793            default: None,
794            corners: false,
795            ..
796        }));
797        assert!(matches!(&schema.fields[1], ControlSpec::Alignment {
798            default: Some(Alignment::TopRight),
799            corners: true,
800            ..
801        }));
802        assert_eq!(
803            schema.active_keys(),
804            HashSet::from(["align".to_owned(), "legend_anchor".to_owned()])
805        );
806        assert!(
807            serde_json::from_value::<ColumnConfigSchema>(json!({
808                "fields": [{ "kind": "Alignment", "key": "x", "default": "middle" }]
809            }))
810            .is_err()
811        );
812    }
813
814    #[test]
815    fn canonicalize_defaults_drops_non_corner_default_of_corners_field() {
816        let schema = ColumnConfigSchema {
817            fields: vec![
818                ControlSpec::Alignment {
819                    key: "bad".to_owned(),
820                    default: Some(Alignment::Top),
821                    corners: true,
822                },
823                ControlSpec::Alignment {
824                    key: "ok".to_owned(),
825                    default: Some(Alignment::Top),
826                    corners: false,
827                },
828                ControlSpec::Alignment {
829                    key: "unset".to_owned(),
830                    default: None,
831                    corners: true,
832                },
833            ],
834        }
835        .canonicalize_defaults();
836
837        assert_eq!(
838            schema.active_keys(),
839            HashSet::from(["ok".to_owned(), "unset".to_owned()])
840        );
841    }
842
843    #[test]
844    fn canonicalize_defaults_recurses_and_drops_empty_groups() {
845        let schema = ColumnConfigSchema {
846            fields: vec![
847                group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
848                group("doomed", vec![color("bad", "not-a-color")]),
849            ],
850        }
851        .canonicalize_defaults();
852
853        assert_eq!(schema.fields.len(), 1);
854        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
855            panic!("expected group");
856        };
857
858        assert_eq!(key, "ok");
859        assert!(matches!(
860            &fields[0],
861            ControlSpec::Color { default, .. } if default == "#ff0000"
862        ));
863    }
864}