Skip to main content

oxiphysics_io/
tecplot_format.rs

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