Skip to main content

oxiphysics_io/
tecplot_format.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tecplot ASCII format I/O.
5//!
6//! Supports reading and writing Tecplot `.dat` / `.plt` ASCII files with
7//! ORDERED and FE (finite element) zone types.  The implementation covers the
8//! minimal subset of the Tecplot ASCII specification needed for typical physics
9//! simulation output.
10//!
11//! # Quick start
12//!
13//! ```no_run
14//! use oxiphysics_io::tecplot_format::{
15//!     TecplotDataset, TecplotVariable, TecplotZone, TecplotZoneType,
16//! };
17//!
18//! let mut ds = TecplotDataset::new("My Dataset");
19//! ds.variables = vec!["X".to_string(), "Y".to_string(), "P".to_string()];
20//! let mut zone = TecplotZone::new_ordered("Zone 1", 3, 1, 1);
21//! zone.variables.push(TecplotVariable::new("X", vec![0.0, 1.0, 2.0]));
22//! zone.variables.push(TecplotVariable::new("Y", vec![0.0, 0.0, 0.0]));
23//! zone.variables.push(TecplotVariable::new("P", vec![1.0, 2.0, 3.0]));
24//! ds.zones.push(zone);
25//! ds.write("/tmp/quick_start_test.dat").unwrap();
26//! ```
27
28use std::fmt::Write as FmtWrite;
29use std::fs;
30use std::io::{self, BufRead};
31
32use crate::Error as IoError;
33
34// ---------------------------------------------------------------------------
35// TecplotZoneType
36// ---------------------------------------------------------------------------
37
38/// Tecplot zone topology.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum TecplotZoneType {
41    /// Structured (IJK-ordered) zone.
42    Ordered,
43    /// Unstructured zone — triangular elements.
44    FETriangle,
45    /// Unstructured zone — quadrilateral elements.
46    FEQuad,
47    /// Unstructured zone — tetrahedral elements.
48    FETetra,
49    /// Unstructured zone — hexahedral (brick) elements.
50    FEBrick,
51}
52
53impl TecplotZoneType {
54    /// Return the Tecplot ASCII keyword for this zone type.
55    pub fn as_tecplot_str(&self) -> &'static str {
56        match self {
57            TecplotZoneType::Ordered => "ORDERED",
58            TecplotZoneType::FETriangle => "FETRIANGLE",
59            TecplotZoneType::FEQuad => "FEQUADRILATERAL",
60            TecplotZoneType::FETetra => "FETETRAHEDRON",
61            TecplotZoneType::FEBrick => "FEBRICK",
62        }
63    }
64
65    /// Parse from a Tecplot ASCII keyword (case-insensitive).
66    pub fn from_keyword(s: &str) -> Self {
67        Self::from(s)
68    }
69}
70
71impl From<&str> for TecplotZoneType {
72    fn from(s: &str) -> Self {
73        match s.trim().to_uppercase().as_str() {
74            "ORDERED" => TecplotZoneType::Ordered,
75            "FETRIANGLE" | "FE_TRIANGLE" => TecplotZoneType::FETriangle,
76            "FEQUADRILATERAL" | "FEQUAD" | "FE_QUAD" => TecplotZoneType::FEQuad,
77            "FETETRAHEDRON" | "FETETRA" | "FE_TETRA" => TecplotZoneType::FETetra,
78            "FEBRICK" | "FE_BRICK" => TecplotZoneType::FEBrick,
79            _ => TecplotZoneType::Ordered,
80        }
81    }
82}
83
84// ---------------------------------------------------------------------------
85// TecplotVariable
86// ---------------------------------------------------------------------------
87
88/// A single named variable (field) in a Tecplot zone.
89#[derive(Debug, Clone, PartialEq)]
90pub struct TecplotVariable {
91    /// Variable name (e.g. `"X"`, `"Pressure"`, `"Velocity_X"`).
92    pub name: String,
93    /// Per-node/per-cell data values.
94    pub data: Vec<f64>,
95}
96
97impl TecplotVariable {
98    /// Create a new variable with the given name and data.
99    pub fn new(name: impl Into<String>, data: Vec<f64>) -> Self {
100        Self {
101            name: name.into(),
102            data,
103        }
104    }
105
106    /// Return the number of data values stored.
107    pub fn len(&self) -> usize {
108        self.data.len()
109    }
110
111    /// Return `true` when no data values are stored.
112    pub fn is_empty(&self) -> bool {
113        self.data.is_empty()
114    }
115}
116
117// ---------------------------------------------------------------------------
118// TecplotZone
119// ---------------------------------------------------------------------------
120
121/// A single Tecplot zone containing one or more variables.
122#[derive(Debug, Clone)]
123pub struct TecplotZone {
124    /// Zone title string.
125    pub title: String,
126    /// Zone topology.
127    pub zone_type: TecplotZoneType,
128    /// Number of nodes in the I direction (ordered zones).
129    pub i_dim: usize,
130    /// Number of nodes in the J direction (ordered zones).
131    pub j_dim: usize,
132    /// Number of nodes in the K direction (ordered zones).
133    pub k_dim: usize,
134    /// Variable data for this zone.
135    pub variables: Vec<TecplotVariable>,
136    /// Number of nodes (FE zones).
137    pub n_nodes: usize,
138    /// Number of elements (FE zones).
139    pub n_elements: usize,
140}
141
142impl TecplotZone {
143    /// Create a new ordered zone with the given IJK dimensions.
144    pub fn new_ordered(title: impl Into<String>, i_dim: usize, j_dim: usize, k_dim: usize) -> Self {
145        Self {
146            title: title.into(),
147            zone_type: TecplotZoneType::Ordered,
148            i_dim,
149            j_dim,
150            k_dim,
151            variables: Vec::new(),
152            n_nodes: i_dim * j_dim * k_dim,
153            n_elements: 0,
154        }
155    }
156
157    /// Create a new finite-element zone.
158    pub fn new_fe(
159        title: impl Into<String>,
160        zone_type: TecplotZoneType,
161        n_nodes: usize,
162        n_elements: usize,
163    ) -> Self {
164        Self {
165            title: title.into(),
166            zone_type,
167            i_dim: 0,
168            j_dim: 0,
169            k_dim: 0,
170            variables: Vec::new(),
171            n_nodes,
172            n_elements,
173        }
174    }
175
176    /// Total number of data points expected in this zone.
177    pub fn point_count(&self) -> usize {
178        match self.zone_type {
179            TecplotZoneType::Ordered => self.i_dim * self.j_dim * self.k_dim,
180            _ => self.n_nodes,
181        }
182    }
183}
184
185// ---------------------------------------------------------------------------
186// TecplotDataset
187// ---------------------------------------------------------------------------
188
189/// A complete Tecplot dataset (title + variable list + zones).
190#[derive(Debug, Clone, Default)]
191pub struct TecplotDataset {
192    /// Dataset title.
193    pub title: String,
194    /// Ordered list of variable names.
195    pub variables: Vec<String>,
196    /// Zones contained in the dataset.
197    pub zones: Vec<TecplotZone>,
198}
199
200impl TecplotDataset {
201    /// Create an empty dataset with a title.
202    pub fn new(title: impl Into<String>) -> Self {
203        Self {
204            title: title.into(),
205            variables: Vec::new(),
206            zones: Vec::new(),
207        }
208    }
209
210    /// Write the dataset to `path` in Tecplot ASCII format.
211    ///
212    /// Returns an error if the file cannot be created or written.
213    pub fn write(&self, path: &str) -> Result<(), IoError> {
214        let writer = TecplotWriter::new();
215        writer.write(self, path)
216    }
217
218    /// Read a Tecplot ASCII file from `path` and return the parsed dataset.
219    ///
220    /// Returns an error if the file cannot be opened or parsed.
221    pub fn read(path: &str) -> Result<Self, IoError> {
222        TecplotReader::new().parse(path)
223    }
224}
225
226// ---------------------------------------------------------------------------
227// TecplotWriter
228// ---------------------------------------------------------------------------
229
230/// Writes a `TecplotDataset` to a Tecplot ASCII file.
231#[derive(Debug, Clone, Default)]
232pub struct TecplotWriter;
233
234impl TecplotWriter {
235    /// Create a new writer.
236    pub fn new() -> Self {
237        Self
238    }
239
240    /// Serialize `dataset` to `path` using point data packing (one record per
241    /// node, all variables interleaved).
242    pub fn write(&self, dataset: &TecplotDataset, path: &str) -> Result<(), IoError> {
243        let mut buf = String::new();
244
245        // --- File header ---
246        let _ = writeln!(buf, "TITLE = \"{}\"", dataset.title);
247
248        if !dataset.variables.is_empty() {
249            let vars: Vec<String> = dataset
250                .variables
251                .iter()
252                .map(|v| format!("\"{v}\""))
253                .collect();
254            let _ = writeln!(buf, "VARIABLES = {}", vars.join(", "));
255        }
256
257        // --- Zones ---
258        for zone in &dataset.zones {
259            self.write_zone(&mut buf, zone);
260        }
261
262        fs::write(path, buf).map_err(IoError::Io)
263    }
264
265    /// Append one zone to `buf`.
266    fn write_zone(&self, buf: &mut String, zone: &TecplotZone) {
267        match zone.zone_type {
268            TecplotZoneType::Ordered => {
269                writeln!(
270                    buf,
271                    "ZONE T=\"{}\", I={}, J={}, K={}, DATAPACKING=POINT",
272                    zone.title, zone.i_dim, zone.j_dim, zone.k_dim
273                )
274                .expect("operation should succeed");
275                self.write_point_data(buf, zone);
276            }
277            _ => {
278                writeln!(
279                    buf,
280                    "ZONE T=\"{}\", N={}, E={}, ZONETYPE={}, DATAPACKING=POINT",
281                    zone.title,
282                    zone.n_nodes,
283                    zone.n_elements,
284                    zone.zone_type.as_tecplot_str()
285                )
286                .expect("operation should succeed");
287                self.write_point_data(buf, zone);
288            }
289        }
290    }
291
292    /// Write variable data in POINT packing (all vars for node 0, then node 1, …).
293    fn write_point_data(&self, buf: &mut String, zone: &TecplotZone) {
294        if zone.variables.is_empty() {
295            return;
296        }
297        let n = zone.point_count();
298        for idx in 0..n {
299            let row: Vec<String> = zone
300                .variables
301                .iter()
302                .map(|v| {
303                    if idx < v.data.len() {
304                        format!("{:.15e}", v.data[idx])
305                    } else {
306                        "0.000000000000000e0".to_string()
307                    }
308                })
309                .collect();
310            let _ = writeln!(buf, "{}", row.join(" "));
311        }
312    }
313}
314
315// ---------------------------------------------------------------------------
316// TecplotReader
317// ---------------------------------------------------------------------------
318
319/// Parses a subset of Tecplot ASCII files.
320///
321/// Handles `TITLE`, `VARIABLES`, and `ZONE` header lines followed by
322/// whitespace-delimited floating-point data blocks.
323#[derive(Debug, Clone, Default)]
324pub struct TecplotReader;
325
326impl TecplotReader {
327    /// Create a new reader.
328    pub fn new() -> Self {
329        Self
330    }
331
332    /// Parse the Tecplot ASCII file at `path` and return a `TecplotDataset`.
333    pub fn parse(&self, path: &str) -> Result<TecplotDataset, IoError> {
334        let file = fs::File::open(path).map_err(IoError::Io)?;
335        let reader = io::BufReader::new(file);
336
337        let mut dataset = TecplotDataset::new("");
338        let mut current_zone: Option<TecplotZone> = None;
339        // How many data points remain for the current zone
340        let mut remaining_points: usize = 0;
341        // Collected raw floats for the current zone before distributing
342        let mut zone_floats: Vec<f64> = Vec::new();
343
344        for line_res in reader.lines() {
345            let line = line_res.map_err(IoError::Io)?;
346            let trimmed = line.trim();
347
348            if trimmed.is_empty() || trimmed.starts_with('#') {
349                continue;
350            }
351
352            let upper = trimmed.to_uppercase();
353
354            // ── TITLE ──────────────────────────────────────────────────────
355            if upper.starts_with("TITLE") {
356                dataset.title =
357                    Self::extract_quoted_value(trimmed).unwrap_or_else(|| trimmed.to_string());
358                continue;
359            }
360
361            // ── VARIABLES ──────────────────────────────────────────────────
362            if upper.starts_with("VARIABLES") {
363                dataset.variables = Self::parse_variables_line(trimmed);
364                continue;
365            }
366
367            // ── ZONE ───────────────────────────────────────────────────────
368            if upper.starts_with("ZONE") {
369                // Flush previous zone
370                if let Some(mut z) = current_zone.take() {
371                    Self::distribute_floats(&mut z, &zone_floats);
372                    dataset.zones.push(z);
373                }
374                zone_floats.clear();
375
376                let zone = Self::parse_zone_header(trimmed, &dataset.variables);
377                remaining_points = zone.point_count();
378                current_zone = Some(zone);
379                continue;
380            }
381
382            // ── Data line ──────────────────────────────────────────────────
383            if current_zone.is_some() && remaining_points > 0 {
384                for token in trimmed.split_whitespace() {
385                    if let Ok(v) = token.parse::<f64>() {
386                        zone_floats.push(v);
387                    }
388                }
389                // Count rows written (POINT packing: one row = one point)
390                let nvars = dataset.variables.len().max(1);
391                let rows_so_far = zone_floats.len() / nvars;
392                if rows_so_far >= remaining_points {
393                    remaining_points = 0;
394                }
395            }
396        }
397
398        // Flush last zone
399        if let Some(mut z) = current_zone.take() {
400            Self::distribute_floats(&mut z, &zone_floats);
401            dataset.zones.push(z);
402        }
403
404        Ok(dataset)
405    }
406
407    /// Distribute a flat float buffer into per-variable data (POINT packing).
408    fn distribute_floats(zone: &mut TecplotZone, floats: &[f64]) {
409        let nvars = zone.variables.len();
410        if nvars == 0 {
411            return;
412        }
413        for (idx, &v) in floats.iter().enumerate() {
414            let var_idx = idx % nvars;
415            if var_idx < zone.variables.len() {
416                zone.variables[var_idx].data.push(v);
417            }
418        }
419    }
420
421    /// Parse a ZONE header line and return a skeleton `TecplotZone`.
422    fn parse_zone_header(line: &str, var_names: &[String]) -> TecplotZone {
423        let title = Self::extract_param_value(line, "T")
424            .or_else(|| Self::extract_param_value(line, "TITLE"))
425            .unwrap_or_default();
426
427        let i_dim = Self::extract_param_value(line, "I")
428            .and_then(|s| s.parse().ok())
429            .unwrap_or(1);
430        let j_dim = Self::extract_param_value(line, "J")
431            .and_then(|s| s.parse().ok())
432            .unwrap_or(1);
433        let k_dim = Self::extract_param_value(line, "K")
434            .and_then(|s| s.parse().ok())
435            .unwrap_or(1);
436
437        let n_nodes = Self::extract_param_value(line, "N")
438            .and_then(|s| s.parse().ok())
439            .unwrap_or(0);
440        let n_elements = Self::extract_param_value(line, "E")
441            .and_then(|s| s.parse().ok())
442            .unwrap_or(0);
443
444        let zone_type_str = Self::extract_param_value(line, "ZONETYPE")
445            .or_else(|| Self::extract_param_value(line, "F"))
446            .unwrap_or_else(|| "ORDERED".to_string());
447        let zone_type = TecplotZoneType::from_keyword(&zone_type_str);
448
449        let mut zone = if zone_type == TecplotZoneType::Ordered {
450            TecplotZone::new_ordered(title, i_dim, j_dim, k_dim)
451        } else {
452            TecplotZone::new_fe(title, zone_type, n_nodes, n_elements)
453        };
454
455        // Pre-populate empty variable slots
456        for name in var_names {
457            zone.variables
458                .push(TecplotVariable::new(name.clone(), Vec::new()));
459        }
460
461        zone
462    }
463
464    /// Extract the value of `KEY=value` from a keyword line (case-insensitive).
465    ///
466    /// Handles quoted values, e.g. `T="My Zone"`.
467    fn extract_param_value(line: &str, key: &str) -> Option<String> {
468        let upper = line.to_uppercase();
469        let key_eq = format!("{key}=");
470        // Search case-insensitively
471        let pos = upper.find(&key_eq.to_uppercase())?;
472        let rest = &line[pos + key_eq.len()..];
473        let rest_trimmed = rest.trim_start();
474        if let Some(inner) = rest_trimmed.strip_prefix('"') {
475            // Quoted value
476            let end = inner.find('"').unwrap_or(inner.len());
477            Some(inner[..end].to_string())
478        } else {
479            // Unquoted: ends at next comma, space, or end of string
480            let end = rest_trimmed.find([',', ' ']).unwrap_or(rest_trimmed.len());
481            Some(rest_trimmed[..end].trim().to_string())
482        }
483    }
484
485    /// Extract a top-level quoted value: `KEY = "value"`.
486    fn extract_quoted_value(line: &str) -> Option<String> {
487        let start = line.find('"')?;
488        let rest = &line[start + 1..];
489        let end = rest.find('"').unwrap_or(rest.len());
490        Some(rest[..end].to_string())
491    }
492
493    /// Parse a `VARIABLES = "X" "Y" "P"` line into a list of variable names.
494    fn parse_variables_line(line: &str) -> Vec<String> {
495        // Strip up to and including '='
496        let after_eq = if let Some(pos) = line.find('=') {
497            &line[pos + 1..]
498        } else {
499            line
500        };
501        let mut names = Vec::new();
502        let mut chars = after_eq.chars().peekable();
503        while let Some(&c) = chars.peek() {
504            if c == '"' {
505                chars.next(); // consume opening quote
506                let mut name = String::new();
507                for ch in chars.by_ref() {
508                    if ch == '"' {
509                        break;
510                    }
511                    name.push(ch);
512                }
513                if !name.is_empty() {
514                    names.push(name);
515                }
516            } else {
517                chars.next();
518            }
519        }
520        names
521    }
522}
523
524// ---------------------------------------------------------------------------
525// Tests
526// ---------------------------------------------------------------------------
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    // ── TecplotZoneType ─────────────────────────────────────────────────────
533
534    #[test]
535    fn test_zone_type_as_str_ordered() {
536        assert_eq!(TecplotZoneType::Ordered.as_tecplot_str(), "ORDERED");
537    }
538
539    #[test]
540    fn test_zone_type_as_str_fe_triangle() {
541        assert_eq!(TecplotZoneType::FETriangle.as_tecplot_str(), "FETRIANGLE");
542    }
543
544    #[test]
545    fn test_zone_type_as_str_fe_quad() {
546        assert_eq!(TecplotZoneType::FEQuad.as_tecplot_str(), "FEQUADRILATERAL");
547    }
548
549    #[test]
550    fn test_zone_type_as_str_fe_tetra() {
551        assert_eq!(TecplotZoneType::FETetra.as_tecplot_str(), "FETETRAHEDRON");
552    }
553
554    #[test]
555    fn test_zone_type_as_str_fe_brick() {
556        assert_eq!(TecplotZoneType::FEBrick.as_tecplot_str(), "FEBRICK");
557    }
558
559    #[test]
560    fn test_zone_type_from_str_ordered() {
561        assert_eq!(
562            TecplotZoneType::from_keyword("ORDERED"),
563            TecplotZoneType::Ordered
564        );
565    }
566
567    #[test]
568    fn test_zone_type_from_str_fe_triangle() {
569        assert_eq!(
570            TecplotZoneType::from_keyword("FETRIANGLE"),
571            TecplotZoneType::FETriangle
572        );
573    }
574
575    #[test]
576    fn test_zone_type_from_str_fe_tetra() {
577        assert_eq!(
578            TecplotZoneType::from_keyword("FETETRAHEDRON"),
579            TecplotZoneType::FETetra
580        );
581    }
582
583    #[test]
584    fn test_zone_type_from_str_case_insensitive() {
585        assert_eq!(
586            TecplotZoneType::from_keyword("ordered"),
587            TecplotZoneType::Ordered
588        );
589    }
590
591    #[test]
592    fn test_zone_type_from_str_unknown_defaults_to_ordered() {
593        assert_eq!(
594            TecplotZoneType::from_keyword("UNKNOWN"),
595            TecplotZoneType::Ordered
596        );
597    }
598
599    // ── TecplotVariable ─────────────────────────────────────────────────────
600
601    #[test]
602    fn test_variable_new() {
603        let v = TecplotVariable::new("Pressure", vec![1.0, 2.0, 3.0]);
604        assert_eq!(v.name, "Pressure");
605        assert_eq!(v.len(), 3);
606        assert!(!v.is_empty());
607    }
608
609    #[test]
610    fn test_variable_empty() {
611        let v = TecplotVariable::new("X", vec![]);
612        assert!(v.is_empty());
613    }
614
615    #[test]
616    fn test_variable_data_values() {
617        let v = TecplotVariable::new("T", vec![100.0, 200.0]);
618        assert!((v.data[0] - 100.0).abs() < 1e-12);
619        assert!((v.data[1] - 200.0).abs() < 1e-12);
620    }
621
622    // ── TecplotZone ─────────────────────────────────────────────────────────
623
624    #[test]
625    fn test_zone_new_ordered() {
626        let z = TecplotZone::new_ordered("Zone 1", 5, 3, 2);
627        assert_eq!(z.zone_type, TecplotZoneType::Ordered);
628        assert_eq!(z.i_dim, 5);
629        assert_eq!(z.j_dim, 3);
630        assert_eq!(z.k_dim, 2);
631        assert_eq!(z.point_count(), 30);
632    }
633
634    #[test]
635    fn test_zone_new_fe() {
636        let z = TecplotZone::new_fe("FE Zone", TecplotZoneType::FETetra, 8, 2);
637        assert_eq!(z.zone_type, TecplotZoneType::FETetra);
638        assert_eq!(z.n_nodes, 8);
639        assert_eq!(z.n_elements, 2);
640        assert_eq!(z.point_count(), 8);
641    }
642
643    #[test]
644    fn test_zone_point_count_ordered_1d() {
645        let z = TecplotZone::new_ordered("1D", 10, 1, 1);
646        assert_eq!(z.point_count(), 10);
647    }
648
649    #[test]
650    fn test_zone_stores_variables() {
651        let mut z = TecplotZone::new_ordered("Z", 2, 1, 1);
652        z.variables.push(TecplotVariable::new("X", vec![0.0, 1.0]));
653        assert_eq!(z.variables.len(), 1);
654        assert_eq!(z.variables[0].name, "X");
655    }
656
657    // ── TecplotDataset ──────────────────────────────────────────────────────
658
659    #[test]
660    fn test_dataset_new() {
661        let ds = TecplotDataset::new("Test");
662        assert_eq!(ds.title, "Test");
663        assert!(ds.zones.is_empty());
664        assert!(ds.variables.is_empty());
665    }
666
667    #[test]
668    fn test_dataset_default() {
669        let ds = TecplotDataset::default();
670        assert!(ds.title.is_empty());
671    }
672
673    // ── TecplotWriter ───────────────────────────────────────────────────────
674
675    fn make_1d_dataset(path: &str, n: usize) -> TecplotDataset {
676        let _ = path; // unused in builder
677        let mut ds = TecplotDataset::new("Test Dataset");
678        ds.variables = vec!["X".to_string(), "P".to_string()];
679        let mut zone = TecplotZone::new_ordered("Zone 1", n, 1, 1);
680        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
681        let p: Vec<f64> = (0..n).map(|i| (i as f64) * 2.0).collect();
682        zone.variables.push(TecplotVariable::new("X", x));
683        zone.variables.push(TecplotVariable::new("P", p));
684        ds.zones.push(zone);
685        ds
686    }
687
688    #[test]
689    fn test_write_creates_file() {
690        let path = std::env::temp_dir().join("oxiphysics_tecplot_write_test.dat");
691        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 5);
692        TecplotWriter::new()
693            .write(&ds, path.to_str().unwrap_or(""))
694            .expect("write failed");
695        assert!(path.exists());
696    }
697
698    #[test]
699    fn test_write_contains_title() {
700        let path = std::env::temp_dir().join("oxiphysics_tecplot_title.dat");
701        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
702        TecplotWriter::new()
703            .write(&ds, path.to_str().unwrap_or(""))
704            .unwrap();
705        let content = fs::read_to_string(&path).unwrap();
706        assert!(content.contains("Test Dataset"), "no title in output");
707    }
708
709    #[test]
710    fn test_write_contains_variables() {
711        let path = std::env::temp_dir().join("oxiphysics_tecplot_vars.dat");
712        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
713        TecplotWriter::new()
714            .write(&ds, path.to_str().unwrap_or(""))
715            .unwrap();
716        let content = fs::read_to_string(&path).unwrap();
717        assert!(content.contains("VARIABLES"), "no VARIABLES header");
718        assert!(content.contains('"'), "variables not quoted");
719    }
720
721    #[test]
722    fn test_write_contains_zone_keyword() {
723        let path = std::env::temp_dir().join("oxiphysics_tecplot_zone_kw.dat");
724        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
725        TecplotWriter::new()
726            .write(&ds, path.to_str().unwrap_or(""))
727            .unwrap();
728        let content = fs::read_to_string(&path).unwrap();
729        assert!(content.contains("ZONE"), "no ZONE header");
730    }
731
732    #[test]
733    fn test_write_ordered_has_ijk() {
734        let path = std::env::temp_dir().join("oxiphysics_tecplot_ijk.dat");
735        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
736        TecplotWriter::new()
737            .write(&ds, path.to_str().unwrap_or(""))
738            .unwrap();
739        let content = fs::read_to_string(&path).unwrap();
740        assert!(content.contains("I="), "no I= in ZONE header");
741        assert!(content.contains("J="), "no J= in ZONE header");
742        assert!(content.contains("K="), "no K= in ZONE header");
743    }
744
745    #[test]
746    fn test_write_data_lines_count() {
747        // A 1D ordered zone with I=4 should produce exactly 4 data rows
748        let path = std::env::temp_dir().join("oxiphysics_tecplot_datarows.dat");
749        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 4);
750        TecplotWriter::new()
751            .write(&ds, path.to_str().unwrap_or(""))
752            .unwrap();
753        let content = fs::read_to_string(&path).unwrap();
754        // Count lines that start with a digit or '-' (data lines)
755        let data_lines = content
756            .lines()
757            .filter(|l| {
758                let t = l.trim();
759                t.starts_with(|c: char| c.is_ascii_digit() || c == '-')
760            })
761            .count();
762        assert_eq!(data_lines, 4, "expected 4 data rows, got {data_lines}");
763    }
764
765    #[test]
766    fn test_write_fe_zone() {
767        let path = std::env::temp_dir().join("oxiphysics_tecplot_fe_zone.dat");
768        let mut ds = TecplotDataset::new("FE Dataset");
769        ds.variables = vec!["X".to_string()];
770        let mut zone = TecplotZone::new_fe("FE Zone", TecplotZoneType::FETetra, 4, 1);
771        zone.variables
772            .push(TecplotVariable::new("X", vec![0.0, 1.0, 0.0, 0.0]));
773        ds.zones.push(zone);
774        TecplotWriter::new()
775            .write(&ds, path.to_str().unwrap_or(""))
776            .unwrap();
777        let content = fs::read_to_string(&path).unwrap();
778        assert!(content.contains("FETETRAHEDRON"));
779    }
780
781    // ── Write/Read roundtrip ────────────────────────────────────────────────
782
783    #[test]
784    fn test_roundtrip_title() {
785        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_title.dat");
786        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
787        ds.write(path.to_str().unwrap_or("")).unwrap();
788        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
789        assert_eq!(parsed.title, "Test Dataset");
790    }
791
792    #[test]
793    fn test_roundtrip_variable_names() {
794        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_varnames.dat");
795        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
796        ds.write(path.to_str().unwrap_or("")).unwrap();
797        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
798        assert_eq!(parsed.variables, vec!["X", "P"]);
799    }
800
801    #[test]
802    fn test_roundtrip_zone_count() {
803        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_nzones.dat");
804        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
805        ds.write(path.to_str().unwrap_or("")).unwrap();
806        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
807        assert_eq!(parsed.zones.len(), 1, "expected 1 zone");
808    }
809
810    #[test]
811    fn test_roundtrip_zone_type_ordered() {
812        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_ztype.dat");
813        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
814        ds.write(path.to_str().unwrap_or("")).unwrap();
815        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
816        assert_eq!(parsed.zones[0].zone_type, TecplotZoneType::Ordered);
817    }
818
819    #[test]
820    fn test_roundtrip_zone_i_dim() {
821        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_idim.dat");
822        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 5);
823        ds.write(path.to_str().unwrap_or("")).unwrap();
824        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
825        assert_eq!(parsed.zones[0].i_dim, 5);
826    }
827
828    #[test]
829    fn test_roundtrip_data_values() {
830        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_data.dat");
831        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
832        ds.write(path.to_str().unwrap_or("")).unwrap();
833        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
834        let zone = &parsed.zones[0];
835        // X variable should be [0,1,2]
836        let x_var = zone.variables.iter().find(|v| v.name == "X").unwrap();
837        assert!((x_var.data[0]).abs() < 1e-6);
838        assert!((x_var.data[1] - 1.0).abs() < 1e-6);
839        assert!((x_var.data[2] - 2.0).abs() < 1e-6);
840    }
841
842    #[test]
843    fn test_roundtrip_second_variable() {
844        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_p.dat");
845        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 3);
846        ds.write(path.to_str().unwrap_or("")).unwrap();
847        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
848        let zone = &parsed.zones[0];
849        let p_var = zone.variables.iter().find(|v| v.name == "P").unwrap();
850        assert!((p_var.data[0]).abs() < 1e-6, "P[0]={}", p_var.data[0]);
851        assert!((p_var.data[1] - 2.0).abs() < 1e-6, "P[1]={}", p_var.data[1]);
852        assert!((p_var.data[2] - 4.0).abs() < 1e-6, "P[2]={}", p_var.data[2]);
853    }
854
855    #[test]
856    fn test_roundtrip_multi_zone() {
857        let path = std::env::temp_dir().join("oxiphysics_tecplot_rt_mz.dat");
858        let mut ds = TecplotDataset::new("Multi");
859        ds.variables = vec!["X".to_string()];
860        for i in 0..3usize {
861            let mut z = TecplotZone::new_ordered(format!("Zone {i}"), 2, 1, 1);
862            z.variables
863                .push(TecplotVariable::new("X", vec![i as f64, i as f64 + 1.0]));
864            ds.zones.push(z);
865        }
866        ds.write(path.to_str().unwrap_or("")).unwrap();
867        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
868        assert_eq!(parsed.zones.len(), 3, "expected 3 zones");
869    }
870
871    #[test]
872    fn test_read_missing_file_returns_error() {
873        let path = std::env::temp_dir().join("no_such_file_oxiphysics_tec.dat");
874        let result = TecplotDataset::read(path.to_str().unwrap_or(""));
875        assert!(result.is_err());
876    }
877
878    #[test]
879    fn test_roundtrip_large_ordered_zone() {
880        let path = std::env::temp_dir().join("oxiphysics_tecplot_large.dat");
881        let n = 50;
882        let ds = make_1d_dataset(path.to_str().unwrap_or(""), n);
883        ds.write(path.to_str().unwrap_or("")).unwrap();
884        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
885        let zone = &parsed.zones[0];
886        assert_eq!(zone.i_dim, n);
887        let x_var = zone.variables.iter().find(|v| v.name == "X").unwrap();
888        assert_eq!(x_var.data.len(), n);
889    }
890
891    #[test]
892    fn test_roundtrip_3d_zone() {
893        let path = std::env::temp_dir().join("oxiphysics_tecplot_3d.dat");
894        let mut ds = TecplotDataset::new("3D");
895        ds.variables = vec!["X".to_string()];
896        let n = 2usize;
897        let total = n * n * n;
898        let mut zone = TecplotZone::new_ordered("3D Zone", n, n, n);
899        let x: Vec<f64> = (0..total).map(|i| i as f64).collect();
900        zone.variables.push(TecplotVariable::new("X", x));
901        ds.zones.push(zone);
902        ds.write(path.to_str().unwrap_or("")).unwrap();
903        let parsed = TecplotDataset::read(path.to_str().unwrap_or("")).unwrap();
904        assert_eq!(parsed.zones[0].i_dim, n);
905        assert_eq!(parsed.zones[0].j_dim, n);
906        assert_eq!(parsed.zones[0].k_dim, n);
907    }
908
909    // ── TecplotWriter helpers ────────────────────────────────────────────────
910
911    #[test]
912    fn test_writer_datapacking_point_keyword() {
913        let path = std::env::temp_dir().join("oxiphysics_tecplot_dpkw.dat");
914        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 2);
915        TecplotWriter::new()
916            .write(&ds, path.to_str().unwrap_or(""))
917            .unwrap();
918        let content = fs::read_to_string(&path).unwrap();
919        assert!(content.contains("DATAPACKING=POINT"));
920    }
921
922    #[test]
923    fn test_writer_zone_title_in_output() {
924        let path = std::env::temp_dir().join("oxiphysics_tecplot_ztitle.dat");
925        let ds = make_1d_dataset(path.to_str().unwrap_or(""), 2);
926        TecplotWriter::new()
927            .write(&ds, path.to_str().unwrap_or(""))
928            .unwrap();
929        let content = fs::read_to_string(&path).unwrap();
930        assert!(content.contains("Zone 1"), "zone title missing");
931    }
932
933    // ── Variable line parser ────────────────────────────────────────────────
934
935    #[test]
936    fn test_parse_variables_line_three_vars() {
937        let line = r#"VARIABLES = "X" "Y" "Z""#;
938        let vars = TecplotReader::parse_variables_line(line);
939        assert_eq!(vars, vec!["X", "Y", "Z"]);
940    }
941
942    #[test]
943    fn test_parse_variables_line_with_commas() {
944        let line = r#"VARIABLES = "X", "P""#;
945        let vars = TecplotReader::parse_variables_line(line);
946        assert_eq!(vars, vec!["X", "P"]);
947    }
948
949    #[test]
950    fn test_parse_variables_line_single() {
951        let line = r#"VARIABLES = "T""#;
952        let vars = TecplotReader::parse_variables_line(line);
953        assert_eq!(vars, vec!["T"]);
954    }
955}