Skip to main content

sqlly_datatable/
data.rs

1//! Core data model for the grid: cell values, columns, and the rectangular
2//! [`GridData`] container.
3//!
4//! `GridData` is intentionally simple — a column list paired with a `Vec` of
5//! rectangular rows of [`CellValue`]. It carries no rendering, sorting, or
6//! filtering state: those live on [`crate::grid::GridState`]. Keeping the data
7//! layer pure makes it reusable from outside the widget (export pipelines,
8//! server-side previews, test fixtures).
9//!
10//! [`CellValue`] does not implement [`Eq`]/[`Ord`] because [`CellValue::Decimal`]
11//! holds an `f64`. Use [`compare_cells`] when you need a deterministic total
12//! ordering that handles `NaN` and mixed numeric kinds deliberately rather
13//! than collapsing to `Equal`.
14
15use std::cmp::Ordering;
16
17/// A single cell value.
18///
19/// Decimal values are stored as `f64`; for very large integers that exceed
20/// `2^53`, route them through [`CellValue::Text`] instead.
21#[derive(Clone, Debug, Default, PartialEq)]
22pub enum CellValue {
23    /// Free-form text. The grid will case-fold, truncate, and align it per
24    /// [`crate::config::StringFormat`].
25    Text(String),
26    /// 64-bit signed integer.
27    Integer(i64),
28    /// 64-bit floating point. `NaN` is permitted; [`compare_cells`] places it
29    /// after all finite numbers so sorting remains stable.
30    Decimal(f64),
31    /// Unix timestamp in seconds. Formatting is driven by
32    /// [`crate::config::DateFormat`].
33    Date(i64),
34    /// Boolean value rendered with [`crate::config::BooleanFormat`].
35    Boolean(bool),
36    /// Explicit "no value" — distinct from empty string and zero. Sorts before
37    /// every other variant. This is the [`Default`] variant.
38    #[default]
39    None,
40}
41
42/// Declared column kind. Drives the default [`crate::config::ResolvedColumnFormat`]
43/// when no [`crate::config::ColumnOverride`] is supplied.
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
45pub enum ColumnKind {
46    /// Text columns (`StringFormat`).
47    Text,
48    /// Integer columns (`NumberFormat`, default decimals = 0).
49    Integer,
50    /// Decimal columns (`NumberFormat`, default decimals = 2).
51    Decimal,
52    /// Date columns (`DateFormat`).
53    Date,
54    /// Boolean columns (`BooleanFormat`).
55    Boolean,
56    /// Unknown / un-inferred kind. Falls back to [`crate::config::StringFormat`] for display.
57    None,
58}
59
60/// A single column declaration.
61#[derive(Clone, Debug, PartialEq)]
62pub struct Column {
63    /// Human-readable column name. Rendered as the header label.
64    pub name: String,
65    /// Inferred kind driving default formatting.
66    pub kind: ColumnKind,
67    /// Initial column width in logical pixels. Resizable by the user at runtime.
68    pub width: f32,
69}
70
71impl Column {
72    /// Convenience constructor.
73    #[must_use]
74    pub fn new(name: impl Into<String>, kind: ColumnKind, width: f32) -> Self {
75        Self {
76            name: name.into(),
77            kind,
78            width,
79        }
80    }
81}
82
83/// Rectangular grid data: `rows.len()` rows each of length `columns.len()`.
84///
85/// The library does not silently fix ragged rows; use [`GridData::new`] or
86/// [`GridData::validate`] to detect and reject them.
87#[derive(Clone, Debug)]
88pub struct GridData {
89    /// Column metadata. `columns.len()` is the row width for every row.
90    pub columns: Vec<Column>,
91    /// Row contents. Every row must have exactly `columns.len()` cells.
92    pub rows: Vec<Vec<CellValue>>,
93}
94
95/// Error returned when [`GridData`] cannot be constructed or validated because
96/// at least one row's length disagrees with the column count.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum GridDataError {
99    /// A row had a different number of cells than `columns.len()`.
100    RaggedRow {
101        /// Index of the offending row.
102        row_index: usize,
103        /// Expected number of cells (always `columns.len()`).
104        expected: usize,
105        /// Actual number of cells found in the row.
106        actual: usize,
107    },
108}
109
110impl std::fmt::Display for GridDataError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            GridDataError::RaggedRow {
114                row_index,
115                expected,
116                actual,
117            } => write!(
118                f,
119                "row {row_index} has {actual} cells but {expected} were expected"
120            ),
121        }
122    }
123}
124
125impl std::error::Error for GridDataError {}
126
127impl GridData {
128    /// Construct a new `GridData`, validating that every row has exactly
129    /// `columns.len()` cells.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`GridDataError::RaggedRow`] pointing at the first mis-sized row.
134    pub fn new(columns: Vec<Column>, rows: Vec<Vec<CellValue>>) -> Result<Self, GridDataError> {
135        let data = Self { columns, rows };
136        data.validate()?;
137        Ok(data)
138    }
139
140    /// Validate the rectangular invariant. Cheap; called by [`GridData::new`]
141    /// and by debug assertions in the paint/copy hot paths.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`GridDataError::RaggedRow`] pointing at the first mis-sized row.
146    pub fn validate(&self) -> Result<(), GridDataError> {
147        let expected = self.columns.len();
148        for (row_index, row) in self.rows.iter().enumerate() {
149            if row.len() != expected {
150                return Err(GridDataError::RaggedRow {
151                    row_index,
152                    expected,
153                    actual: row.len(),
154                });
155            }
156        }
157        Ok(())
158    }
159
160    /// Safe accessor for cell `(row, col)`. Returns `None` if either index is
161    /// out of bounds.
162    #[must_use]
163    pub fn cell(&self, row: usize, col: usize) -> Option<&CellValue> {
164        self.rows.get(row).and_then(|r| r.get(col))
165    }
166
167    /// Number of rows (after sort/filter this reflects the live `display_indices`
168    /// length, not `rows.len()`).
169    #[must_use]
170    pub fn row_count(&self) -> usize {
171        self.rows.len()
172    }
173
174    /// Number of columns. Always equal to any row's length if [`Self::validate`]
175    /// succeeded.
176    #[must_use]
177    pub fn column_count(&self) -> usize {
178        self.columns.len()
179    }
180
181    /// Ordinal index of the first column whose name matches `name` exactly
182    /// (case-sensitive). If duplicate names exist, the first match wins.
183    #[must_use]
184    pub fn column_index(&self, name: &str) -> Option<usize> {
185        self.columns.iter().position(|col| col.name == name)
186    }
187}
188
189impl From<&str> for CellValue {
190    fn from(s: &str) -> Self {
191        CellValue::Text(s.to_owned())
192    }
193}
194
195impl From<String> for CellValue {
196    fn from(s: String) -> Self {
197        CellValue::Text(s)
198    }
199}
200
201impl From<i64> for CellValue {
202    fn from(v: i64) -> Self {
203        CellValue::Integer(v)
204    }
205}
206
207impl From<i32> for CellValue {
208    fn from(v: i32) -> Self {
209        CellValue::Integer(v.into())
210    }
211}
212
213impl From<f64> for CellValue {
214    fn from(v: f64) -> Self {
215        CellValue::Decimal(v)
216    }
217}
218
219impl From<bool> for CellValue {
220    fn from(v: bool) -> Self {
221        CellValue::Boolean(v)
222    }
223}
224
225impl From<Option<CellValue>> for CellValue {
226    fn from(v: Option<CellValue>) -> Self {
227        v.unwrap_or(CellValue::None)
228    }
229}
230
231/// Total deterministic ordering for `CellValue`.
232///
233/// Behavior:
234///
235/// * Same-kind numeric values compare numerically; decimals use
236///   [`f64::total_cmp`] so `NaN` is ordered consistently (after all finite
237///   values, and `0.0` before `-0.0` is reversed by `total_cmp` semantics —
238///   we keep that contract).
239/// * Mixed `Integer` / `Decimal` pairs compare numerically.
240/// * `None` always sorts before every other variant.
241/// * Cross-type non-numeric pairs fall back to a stable type-rank order so
242///   the return value is never `Equal` for genuinely different values.
243///
244/// Use [`std::cmp::Ordering`] directly via `slice::sort_by`; do not rely on
245/// whatever a future `PartialOrd` derive might produce.
246#[must_use]
247pub fn compare_cells(a: &CellValue, b: &CellValue) -> Ordering {
248    match (a, b) {
249        (CellValue::None, CellValue::None) => Ordering::Equal,
250        (CellValue::None, _) => Ordering::Less,
251        (_, CellValue::None) => Ordering::Greater,
252
253        (CellValue::Integer(x), CellValue::Integer(y)) => x.cmp(y),
254        (CellValue::Decimal(x), CellValue::Decimal(y)) => x.total_cmp(y),
255        (CellValue::Integer(x), CellValue::Decimal(y)) => (*x as f64).total_cmp(y),
256        (CellValue::Decimal(x), CellValue::Integer(y)) => x.total_cmp(&(*y as f64)),
257
258        (CellValue::Text(x), CellValue::Text(y)) => x.cmp(y),
259        (CellValue::Date(x), CellValue::Date(y)) => x.cmp(y),
260        (CellValue::Boolean(x), CellValue::Boolean(y)) => x.cmp(y),
261
262        (left, right) => type_rank(left).cmp(&type_rank(right)),
263    }
264}
265
266/// Stable rank used as a tie-breaker when two cells have different, non-numeric
267/// kinds. Sort order is `None < Boolean < Integer == Decimal < Date < Text`.
268fn type_rank(value: &CellValue) -> u8 {
269    match value {
270        CellValue::None => 0,
271        CellValue::Boolean(_) => 1,
272        CellValue::Integer(_) | CellValue::Decimal(_) => 2,
273        CellValue::Date(_) => 3,
274        CellValue::Text(_) => 4,
275    }
276}
277
278/// A handful of synthetic ledger-style rows for examples and the sample
279/// application. Kept here so examples have a known shape without pulling in a
280/// separate data file. Production code should construct [`GridData`] directly.
281#[must_use]
282pub fn sample_data() -> GridData {
283    use CellValue::{Boolean as B, Decimal as D, Integer as I, None as N, Text as T};
284    use ColumnKind::*;
285
286    let columns = vec![
287        Column::new("JournalLineId", Integer, 120.0),
288        Column::new("TenantId", Integer, 100.0),
289        Column::new("JournalId", Integer, 110.0),
290        Column::new("FinancialAccountingKeyId", Integer, 200.0),
291        Column::new("ExtendedFinancialAccountingKeyId", Integer, 240.0),
292        Column::new("TransactionCurrencyAmount", Decimal, 200.0),
293        Column::new("JurisdictionalCurrencyAmount", Decimal, 200.0),
294        Column::new("ReportingCurrencyAmount", Decimal, 200.0),
295        Column::new("Sequence", Integer, 100.0),
296        Column::new("TransPart", Boolean, 110.0),
297        Column::new("ReferenceTypeId", Integer, 140.0), // Nullable
298        Column::new("ReferenceEntityId", Integer, 150.0), // Nullable
299        Column::new("InternalReference", Text, 160.0),
300        Column::new("CounterPartyReference", Text, 180.0),
301        Column::new("Narrative", Text, 270.0),
302        Column::new("CurrencyId", Integer, 110.0),
303        Column::new("IsCleared", Boolean, 110.0),
304    ];
305
306    let row = |id: i64,
307               ta: i64,
308               ja: i64,
309               fa: i64,
310               ea: i64,
311               tx: i64,
312               jx: i64,
313               rx: i64,
314               sq: i64,
315               pa: bool,
316               rt: Option<i64>,
317               re: Option<i64>,
318               ir: &str,
319               cr: Option<&str>,
320               na: &str,
321               ci: i64,
322               cl: bool| {
323        vec![
324            I(id),
325            I(ta),
326            I(ja),
327            I(fa),
328            I(ea),
329            D(tx as f64),
330            D(jx as f64),
331            D(rx as f64),
332            I(sq),
333            B(pa),
334            rt.map(I).unwrap_or(N),
335            re.map(I).unwrap_or(N),
336            T(ir.into()),
337            cr.map(|s| T(s.into())).unwrap_or(N),
338            T(na.into()),
339            I(ci),
340            B(cl),
341        ]
342    };
343
344    let rows = vec![
345        row(
346            1096,
347            1,
348            148,
349            33,
350            528,
351            17968,
352            17968,
353            485,
354            0,
355            false,
356            Option::None,
357            Option::None,
358            "tomar 1",
359            Option::None,
360            "saldo de apertura de carga",
361            1,
362            false,
363        ),
364        row(
365            1097,
366            1,
367            148,
368            33,
369            530,
370            717,
371            717,
372            19,
373            1,
374            false,
375            Option::None,
376            Option::None,
377            "tomar 1",
378            Option::None,
379            "saldo de apertura de carga",
380            1,
381            false,
382        ),
383        row(
384            1098,
385            1,
386            148,
387            33,
388            532,
389            768,
390            768,
391            20,
392            2,
393            false,
394            Option::None,
395            Option::None,
396            "tomar 1",
397            Option::None,
398            "saldo de apertura de carga",
399            1,
400            false,
401        ),
402        row(
403            1099,
404            1,
405            148,
406            33,
407            533,
408            1141,
409            1141,
410            30,
411            3,
412            false,
413            Option::None,
414            Option::None,
415            "tomar 1",
416            Option::None,
417            "saldo de apertura de carga",
418            1,
419            false,
420        ),
421        row(
422            1100,
423            1,
424            148,
425            33,
426            536,
427            1937,
428            1937,
429            52,
430            4,
431            false,
432            Option::None,
433            Option::None,
434            "tomar 1",
435            Option::None,
436            "saldo de apertura de carga",
437            1,
438            false,
439        ),
440        row(
441            1101,
442            1,
443            148,
444            33,
445            538,
446            1018,
447            1018,
448            27,
449            5,
450            false,
451            Option::None,
452            Option::None,
453            "tomar 1",
454            Option::None,
455            "saldo de apertura de carga",
456            1,
457            false,
458        ),
459        row(
460            1102,
461            1,
462            148,
463            33,
464            542,
465            3172,
466            3172,
467            85,
468            6,
469            false,
470            Option::None,
471            Option::None,
472            "tomar 1",
473            Option::None,
474            "saldo de apertura de carga",
475            1,
476            false,
477        ),
478        row(
479            1103,
480            1,
481            148,
482            33,
483            544,
484            1640,
485            1640,
486            44,
487            7,
488            false,
489            Option::None,
490            Option::None,
491            "tomar 1",
492            Option::None,
493            "saldo de apertura de carga",
494            1,
495            false,
496        ),
497        row(
498            1104,
499            1,
500            148,
501            33,
502            546,
503            809,
504            809,
505            21,
506            8,
507            false,
508            Option::None,
509            Option::None,
510            "tomar 1",
511            Option::None,
512            "saldo de apertura de carga",
513            1,
514            false,
515        ),
516        row(
517            1105,
518            1,
519            148,
520            33,
521            573,
522            67,
523            67,
524            1,
525            9,
526            false,
527            Option::None,
528            Option::None,
529            "tomar 1",
530            Option::None,
531            "saldo de apertura de carga",
532            1,
533            false,
534        ),
535        row(
536            1106,
537            1,
538            148,
539            33,
540            574,
541            20,
542            20,
543            0,
544            10,
545            false,
546            Option::None,
547            Option::None,
548            "tomar 1",
549            Option::None,
550            "saldo de apertura de carga",
551            1,
552            false,
553        ),
554        row(
555            1107,
556            1,
557            148,
558            33,
559            575,
560            70,
561            70,
562            1,
563            11,
564            false,
565            Option::None,
566            Option::None,
567            "tomar 1",
568            Option::None,
569            "saldo de apertura de carga",
570            1,
571            false,
572        ),
573        row(
574            1108,
575            1,
576            148,
577            33,
578            576,
579            29,
580            29,
581            0,
582            12,
583            false,
584            Option::None,
585            Option::None,
586            "tomar 1",
587            Option::None,
588            "saldo de apertura de carga",
589            1,
590            false,
591        ),
592        row(
593            1109,
594            1,
595            148,
596            33,
597            577,
598            35,
599            35,
600            0,
601            13,
602            false,
603            Option::None,
604            Option::None,
605            "tomar 1",
606            Option::None,
607            "saldo de apertura de carga",
608            1,
609            false,
610        ),
611        row(
612            1110,
613            1,
614            148,
615            33,
616            578,
617            283,
618            283,
619            7,
620            14,
621            false,
622            Option::None,
623            Option::None,
624            "tomar 1",
625            Option::None,
626            "saldo de apertura de carga",
627            1,
628            false,
629        ),
630        row(
631            1111,
632            1,
633            148,
634            33,
635            579,
636            200,
637            200,
638            5,
639            15,
640            false,
641            Option::None,
642            Option::None,
643            "tomar 1",
644            Option::None,
645            "saldo de apertura de carga",
646            1,
647            false,
648        ),
649        row(
650            1112,
651            1,
652            148,
653            33,
654            580,
655            1140,
656            1140,
657            30,
658            16,
659            false,
660            Option::None,
661            Option::None,
662            "tomar 1",
663            Option::None,
664            "saldo de apertura de carga",
665            1,
666            false,
667        ),
668        row(
669            1113,
670            1,
671            148,
672            33,
673            581,
674            117,
675            117,
676            3,
677            17,
678            false,
679            Option::None,
680            Option::None,
681            "tomar 1",
682            Option::None,
683            "saldo de apertura de carga",
684            1,
685            false,
686        ),
687        row(
688            1114,
689            1,
690            148,
691            33,
692            582,
693            366,
694            366,
695            9,
696            18,
697            false,
698            Option::None,
699            Option::None,
700            "tomar 1",
701            Option::None,
702            "saldo de apertura de carga",
703            1,
704            false,
705        ),
706        row(
707            1115,
708            1,
709            148,
710            33,
711            603,
712            241,
713            241,
714            6,
715            19,
716            false,
717            Option::None,
718            Option::None,
719            "tomar 1",
720            Option::None,
721            "saldo de apertura de carga",
722            1,
723            false,
724        ),
725        row(
726            1116,
727            1,
728            148,
729            33,
730            604,
731            458,
732            458,
733            12,
734            20,
735            false,
736            Option::None,
737            Option::None,
738            "tomar 1",
739            Option::None,
740            "saldo de apertura de carga",
741            1,
742            false,
743        ),
744        row(
745            1117,
746            1,
747            148,
748            33,
749            605,
750            2640,
751            2640,
752            71,
753            21,
754            false,
755            Option::None,
756            Option::None,
757            "tomar 1",
758            Option::None,
759            "saldo de apertura de carga",
760            1,
761            false,
762        ),
763        row(
764            1118,
765            1,
766            148,
767            33,
768            606,
769            104,
770            104,
771            2,
772            22,
773            false,
774            Option::None,
775            Option::None,
776            "tomar 1",
777            Option::None,
778            "saldo de apertura de carga",
779            1,
780            false,
781        ),
782        row(
783            1119,
784            1,
785            148,
786            33,
787            607,
788            236,
789            236,
790            6,
791            23,
792            false,
793            Option::None,
794            Option::None,
795            "tomar 1",
796            Option::None,
797            "saldo de apertura de carga",
798            1,
799            false,
800        ),
801        row(
802            1120,
803            1,
804            148,
805            33,
806            608,
807            356,
808            356,
809            9,
810            24,
811            false,
812            Option::None,
813            Option::None,
814            "tomar 1",
815            Option::None,
816            "saldo de apertura de carga",
817            1,
818            false,
819        ),
820        row(
821            1121,
822            1,
823            148,
824            33,
825            609,
826            323,
827            323,
828            8,
829            25,
830            false,
831            Option::None,
832            Option::None,
833            "tomar 1",
834            Option::None,
835            "saldo de apertura de carga",
836            1,
837            false,
838        ),
839    ];
840
841    GridData { columns, rows }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn compare_same_kind_numeric() {
850        assert_eq!(
851            compare_cells(&CellValue::Integer(1), &CellValue::Integer(2)),
852            Ordering::Less
853        );
854        assert_eq!(
855            compare_cells(&CellValue::Integer(2), &CellValue::Integer(1)),
856            Ordering::Greater
857        );
858        assert_eq!(
859            compare_cells(&CellValue::Integer(7), &CellValue::Integer(7)),
860            Ordering::Equal
861        );
862        assert_eq!(
863            compare_cells(&CellValue::Decimal(1.5), &CellValue::Decimal(2.5)),
864            Ordering::Less
865        );
866    }
867
868    #[test]
869    fn compare_decimal_handles_nan_deterministically() {
870        let nan = CellValue::Decimal(f64::NAN);
871        let one = CellValue::Decimal(1.0);
872        // `NaN` should not collapse to Equal: it sits at a defined slot in total_cmp.
873        assert_ne!(compare_cells(&nan, &one), Ordering::Equal);
874        // Two `NaN` should be equal under total_cmp.
875        assert_eq!(
876            compare_cells(&nan, &CellValue::Decimal(f64::NAN)),
877            Ordering::Equal
878        );
879    }
880
881    #[test]
882    fn compare_mixed_numeric_via_total_cmp() {
883        assert_eq!(
884            compare_cells(&CellValue::Integer(5), &CellValue::Decimal(5.5)),
885            Ordering::Less,
886        );
887        assert_eq!(
888            compare_cells(&CellValue::Decimal(5.5), &CellValue::Integer(5)),
889            Ordering::Greater,
890        );
891        assert_eq!(
892            compare_cells(&CellValue::Integer(5), &CellValue::Decimal(5.0)),
893            Ordering::Equal,
894        );
895    }
896
897    #[test]
898    fn compare_null_is_always_less_than_other() {
899        assert_eq!(
900            compare_cells(&CellValue::None, &CellValue::Integer(0)),
901            Ordering::Less
902        );
903        assert_eq!(
904            compare_cells(&CellValue::Integer(0), &CellValue::None),
905            Ordering::Greater
906        );
907        assert_eq!(
908            compare_cells(&CellValue::None, &CellValue::None),
909            Ordering::Equal
910        );
911        assert_eq!(
912            compare_cells(&CellValue::None, &CellValue::Text("z".into())),
913            Ordering::Less
914        );
915    }
916
917    #[test]
918    fn compare_cross_type_non_numeric_is_deterministic_non_equal() {
919        // Different kinds, neither numeric, both non-null -> type-rank, Equal only by rank.
920        assert_ne!(
921            compare_cells(&CellValue::Boolean(true), &CellValue::Text("x".into())),
922            Ordering::Equal,
923        );
924        assert_eq!(
925            compare_cells(&CellValue::Boolean(true), &CellValue::Boolean(true)),
926            Ordering::Equal,
927        );
928    }
929
930    #[test]
931    fn grid_data_construction_validates_rows() {
932        let cols = vec![
933            Column::new("a", ColumnKind::Integer, 80.0),
934            Column::new("b", ColumnKind::Integer, 80.0),
935        ];
936        // Good.
937        let ok = GridData::new(
938            cols.clone(),
939            vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
940        );
941        assert!(ok.is_ok());
942
943        // Ragged row.
944        let bad = GridData::new(
945            cols,
946            vec![vec![
947                CellValue::Integer(1),
948                CellValue::Integer(2),
949                CellValue::Integer(3),
950            ]],
951        );
952        assert_eq!(
953            bad.err(),
954            Some(GridDataError::RaggedRow {
955                row_index: 0,
956                expected: 2,
957                actual: 3
958            }),
959        );
960    }
961
962    #[test]
963    #[allow(clippy::unwrap_used, clippy::expect_used)]
964    fn grid_data_cell_safe_access() {
965        let data = GridData::new(
966            vec![Column::new("a", ColumnKind::Integer, 80.0)],
967            vec![vec![CellValue::Integer(9)]],
968        )
969        .expect("row width matches columns");
970        assert_eq!(data.cell(0, 0), Some(&CellValue::Integer(9)));
971        assert_eq!(data.cell(1, 0), Option::None);
972        assert_eq!(data.cell(0, 1), Option::None);
973    }
974
975    #[test]
976    fn from_conversions_match_variant() {
977        assert_eq!(
978            CellValue::from(String::from("x")),
979            CellValue::Text("x".into())
980        );
981        assert_eq!(CellValue::from(42_i64), CellValue::Integer(42));
982        assert_eq!(CellValue::from(7_i32), CellValue::Integer(7));
983        assert_eq!(CellValue::from(0.5_f64), CellValue::Decimal(0.5));
984        assert_eq!(CellValue::from(true), CellValue::Boolean(true));
985        assert_eq!(
986            CellValue::from(Some(CellValue::Integer(3))),
987            CellValue::Integer(3),
988        );
989        assert_eq!(CellValue::from(Option::None::<CellValue>), CellValue::None);
990    }
991
992    #[test]
993    fn sample_data_is_rectangular() {
994        let sample = sample_data();
995        assert!(
996            sample.validate().is_ok(),
997            "sample rows should be rectangular"
998        );
999        assert!(sample.row_count() > 0);
1000    }
1001
1002    #[test]
1003    #[allow(clippy::expect_used)]
1004    fn column_index_exact_match() {
1005        let data = GridData::new(
1006            vec![
1007                Column::new("alpha", ColumnKind::Integer, 80.0),
1008                Column::new("beta", ColumnKind::Text, 80.0),
1009                Column::new("gamma", ColumnKind::Decimal, 80.0),
1010            ],
1011            vec![vec![
1012                CellValue::Integer(1),
1013                CellValue::Text("x".into()),
1014                CellValue::Decimal(1.0),
1015            ]],
1016        )
1017        .expect("rectangular");
1018        assert_eq!(data.column_index("alpha"), Some(0));
1019        assert_eq!(data.column_index("beta"), Some(1));
1020        assert_eq!(data.column_index("gamma"), Some(2));
1021        assert_eq!(data.column_index("Alpha"), None);
1022        assert_eq!(data.column_index("missing"), None);
1023    }
1024
1025    #[test]
1026    #[allow(clippy::expect_used)]
1027    fn column_index_first_duplicate_wins() {
1028        let data = GridData::new(
1029            vec![
1030                Column::new("dup", ColumnKind::Integer, 80.0),
1031                Column::new("dup", ColumnKind::Integer, 80.0),
1032            ],
1033            vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1034        )
1035        .expect("rectangular");
1036        assert_eq!(data.column_index("dup"), Some(0));
1037    }
1038
1039    #[test]
1040    #[allow(clippy::expect_used)]
1041    fn column_index_empty_data_returns_none() {
1042        let data = GridData::new(vec![], vec![]).expect("empty");
1043        assert_eq!(data.column_index("anything"), None);
1044    }
1045}