Skip to main content

oxiphysics_io/
cgns_format.rs

1#![allow(clippy::manual_strip, clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! CGNS (CFD General Notation System) format I/O — text-based subset.
6//!
7//! Provides readers and writers for a simplified text representation of CGNS
8//! files, covering structured and unstructured zones, flow solutions, and
9//! multi-base hierarchies.
10
11use std::collections::HashMap;
12use std::fs;
13use std::io::{self, Write as IoWrite};
14
15use crate::Error as IoError;
16
17// ---------------------------------------------------------------------------
18// CgnsNode
19// ---------------------------------------------------------------------------
20
21/// A generic CGNS tree node carrying metadata and optional data payload.
22#[derive(Debug, Clone)]
23pub struct CgnsNode {
24    /// Unique integer identifier for this node.
25    pub id: u32,
26    /// Node name string (e.g. `"GridCoordinates"`).
27    pub name: String,
28    /// CGNS label string (e.g. `"GridCoordinates_t"`).
29    pub label: String,
30    /// Data-type descriptor (e.g. `"R8"`, `"I4"`).
31    pub data_type: String,
32    /// Floating-point data payload.
33    pub data: Vec<f64>,
34}
35
36impl CgnsNode {
37    /// Create a new node with the given metadata and no data.
38    pub fn new(
39        id: u32,
40        name: impl Into<String>,
41        label: impl Into<String>,
42        data_type: impl Into<String>,
43    ) -> Self {
44        Self {
45            id,
46            name: name.into(),
47            label: label.into(),
48            data_type: data_type.into(),
49            data: Vec::new(),
50        }
51    }
52}
53
54// ---------------------------------------------------------------------------
55// ZoneType
56// ---------------------------------------------------------------------------
57
58/// CGNS zone topology type.
59#[derive(Debug, Clone, PartialEq)]
60pub enum ZoneType {
61    /// Structured (curvilinear) grid topology.
62    Structured,
63    /// Unstructured (arbitrary connectivity) grid topology.
64    Unstructured,
65}
66
67impl ZoneType {
68    /// Canonical string representation used in the text format.
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            ZoneType::Structured => "Structured",
72            ZoneType::Unstructured => "Unstructured",
73        }
74    }
75
76    /// Parse from the string representation used in the text format.
77    pub fn from_str(s: &str) -> Self {
78        match s.trim() {
79            "Unstructured" => ZoneType::Unstructured,
80            _ => ZoneType::Structured,
81        }
82    }
83}
84
85// ---------------------------------------------------------------------------
86// CgnsZone
87// ---------------------------------------------------------------------------
88
89/// A CGNS zone: a block of coordinates and optional element connectivity.
90#[derive(Debug, Clone)]
91pub struct CgnsZone {
92    /// Zone name string.
93    pub zone_name: String,
94    /// Topology type of this zone.
95    pub zone_type: ZoneType,
96    /// X-coordinates of grid points.
97    pub x: Vec<f64>,
98    /// Y-coordinates of grid points.
99    pub y: Vec<f64>,
100    /// Z-coordinates of grid points.
101    pub z: Vec<f64>,
102    /// Flattened element connectivity array (node IDs, 1-based).
103    pub elements: Vec<usize>,
104}
105
106impl CgnsZone {
107    /// Create an empty zone with the given name and type.
108    pub fn new(zone_name: impl Into<String>, zone_type: ZoneType) -> Self {
109        Self {
110            zone_name: zone_name.into(),
111            zone_type,
112            x: Vec::new(),
113            y: Vec::new(),
114            z: Vec::new(),
115            elements: Vec::new(),
116        }
117    }
118
119    /// Set the coordinate arrays for this zone.
120    pub fn set_coords(&mut self, x: Vec<f64>, y: Vec<f64>, z: Vec<f64>) {
121        self.x = x;
122        self.y = y;
123        self.z = z;
124    }
125
126    /// Number of grid points in this zone.
127    pub fn n_points(&self) -> usize {
128        self.x.len()
129    }
130}
131
132// ---------------------------------------------------------------------------
133// CgnsBase
134// ---------------------------------------------------------------------------
135
136/// A CGNS base node: collects zones under a named base with dimension info.
137#[derive(Debug, Clone)]
138pub struct CgnsBase {
139    /// Base name string.
140    pub base_name: String,
141    /// Cell dimension (1, 2, or 3).
142    pub cell_dim: u8,
143    /// Physical dimension (1, 2, or 3).
144    pub phys_dim: u8,
145    /// Zones belonging to this base.
146    pub zones: Vec<CgnsZone>,
147}
148
149impl CgnsBase {
150    /// Create a new base with the given name and dimensions.
151    pub fn new(base_name: impl Into<String>, cell_dim: u8, phys_dim: u8) -> Self {
152        Self {
153            base_name: base_name.into(),
154            cell_dim,
155            phys_dim,
156            zones: Vec::new(),
157        }
158    }
159
160    /// Append a zone to this base.
161    pub fn add_zone(&mut self, zone: CgnsZone) {
162        self.zones.push(zone);
163    }
164}
165
166// ---------------------------------------------------------------------------
167// SolutionLocation
168// ---------------------------------------------------------------------------
169
170/// Location at which a flow solution is defined.
171#[derive(Debug, Clone, PartialEq)]
172pub enum SolutionLocation {
173    /// Data is defined at grid vertices.
174    Vertex,
175    /// Data is defined at element/cell centres.
176    CellCenter,
177}
178
179impl SolutionLocation {
180    /// Canonical string representation.
181    pub fn as_str(&self) -> &'static str {
182        match self {
183            SolutionLocation::Vertex => "Vertex",
184            SolutionLocation::CellCenter => "CellCenter",
185        }
186    }
187
188    /// Parse from canonical string representation.
189    pub fn from_str(s: &str) -> Self {
190        match s.trim() {
191            "CellCenter" => SolutionLocation::CellCenter,
192            _ => SolutionLocation::Vertex,
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// FlowSolution
199// ---------------------------------------------------------------------------
200
201/// A named flow solution attached to a zone.
202#[derive(Debug, Clone)]
203pub struct FlowSolution {
204    /// Name of this solution (e.g. `"FlowSolution"`).
205    pub solution_name: String,
206    /// Location at which solution fields are defined.
207    pub location: SolutionLocation,
208    /// Named scalar fields (e.g. `"Pressure"`, `"VelocityX"`).
209    pub fields: HashMap<String, Vec<f64>>,
210}
211
212impl FlowSolution {
213    /// Create a new empty flow solution.
214    pub fn new(solution_name: impl Into<String>, location: SolutionLocation) -> Self {
215        Self {
216            solution_name: solution_name.into(),
217            location,
218            fields: HashMap::new(),
219        }
220    }
221
222    /// Insert or replace a named field.
223    pub fn add_field(&mut self, name: impl Into<String>, values: Vec<f64>) {
224        self.fields.insert(name.into(), values);
225    }
226}
227
228// ---------------------------------------------------------------------------
229// CgnsFile
230// ---------------------------------------------------------------------------
231
232/// Top-level CGNS file container with multi-base support.
233#[derive(Debug, Clone)]
234pub struct CgnsFile {
235    /// All bases stored in this file.
236    pub bases: Vec<CgnsBase>,
237}
238
239impl CgnsFile {
240    /// Create an empty CGNS file.
241    pub fn new() -> Self {
242        Self { bases: Vec::new() }
243    }
244
245    /// Append a base to the file.
246    pub fn add_base(&mut self, base: CgnsBase) {
247        self.bases.push(base);
248    }
249
250    /// Write the file in the simplified text CGNS format to `path`.
251    pub fn write_text(&self, path: &str) -> Result<(), IoError> {
252        let mut f = fs::File::create(path).map_err(IoError::Io)?;
253        writeln!(f, "CGNS_TEXT_V1").map_err(IoError::Io)?;
254        writeln!(f, "N_BASES {}", self.bases.len()).map_err(IoError::Io)?;
255        for base in &self.bases {
256            writeln!(
257                f,
258                "BASE {} {} {}",
259                base.base_name, base.cell_dim, base.phys_dim
260            )
261            .map_err(IoError::Io)?;
262            writeln!(f, "N_ZONES {}", base.zones.len()).map_err(IoError::Io)?;
263            for zone in &base.zones {
264                writeln!(f, "ZONE {} {}", zone.zone_name, zone.zone_type.as_str())
265                    .map_err(IoError::Io)?;
266                writeln!(f, "N_POINTS {}", zone.x.len()).map_err(IoError::Io)?;
267                // Coords
268                let x_strs: Vec<String> = zone.x.iter().map(|v| v.to_string()).collect();
269                writeln!(f, "X {}", x_strs.join(" ")).map_err(IoError::Io)?;
270                let y_strs: Vec<String> = zone.y.iter().map(|v| v.to_string()).collect();
271                writeln!(f, "Y {}", y_strs.join(" ")).map_err(IoError::Io)?;
272                let z_strs: Vec<String> = zone.z.iter().map(|v| v.to_string()).collect();
273                writeln!(f, "Z {}", z_strs.join(" ")).map_err(IoError::Io)?;
274                // Elements
275                let elem_strs: Vec<String> = zone.elements.iter().map(|v| v.to_string()).collect();
276                writeln!(
277                    f,
278                    "ELEMENTS {} {}",
279                    zone.elements.len(),
280                    elem_strs.join(" ")
281                )
282                .map_err(IoError::Io)?;
283                writeln!(f, "END_ZONE").map_err(IoError::Io)?;
284            }
285            writeln!(f, "END_BASE").map_err(IoError::Io)?;
286        }
287        writeln!(f, "END_FILE").map_err(IoError::Io)?;
288        Ok(())
289    }
290
291    /// Read a CGNS file from the simplified text format at `path`.
292    pub fn read_text(path: &str) -> Result<Self, IoError> {
293        let text = fs::read_to_string(path).map_err(IoError::Io)?;
294        CgnsReader::parse(&text)
295    }
296}
297
298impl Default for CgnsFile {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304// ---------------------------------------------------------------------------
305// CgnsWriter
306// ---------------------------------------------------------------------------
307
308/// High-level writer that assembles a structured grid with a flow solution
309/// and delegates to `CgnsFile::write_text`.
310#[derive(Debug)]
311pub struct CgnsWriter {
312    /// The file being assembled.
313    pub file: CgnsFile,
314}
315
316impl CgnsWriter {
317    /// Create a new writer backed by an empty `CgnsFile`.
318    pub fn new() -> Self {
319        Self {
320            file: CgnsFile::new(),
321        }
322    }
323
324    /// Write a structured grid zone (with optional flow solution) to `path`.
325    ///
326    /// * `base_name`  — name of the CGNS base node.
327    /// * `zone_name`  — name of the zone.
328    /// * `x`, `y`, `z` — coordinate arrays of equal length.
329    /// * `solution`   — optional flow solution to attach.
330    /// * `path`       — output file path.
331    #[allow(clippy::too_many_arguments)]
332    pub fn write_structured_grid(
333        &mut self,
334        base_name: &str,
335        zone_name: &str,
336        x: Vec<f64>,
337        y: Vec<f64>,
338        z: Vec<f64>,
339        solution: Option<FlowSolution>,
340        path: &str,
341    ) -> Result<(), IoError> {
342        let mut base = CgnsBase::new(base_name, 3, 3);
343        let mut zone = CgnsZone::new(zone_name, ZoneType::Structured);
344        zone.set_coords(x, y, z);
345        base.add_zone(zone);
346        self.file.add_base(base);
347
348        // Write main file
349        self.file.write_text(path)?;
350
351        // If a solution is provided, write a companion solution file
352        if let Some(sol) = solution {
353            let sol_path = format!("{path}.sol");
354            let mut sf = fs::File::create(&sol_path).map_err(IoError::Io)?;
355            writeln!(
356                sf,
357                "SOLUTION {} {}",
358                sol.solution_name,
359                sol.location.as_str()
360            )
361            .map_err(IoError::Io)?;
362            for (name, vals) in &sol.fields {
363                let strs: Vec<String> = vals.iter().map(|v| v.to_string()).collect();
364                writeln!(sf, "FIELD {} {}", name, strs.join(" ")).map_err(IoError::Io)?;
365            }
366            writeln!(sf, "END_SOLUTION").map_err(IoError::Io)?;
367        }
368        Ok(())
369    }
370}
371
372impl Default for CgnsWriter {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378// ---------------------------------------------------------------------------
379// CgnsReader
380// ---------------------------------------------------------------------------
381
382/// Parser for the simplified CGNS text format produced by `CgnsFile::write_text`.
383#[derive(Debug)]
384pub struct CgnsReader;
385
386impl CgnsReader {
387    /// Parse CGNS text format from a string slice.
388    pub fn parse(text: &str) -> Result<CgnsFile, IoError> {
389        let mut file = CgnsFile::new();
390        let mut lines = text.lines().peekable();
391
392        // Expect header
393        match lines.next() {
394            Some(l) if l.trim() == "CGNS_TEXT_V1" => {}
395            _ => {
396                return Err(IoError::Io(io::Error::new(
397                    io::ErrorKind::InvalidData,
398                    "missing CGNS_TEXT_V1 header",
399                )));
400            }
401        }
402
403        while let Some(line) = lines.next() {
404            let line = line.trim();
405            if line.starts_with("N_BASES") {
406                // Just consume — we infer count from BASE tokens
407                continue;
408            }
409            if line.starts_with("BASE ") {
410                let parts: Vec<&str> = line.splitn(4, ' ').collect();
411                let base_name = parts.get(1).copied().unwrap_or("Base").to_string();
412                let cell_dim: u8 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(3);
413                let phys_dim: u8 = parts.get(3).and_then(|s| s.parse().ok()).unwrap_or(3);
414                let mut base = CgnsBase::new(base_name, cell_dim, phys_dim);
415
416                // Read zones until END_BASE
417                'base_loop: loop {
418                    match lines.next() {
419                        None => break 'base_loop,
420                        Some(l) => {
421                            let l = l.trim();
422                            if l == "END_BASE" {
423                                break 'base_loop;
424                            }
425                            if l.starts_with("N_ZONES") {
426                                continue;
427                            }
428                            if l.starts_with("ZONE ") {
429                                let zparts: Vec<&str> = l.splitn(3, ' ').collect();
430                                let zone_name =
431                                    zparts.get(1).copied().unwrap_or("Zone").to_string();
432                                let zone_type = ZoneType::from_str(
433                                    zparts.get(2).copied().unwrap_or("Structured"),
434                                );
435                                let mut zone = CgnsZone::new(zone_name, zone_type);
436
437                                // Read zone contents until END_ZONE
438                                'zone_loop: loop {
439                                    match lines.next() {
440                                        None => break 'zone_loop,
441                                        Some(zl) => {
442                                            let zl = zl.trim();
443                                            if zl == "END_ZONE" {
444                                                break 'zone_loop;
445                                            }
446                                            if zl.starts_with("N_POINTS") {
447                                                continue;
448                                            }
449                                            if zl.starts_with("X ") {
450                                                zone.x = Self::parse_floats(&zl[2..]);
451                                            } else if zl.starts_with("Y ") {
452                                                zone.y = Self::parse_floats(&zl[2..]);
453                                            } else if zl.starts_with("Z ") {
454                                                zone.z = Self::parse_floats(&zl[2..]);
455                                            } else if zl.starts_with("ELEMENTS ") {
456                                                // Format: ELEMENTS <count> <v1> <v2> ...
457                                                let ep: Vec<&str> = zl.splitn(3, ' ').collect();
458                                                if let Some(data_str) = ep.get(2) {
459                                                    zone.elements = data_str
460                                                        .split_whitespace()
461                                                        .filter_map(|s| s.parse().ok())
462                                                        .collect();
463                                                }
464                                            }
465                                        }
466                                    }
467                                }
468                                base.add_zone(zone);
469                            }
470                        }
471                    }
472                }
473                file.add_base(base);
474            }
475        }
476        Ok(file)
477    }
478
479    /// Parse a space-separated list of f64 values from a string slice.
480    fn parse_floats(s: &str) -> Vec<f64> {
481        s.split_whitespace()
482            .filter_map(|tok| tok.parse().ok())
483            .collect()
484    }
485}
486
487// ---------------------------------------------------------------------------
488// Tests
489// ---------------------------------------------------------------------------
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn tmp_path(name: &str) -> String {
496        format!("/tmp/cgns_test_{name}")
497    }
498
499    // ── CgnsNode tests ───────────────────────────────────────────────────
500
501    #[test]
502    fn test_cgns_node_new() {
503        let node = CgnsNode::new(1, "GridCoords", "GridCoordinates_t", "R8");
504        assert_eq!(node.id, 1);
505        assert_eq!(node.name, "GridCoords");
506        assert_eq!(node.label, "GridCoordinates_t");
507        assert_eq!(node.data_type, "R8");
508        assert!(node.data.is_empty());
509    }
510
511    #[test]
512    fn test_cgns_node_data_storage() {
513        let mut node = CgnsNode::new(2, "Pressure", "DataArray_t", "R8");
514        node.data = vec![1.0, 2.0, 3.0];
515        assert_eq!(node.data.len(), 3);
516        assert!((node.data[1] - 2.0).abs() < 1e-12);
517    }
518
519    // ── ZoneType tests ───────────────────────────────────────────────────
520
521    #[test]
522    fn test_zone_type_as_str() {
523        assert_eq!(ZoneType::Structured.as_str(), "Structured");
524        assert_eq!(ZoneType::Unstructured.as_str(), "Unstructured");
525    }
526
527    #[test]
528    fn test_zone_type_from_str_structured() {
529        assert_eq!(ZoneType::from_str("Structured"), ZoneType::Structured);
530        assert_eq!(ZoneType::from_str("anything"), ZoneType::Structured);
531    }
532
533    #[test]
534    fn test_zone_type_from_str_unstructured() {
535        assert_eq!(ZoneType::from_str("Unstructured"), ZoneType::Unstructured);
536    }
537
538    // ── CgnsZone tests ───────────────────────────────────────────────────
539
540    #[test]
541    fn test_zone_creation() {
542        let zone = CgnsZone::new("Zone1", ZoneType::Structured);
543        assert_eq!(zone.zone_name, "Zone1");
544        assert_eq!(zone.zone_type, ZoneType::Structured);
545        assert!(zone.x.is_empty());
546    }
547
548    #[test]
549    fn test_zone_set_coords() {
550        let mut zone = CgnsZone::new("Z", ZoneType::Structured);
551        zone.set_coords(vec![0.0, 1.0], vec![0.0, 0.0], vec![0.0, 0.0]);
552        assert_eq!(zone.n_points(), 2);
553        assert!((zone.x[1] - 1.0).abs() < 1e-12);
554    }
555
556    #[test]
557    fn test_zone_n_points() {
558        let mut zone = CgnsZone::new("Z", ZoneType::Unstructured);
559        zone.x = vec![1.0, 2.0, 3.0];
560        assert_eq!(zone.n_points(), 3);
561    }
562
563    #[test]
564    fn test_zone_elements() {
565        let mut zone = CgnsZone::new("Z", ZoneType::Unstructured);
566        zone.elements = vec![1, 2, 3, 4];
567        assert_eq!(zone.elements.len(), 4);
568    }
569
570    // ── CgnsBase tests ───────────────────────────────────────────────────
571
572    #[test]
573    fn test_base_creation() {
574        let base = CgnsBase::new("Base1", 3, 3);
575        assert_eq!(base.base_name, "Base1");
576        assert_eq!(base.cell_dim, 3);
577        assert_eq!(base.phys_dim, 3);
578        assert!(base.zones.is_empty());
579    }
580
581    #[test]
582    fn test_base_add_zone() {
583        let mut base = CgnsBase::new("Base1", 3, 3);
584        base.add_zone(CgnsZone::new("Z1", ZoneType::Structured));
585        base.add_zone(CgnsZone::new("Z2", ZoneType::Unstructured));
586        assert_eq!(base.zones.len(), 2);
587    }
588
589    // ── SolutionLocation tests ───────────────────────────────────────────
590
591    #[test]
592    fn test_solution_location_as_str() {
593        assert_eq!(SolutionLocation::Vertex.as_str(), "Vertex");
594        assert_eq!(SolutionLocation::CellCenter.as_str(), "CellCenter");
595    }
596
597    #[test]
598    fn test_solution_location_from_str() {
599        assert_eq!(
600            SolutionLocation::from_str("Vertex"),
601            SolutionLocation::Vertex
602        );
603        assert_eq!(
604            SolutionLocation::from_str("CellCenter"),
605            SolutionLocation::CellCenter
606        );
607        assert_eq!(
608            SolutionLocation::from_str("other"),
609            SolutionLocation::Vertex
610        );
611    }
612
613    // ── FlowSolution tests ───────────────────────────────────────────────
614
615    #[test]
616    fn test_flow_solution_new() {
617        let sol = FlowSolution::new("FlowSolution", SolutionLocation::Vertex);
618        assert_eq!(sol.solution_name, "FlowSolution");
619        assert_eq!(sol.location, SolutionLocation::Vertex);
620        assert!(sol.fields.is_empty());
621    }
622
623    #[test]
624    fn test_flow_solution_add_field() {
625        let mut sol = FlowSolution::new("Sol", SolutionLocation::CellCenter);
626        sol.add_field("Pressure", vec![1.0, 2.0, 3.0]);
627        assert!(sol.fields.contains_key("Pressure"));
628        assert_eq!(sol.fields["Pressure"].len(), 3);
629    }
630
631    #[test]
632    fn test_flow_solution_multiple_fields() {
633        let mut sol = FlowSolution::new("Sol", SolutionLocation::Vertex);
634        sol.add_field("P", vec![1.0]);
635        sol.add_field("T", vec![300.0]);
636        sol.add_field("U", vec![0.5]);
637        assert_eq!(sol.fields.len(), 3);
638    }
639
640    // ── CgnsFile write/read roundtrip tests ─────────────────────────────
641
642    #[test]
643    fn test_file_write_read_empty() {
644        let file = CgnsFile::new();
645        let path = tmp_path("empty");
646        file.write_text(&path).unwrap();
647        let restored = CgnsFile::read_text(&path).unwrap();
648        assert_eq!(restored.bases.len(), 0);
649        let _ = fs::remove_file(&path);
650    }
651
652    #[test]
653    fn test_file_write_read_single_base() {
654        let mut file = CgnsFile::new();
655        let base = CgnsBase::new("MainBase", 3, 3);
656        file.add_base(base);
657        let path = tmp_path("single_base");
658        file.write_text(&path).unwrap();
659        let restored = CgnsFile::read_text(&path).unwrap();
660        assert_eq!(restored.bases.len(), 1);
661        assert_eq!(restored.bases[0].base_name, "MainBase");
662        let _ = fs::remove_file(&path);
663    }
664
665    #[test]
666    fn test_file_write_read_coords_roundtrip() {
667        let mut file = CgnsFile::new();
668        let mut base = CgnsBase::new("B", 3, 3);
669        let mut zone = CgnsZone::new("Z1", ZoneType::Structured);
670        zone.set_coords(vec![0.0, 1.0, 2.0], vec![0.0, 0.5, 1.0], vec![0.0; 3]);
671        base.add_zone(zone);
672        file.add_base(base);
673        let path = tmp_path("coords_rt");
674        file.write_text(&path).unwrap();
675        let restored = CgnsFile::read_text(&path).unwrap();
676        let rz = &restored.bases[0].zones[0];
677        assert_eq!(rz.x.len(), 3);
678        assert!((rz.x[2] - 2.0).abs() < 1e-10);
679        assert!((rz.y[1] - 0.5).abs() < 1e-10);
680        let _ = fs::remove_file(&path);
681    }
682
683    #[test]
684    fn test_file_write_read_zone_type() {
685        let mut file = CgnsFile::new();
686        let mut base = CgnsBase::new("B", 3, 3);
687        let mut zone = CgnsZone::new("Unstr", ZoneType::Unstructured);
688        zone.elements = vec![1, 2, 3];
689        base.add_zone(zone);
690        file.add_base(base);
691        let path = tmp_path("zone_type");
692        file.write_text(&path).unwrap();
693        let restored = CgnsFile::read_text(&path).unwrap();
694        assert_eq!(restored.bases[0].zones[0].zone_type, ZoneType::Unstructured);
695        let _ = fs::remove_file(&path);
696    }
697
698    #[test]
699    fn test_file_write_read_elements() {
700        let mut file = CgnsFile::new();
701        let mut base = CgnsBase::new("B", 3, 3);
702        let mut zone = CgnsZone::new("Z", ZoneType::Unstructured);
703        zone.elements = vec![1, 2, 3, 4, 5, 6];
704        base.add_zone(zone);
705        file.add_base(base);
706        let path = tmp_path("elements");
707        file.write_text(&path).unwrap();
708        let restored = CgnsFile::read_text(&path).unwrap();
709        assert_eq!(restored.bases[0].zones[0].elements, vec![1, 2, 3, 4, 5, 6]);
710        let _ = fs::remove_file(&path);
711    }
712
713    #[test]
714    fn test_file_write_read_multi_zone() {
715        let mut file = CgnsFile::new();
716        let mut base = CgnsBase::new("B", 3, 3);
717        base.add_zone(CgnsZone::new("Zone1", ZoneType::Structured));
718        base.add_zone(CgnsZone::new("Zone2", ZoneType::Unstructured));
719        file.add_base(base);
720        let path = tmp_path("multi_zone");
721        file.write_text(&path).unwrap();
722        let restored = CgnsFile::read_text(&path).unwrap();
723        assert_eq!(restored.bases[0].zones.len(), 2);
724        assert_eq!(restored.bases[0].zones[1].zone_name, "Zone2");
725        let _ = fs::remove_file(&path);
726    }
727
728    #[test]
729    fn test_file_write_read_multi_base() {
730        let mut file = CgnsFile::new();
731        file.add_base(CgnsBase::new("Base1", 3, 3));
732        file.add_base(CgnsBase::new("Base2", 2, 2));
733        let path = tmp_path("multi_base");
734        file.write_text(&path).unwrap();
735        let restored = CgnsFile::read_text(&path).unwrap();
736        assert_eq!(restored.bases.len(), 2);
737        assert_eq!(restored.bases[1].base_name, "Base2");
738        assert_eq!(restored.bases[1].cell_dim, 2);
739        let _ = fs::remove_file(&path);
740    }
741
742    #[test]
743    fn test_file_invalid_header() {
744        let result = CgnsReader::parse("NOT_CGNS\n");
745        assert!(result.is_err());
746    }
747
748    #[test]
749    fn test_file_default() {
750        let f = CgnsFile::default();
751        assert!(f.bases.is_empty());
752    }
753
754    // ── CgnsWriter tests ─────────────────────────────────────────────────
755
756    #[test]
757    fn test_writer_structured_grid_no_solution() {
758        let mut writer = CgnsWriter::new();
759        let path = tmp_path("writer_noSol");
760        writer
761            .write_structured_grid(
762                "B",
763                "Z",
764                vec![0.0, 1.0],
765                vec![0.0, 0.0],
766                vec![0.0, 0.0],
767                None,
768                &path,
769            )
770            .unwrap();
771        let content = fs::read_to_string(&path).unwrap();
772        assert!(content.contains("CGNS_TEXT_V1"));
773        assert!(content.contains("Structured"));
774        let _ = fs::remove_file(&path);
775    }
776
777    #[test]
778    fn test_writer_structured_grid_with_solution() {
779        let mut writer = CgnsWriter::new();
780        let mut sol = FlowSolution::new("Sol", SolutionLocation::Vertex);
781        sol.add_field("Pressure", vec![101325.0, 101300.0]);
782        let path = tmp_path("writer_sol");
783        writer
784            .write_structured_grid(
785                "B",
786                "Z",
787                vec![0.0, 1.0],
788                vec![0.0, 0.0],
789                vec![0.0, 0.0],
790                Some(sol),
791                &path,
792            )
793            .unwrap();
794        let sol_path = format!("{path}.sol");
795        let sol_content = fs::read_to_string(&sol_path).unwrap();
796        assert!(sol_content.contains("Pressure"));
797        let _ = fs::remove_file(&path);
798        let _ = fs::remove_file(&sol_path);
799    }
800
801    #[test]
802    fn test_writer_default() {
803        let w = CgnsWriter::default();
804        assert!(w.file.bases.is_empty());
805    }
806
807    // ── CgnsReader tests ─────────────────────────────────────────────────
808
809    #[test]
810    fn test_reader_parse_minimal() {
811        let text = "CGNS_TEXT_V1\nN_BASES 0\nEND_FILE\n";
812        let file = CgnsReader::parse(text).unwrap();
813        assert_eq!(file.bases.len(), 0);
814    }
815
816    #[test]
817    fn test_reader_parse_with_zone_coords() {
818        let text = concat!(
819            "CGNS_TEXT_V1\n",
820            "N_BASES 1\n",
821            "BASE MyBase 3 3\n",
822            "N_ZONES 1\n",
823            "ZONE Z1 Structured\n",
824            "N_POINTS 2\n",
825            "X 0 1\n",
826            "Y 0 0\n",
827            "Z 0 0\n",
828            "ELEMENTS 0 \n",
829            "END_ZONE\n",
830            "END_BASE\n",
831            "END_FILE\n",
832        );
833        let file = CgnsReader::parse(text).unwrap();
834        assert_eq!(file.bases[0].zones[0].x.len(), 2);
835    }
836}