Files
gridcraft/crates/xlsx/src/tests/csv_tests.rs
aiongg d5cdd2ea2b Integration: track t15
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
2026-10-22 03:05:39 +00:00

329 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
use gridcraft_model::{Sheet, Workbook};
use crate::csv::{decode_text, parse_records, sniff_delimiter};
use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited};
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
wb.sheet(0).unwrap().value(CellRef::new(r, c))
}
#[test]
fn quoting_and_newlines() {
let src = "a,\"b,c\",\"say \"\"hi\"\"\"\r\n\"multi\nline\",,3\n\nlast";
let wb = read_csv(src.as_bytes(), &CsvOptions::default()).unwrap();
assert_eq!(wb.sheets[0].name, "Sheet1");
assert_eq!(v(&wb, 0, 0), Value::text("a"));
assert_eq!(v(&wb, 0, 1), Value::text("b,c"));
assert_eq!(v(&wb, 0, 2), Value::text("say \"hi\""));
assert_eq!(v(&wb, 1, 0), Value::text("multi\nline"));
assert_eq!(v(&wb, 1, 1), Value::Empty);
assert_eq!(v(&wb, 1, 2), Value::Number(3.0));
assert_eq!(v(&wb, 2, 0), Value::Empty);
assert_eq!(v(&wb, 3, 0), Value::text("last"));
}
#[test]
fn values_and_formats() {
let src = "1,2.5,\"1,234\",50%,TRUE,#N/A,2026-03-04,$5.00,'007,text";
let wb = read_csv(src.as_bytes(), &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 0, 0), Value::Number(1.0));
assert_eq!(v(&wb, 0, 1), Value::Number(2.5));
assert_eq!(v(&wb, 0, 2), Value::Number(1234.0));
assert_eq!(v(&wb, 0, 3), Value::Number(0.5));
assert_eq!(v(&wb, 0, 4), Value::Bool(true));
assert!(v(&wb, 0, 5).is_error());
assert!(v(&wb, 0, 6).is_number());
let s = wb.sheet(0).unwrap();
let st = wb.styles.get(s.cell(CellRef::new(0, 6)).unwrap().style);
assert_ne!(st.num_fmt.as_str(), "General");
assert_eq!(v(&wb, 0, 8), Value::text("007"));
// 1904 dates differ from 1900 dates.
let wb2 = read_csv(b"2026-03-04", &CsvOptions { date_system: DateSystem::D1904, ..Default::default() }).unwrap();
assert_eq!(wb2.date_system, DateSystem::D1904);
assert_eq!(v(&wb, 0, 6).as_f64().unwrap() - v(&wb2, 0, 0).as_f64().unwrap(), 1462.0);
// parse_values off keeps text.
let wb3 = read_csv(b"1,TRUE", &CsvOptions { parse_values: false, ..Default::default() }).unwrap();
assert_eq!(v(&wb3, 0, 0), Value::text("1"));
}
#[test]
fn encodings() {
let mut b = vec![0xEF, 0xBB, 0xBF];
b.extend_from_slice("é,x".as_bytes());
assert_eq!(decode_text(&b), "é,x");
let mut le = vec![0xFF, 0xFE];
for u in "ü;1".encode_utf16() {
le.extend_from_slice(&u.to_le_bytes());
}
assert_eq!(decode_text(&le), "ü;1");
let mut be = vec![0xFE, 0xFF];
for u in "ß".encode_utf16() {
be.extend_from_slice(&u.to_be_bytes());
}
assert_eq!(decode_text(&be), "ß");
assert_eq!(decode_text(&[0x80, b'a', 0xE9]), "€aé");
let wb = read_csv(&le, &CsvOptions { delimiter: 0, ..Default::default() }).unwrap();
assert_eq!(v(&wb, 0, 0), Value::text("ü"));
assert_eq!(v(&wb, 0, 1), Value::Number(1.0));
}
#[test]
fn windows_1256_table_matches_the_unicode_mapping() {
let at = |b: u8| crate::csv::CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c));
let expected = [
(0x80, '€'),
(0x81, 'پ'),
(0x8D, 'چ'),
(0x98, 'ک'),
(0x9D, '\u{200C}'),
(0xA1, '،'),
(0xBA, '؛'),
(0xBF, '؟'),
(0xC7, 'ا'),
(0xC8, 'ب'),
(0xDC, 'ـ'),
(0xE1, 'ل'),
(0xE3, 'م'),
(0xE9, 'é'),
(0xFD, '\u{200E}'),
(0xFE, '\u{200F}'),
(0xFF, 'ے'),
];
for (b, c) in expected {
assert_eq!(at(b), Some(c), "0x{b:02X}");
}
}
#[test]
fn arabic_csv_in_windows_1256_reads_as_arabic() {
let bytes = [
0xC7, 0xE1, 0xC7, 0xD3, 0xE3, b',', 0xC7, 0xE1, 0xDE, 0xED, 0xE3, 0xC9, b'\n', 0xE3, 0xD1, 0xCD, 0xC8, 0xC7, b' ', b'1', b'2', b'3', 0xA1,
b' ', b'4', b'2', 0xBF, b',', 0xC8, 0xED, 0xC7, 0xE4, 0xC7, 0xCA, b' ', b'7', b'\n',
];
let wb = read_csv(&bytes, &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 0, 0), Value::text("الاسم"));
assert_eq!(v(&wb, 0, 1), Value::text("القيمة"));
assert_eq!(v(&wb, 1, 0), Value::text("مرحبا 123، 42؟"));
assert_eq!(v(&wb, 1, 1), Value::text("بيانات 7"));
}
fn reads_as_arabic(bytes: &[u8]) -> bool {
decode_text(bytes).chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c))
}
#[test]
fn western_1252_text_stays_latin() {
let french = [
b'C', b'r', 0xE8, b'm', b'e', b' ', b'b', b'r', 0xFB, b'l', 0xE9, b'e', b',', b' ', 0xE0, b' ', b'c', 0xF4, b't', 0xE9, b',', b' ', 0xE9,
b't', 0xE9,
];
let german = [
b'G', b'r', 0xF6, 0xDF, b'e', b',', b' ', 0xDC, b'b', b'e', b'r', b'm', b'a', 0xDF, b',', b' ', b'F', b'u', 0xDF, b'g', 0xE4, b'n', b'g',
b'e', b'r',
];
assert_eq!(decode_text(&french), "Crème brûlée, à côté, été");
assert_eq!(decode_text(&german), "Größe, Übermaß, Fußgänger");
let portuguese = [
b'A', 0xC7, 0xC3, b'O', b',', b' ', b'i', b'n', b'f', b'o', b'r', b'm', b'a', 0xE7, 0xE3, b'o', b',', b' ', b's', b'i', b't', b'u', b'a',
0xE7, 0xE3, b'o', b',', b' ', b'P', 0xE3, b'o',
];
assert_eq!(decode_text(&portuguese), "AÇÃO, informação, situação, Pão");
let nordic = [
0xC6, b'r', 0xF8, b',', 0xD8, b's', b't', b',', 0xC5, b's', b'e', b',', 0xDE, 0xF3, b'r', 0xF0, b'u', b'r', b',', b'G', b'u', 0xF0, b'r',
0xFA, b'n', b',', 0xC4, 0xD6, 0xDC,
];
assert_eq!(decode_text(&nordic), "Ærø,Øst,Åse,Þórður,Guðrún,ÄÖÜ");
let ordinals = [b'1', 0xBA, b',', b'2', 0xBA, b',', b'3', 0xBA, b'\n', b'1', 0xAA, b',', b'2', 0xAA];
assert_eq!(decode_text(&ordinals), "1º,2º,3º\n1ª,2ª");
let inside_words = [b'K', 0xE4, b's', b'e', b',', b'B', 0xE4, b'c', b'k', b'e', b'r'];
assert_eq!(decode_text(&inside_words), "Käse,Bäcker");
}
#[test]
fn short_arabic_words_read_as_arabic() {
assert_eq!(decode_text(&[0xDD, 0xED, b',', 0xE3, 0xE4, b',', 0xDA, 0xE4, b'\n']), "في,من,عن\n");
assert_eq!(decode_text(&[0xE4, 0xDA, 0xE3, b',', 0xE1, 0xC7, b'\n', 0xE1, 0xC7, b',', 0xE4, 0xDA, 0xE3, b'\n']), "نعم,لا\nلا,نعم\n");
assert_eq!(decode_text(&[0x98, 0xCA, 0xC7, 0xC8, b' ', 0x81, 0xC7, 0xD1, 0xD3, 0xED]), "کتاب پارسي");
let with_marks = [
0xDF, 0xCA, 0xC7, 0xC8, 0x9D, 0xE5, 0xC7, b',', 0xCE, 0xC7, 0xE4, 0xE5, 0x9D, 0xE5, 0xC7, b',', 0xE3, 0xCD, 0xE3, 0xCF, 0xFE, b',', 0xDA,
0xE1, 0xED, 0xFE,
];
assert_eq!(decode_text(&with_marks), "كتاب\u{200C}ها,خانه\u{200C}ها,محمد\u{200F},علي\u{200F}");
}
#[test]
fn hebrew_1255_bytes_are_not_read_as_arabic() {
let plain = [0xF9, 0xEC, 0xE5, 0xED, b',', 0xF2, 0xE5, 0xEC, 0xED];
let pointed = [0xF9, 0xC8, 0xD1, 0xEC, 0xE5, 0xC9, 0xED, b' ', 0xF2, 0xE5, 0xC9, 0xEC, 0xC8, 0xED];
let maqaf = [0xFA, 0xEC, 0xCE, 0xE0, 0xE1, 0xE9, 0xE1, b',', 0xE1, 0xEF, 0xCE, 0xE2, 0xE5, 0xF8, 0xE9, 0xE5, 0xEF];
let gershayim = [0xF6, 0xE4, 0xD8, 0xEC, b',', 0xE3, 0xD8, 0xF8];
for bytes in [&plain[..], &pointed, &maqaf, &gershayim] {
assert!(!reads_as_arabic(bytes), "{:?}", decode_text(bytes));
}
}
#[test]
fn delimiter_sniffing() {
assert_eq!(sniff_delimiter("a;b;c\n1;2;3\n", '"'), b';');
assert_eq!(sniff_delimiter("a\tb\n1\t2\n", '"'), b'\t');
assert_eq!(sniff_delimiter("a,b\n\"x;y;z\",2\n", '"'), b',');
assert_eq!(sniff_delimiter("single", '"'), b',');
// A delimiter missing from some lines is text, not a separator (single-column export round trip).
assert_eq!(sniff_delimiter("alpha;beta\r\ngamma\r\n", '"'), b',');
assert_eq!(sniff_delimiter("alpha\tbeta\r\ngamma\r\n", '"'), b',');
assert_eq!(sniff_delimiter("a;b\r\n\r\nc;d\r\n", '"'), b';');
let wb = read_csv(b"x;y\n1,5;2", &CsvOptions { delimiter: 0, ..Default::default() }).unwrap();
assert_eq!(v(&wb, 1, 1), Value::Number(2.0));
// The workbook remembers the separator it was read with, sniffed or given.
assert_eq!(wb.csv_delimiter, Some(b';'));
assert_eq!(read_csv(b"a\tb", &CsvOptions { delimiter: 0, ..Default::default() }).unwrap().csv_delimiter, Some(b'\t'));
assert_eq!(read_csv(b"a;b", &CsvOptions::default()).unwrap().csv_delimiter, Some(b','));
assert_eq!(Workbook::new().csv_delimiter, None);
assert!(read_csv(b"a", &CsvOptions { delimiter: b'"', ..Default::default() }).is_err());
}
#[test]
fn writing() {
let mut wb = Workbook::new();
let mut s = Sheet::new("S");
s.set_value(CellRef::new(1, 1), Value::text("a,b"));
s.set_value(CellRef::new(1, 2), Value::text("q\"t"));
s.set_value(CellRef::new(2, 1), Value::Number(0.1));
s.set_value(CellRef::new(2, 2), Value::Bool(false));
s.set_value(CellRef::new(3, 3), Value::text("x\ny"));
s.set_value(CellRef::new(3, 1), Value::Error(gridcraft_core::CellError::Div0));
wb.sheets = vec![std::sync::Arc::new(s)];
// The caller supplies each field's text; quoting and line ends are the writer's.
let sh = wb.sheet(0).unwrap();
let raw = |c: CellRef| match sh.value(c) {
Value::Empty => String::new(),
Value::Number(n) => gridcraft_core::number_to_text(n),
Value::Text(t) => t.to_string(),
Value::Bool(b) => if b { "TRUE" } else { "FALSE" }.into(),
Value::Error(e) => e.as_str().into(),
Value::Array(_) => String::new(),
};
let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap();
// The first cell is B2: the file starts at A1, so the leading empty row and column stay.
assert_eq!(out, ",,,\r\n,\"a,b\",\"q\"\"t\",\r\n,0.1,FALSE,\r\n,#DIV/0!,,\"x\ny\"\r\n");
let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap();
assert!(semi.starts_with(";;;\r\n;a,b;"));
assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty());
// Round trip.
let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap();
assert_eq!(v(&back, 1, 1), Value::text("a,b"));
assert_eq!(v(&back, 3, 3), Value::text("x\ny"));
}
#[test]
fn sniffing_formats() {
assert_eq!(sniff(b"PK\x03\x04rest"), Format::Xlsx);
assert_eq!(sniff(b"a,b\n1,2"), Format::Csv);
// An Excel password-protected file is a CFB/OLE2 compound file with an EncryptedPackage
// stream; any other compound file is a legacy binary (.xls), not an encrypted workbook.
assert_eq!(sniff(&crate::cfb::tests::build(&["EncryptionInfo", "EncryptedPackage"])), Format::Encrypted);
assert_eq!(sniff(&crate::cfb::tests::build(&["Workbook"])), Format::LegacyBinary);
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::LegacyBinary);
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0, 0, 1, 2]), Format::Unknown);
assert_eq!(sniff(b""), Format::Unknown);
assert_eq!(sniff(&[0xFF, 0xFE, b'a', 0]), Format::Csv);
}
#[test]
fn hostile_csv() {
let _ = read_csv(&[0xFF; 1000], &CsvOptions::default()).unwrap();
let _ = read_csv(b"\"unterminated,quote\n1,2", &CsvOptions::default()).unwrap();
let wide = ",".repeat(20_000);
let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap();
assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped");
}
/// A sheet with `a` in A1 and `b` in `corner`.
fn two_cells(corner: (u32, u32)) -> Sheet {
let mut s = Sheet::new("S");
s.set_value(CellRef::new(0, 0), Value::text("a"));
s.set_value(CellRef::new(corner.0, corner.1), Value::text("b"));
s
}
fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ {
|c| match sh.value(c) {
Value::Text(t) => t.to_string(),
_ => String::new(),
}
}
#[test]
fn two_far_apart_cells_are_refused_before_anything_is_allocated() {
// A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers.
let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1));
let err = write_csv(&s, b',', text_of(&s)).unwrap_err();
assert!(matches!(err, IoError::TooLarge(_)), "{err}");
let msg = err.to_string();
assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}");
// A single row or column across the grid is refused too when it is over the cap.
let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0));
assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err());
}
#[test]
fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() {
// 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under.
let s = two_cells((2, 2));
let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap();
assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n");
assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err());
// Rows that pass the lower bound but hold long text stop as soon as the cap is passed.
let mut long = Sheet::new("L");
for r in 0..50u32 {
long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100)));
}
let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err();
assert!(err.to_string().contains("stopped at row"), "{err}");
assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok());
// Within one row the cap is checked before each field is written: a wide row of long and
// quote-heavy fields is refused at the field that crosses it, without asking for the rest.
let mut wide = Sheet::new("W");
for c in 0..10u32 {
wide.set_value(CellRef::new(0, c), Value::text("\"".repeat(100)));
}
let mut asked = 0;
let err = write_csv_limited(&wide, b',', 500, |c| {
asked += 1;
text_of(&wide)(c)
})
.unwrap_err();
assert!(err.to_string().contains("stopped at row 1"), "{err}");
assert_eq!(asked, 3, "each field is 202 bytes; the third passes 500");
// Exact boundary: a quoted field of 202 bytes plus CRLF fits in 204 and not in 203.
let mut one = Sheet::new("O");
one.set_value(CellRef::new(0, 0), Value::text("\"".repeat(100)));
assert_eq!(write_csv_limited(&one, b',', 204, text_of(&one)).unwrap().len(), 204);
assert!(write_csv_limited(&one, b',', 203, text_of(&one)).is_err());
// A tab is one byte, like a comma.
let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap();
assert_eq!(tabs.len(), 14);
}
#[test]
fn parser_stops_collecting_fields_at_max_cols() {
let extra = 50;
// MAX_COLS + extra fields on the first row (the last one filled), then a short second row.
let mut text = ",".repeat((MAX_COLS + extra) as usize - 1);
text.push_str("end\nx,y\n");
let mut lens = vec![];
parse_records(&text, ',', '"', |rec| {
lens.push(rec.len());
true
});
assert_eq!(lens, vec![MAX_COLS as usize, 2]);
// An unterminated last row is capped the same way.
let mut lens = vec![];
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| {
lens.push(rec.len());
true
});
assert_eq!(lens, vec![MAX_COLS as usize]);
}