Variables: Import Data Sets… reads tab-separated files and files without a name column

dataset.import read only commas between cells and refused a file whose
first header cell wasn't `name`. A value that didn't match its
variable's kind, and a repeated data set name, were reported with the
row's record number, which is not the file line after a blank line or a
quoted line break. A data set name over 255 characters gave no line.

- When the header row, read with tabs between cells, has a tab and no
  comma outside quotes, tabs separate the cells. A quoted cell may then
  hold tabs and commas.
- Without a `name` column every header cell is a variable, and the rows
  take the first free names Data Set 1, Data Set 2 and so on, as Capture
  Data Set and blank name cells do. A first header cell `name`, in any
  case, still holds the data set names.
- Value errors, a repeated data set name and a name over 255 characters
  give the file line the row starts on.
- The Variables panel's Import Data Sets… dialog also lists .tsv files.
- import_fuzz.rs imports random text, random bytes and mutated exports,
  comma- and tab-separated, into a document with bound text, visibility
  and graph variables, selects up to three of the imported data sets in
  turn, and exports every data set.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 13:49:49 +07:00
committed by bflatastic
parent a285efda46
commit 5bdd9d539f
6 changed files with 219 additions and 35 deletions
+65 -1
View File
@@ -948,6 +948,70 @@ fn datasets_csv_keeps_spaces_in_quoted_cells() {
assert_eq!(value("C"), text_value(" quoted "));
}
/// A header row with a tab and no comma outside quotes makes tabs the separator; a quoted cell
/// may then hold tabs and commas. A header row with a comma outside quotes keeps commas, even
/// when a cell holds a tab. A tab-separated header with an unclosed quote fails with the reason.
#[test]
fn datasets_csv_reads_tab_separated_files() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Last, First", "kind": "text"})).unwrap();
let tsv = "name\tTitle\tShow\r\nA\tTwo words\ttrue\r\nB\t\"a, b\tc\"\tfalse\r\n";
let imp = s.execute("dataset.import", &json!({"csv": tsv})).unwrap();
assert_eq!(imp["names"], json!(["A", "B"]));
s.execute("dataset.import", &json!({"csv": "name,Title\nC,one\ttwo"})).unwrap();
s.execute("dataset.import", &json!({"csv": "name\t\"Last, First\"\nD\tSmith, Jo"})).unwrap();
let value = |name: &str, var: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get(var)).cloned();
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
assert_eq!(value("A", "Title"), text_value("Two words"));
assert_eq!(value("A", "Show"), Some(vectorcraft_doc::DataValue::Visible(true)));
assert_eq!(value("B", "Title"), text_value("a, b\tc"));
assert_eq!(value("B", "Show"), Some(vectorcraft_doc::DataValue::Visible(false)));
assert_eq!(value("C", "Title"), text_value("one\ttwo"));
assert_eq!(value("D", "Last, First"), text_value("Smith, Jo"));
let unclosed = s.execute("dataset.import", &json!({"csv": "name\tTitle\t\"Show\nA\tx\ttrue"})).unwrap_err().to_string();
assert!(unclosed.contains("unclosed quote"), "{unclosed}");
}
/// Without a `name` column every header cell is a variable, and the rows take the first free
/// names Data Set 1, 2 and so on. A first header cell `name`, in any case, still holds the names.
#[test]
fn datasets_csv_without_a_name_column_numbers_the_rows() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("dataset.new", &json!({"name": "Data Set 2", "values": {"Title": "kept"}})).unwrap();
let imp = s.execute("dataset.import", &json!({"csv": "Title\nx\ny\n"})).unwrap();
assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0)));
assert_eq!(imp["names"], json!(["Data Set 1", "Data Set 3"]));
let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Title")).cloned();
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
assert_eq!(value(&s, "Data Set 1"), text_value("x"));
assert_eq!(value(&s, "Data Set 2"), text_value("kept"));
assert_eq!(value(&s, "Data Set 3"), text_value("y"));
let named = s.execute("dataset.import", &json!({"csv": "NAME,Title\nData Set 1,z"})).unwrap();
assert_eq!((named["imported"].as_u64(), named["updated"].as_u64()), (Some(0), Some(1)));
assert_eq!(value(&s, "Data Set 1"), text_value("z"));
}
/// An error about a row's values or name gives the file line the row starts on, counting blank
/// lines and line breaks inside quoted cells.
#[test]
fn datasets_csv_row_errors_name_the_file_line() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
let blank_line = error(&mut s, "name,Show\n\nX,maybe");
assert!(blank_line.contains("line 3:"), "{blank_line}");
let line_break = error(&mut s, "name,Title,Show\nA,\"one\ntwo\",true\nB,x,maybe");
assert!(line_break.contains("line 4:"), "{line_break}");
let twice = error(&mut s, "name,Title\nA,x\n\nA,y");
assert!(twice.contains("line 4:"), "{twice}");
let long = error(&mut s, &format!("name,Title\n\n{},x", "n".repeat(256)));
assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}");
}
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
#[test]
fn datasets_csv_round_trips_and_rejects_bad_input() {
@@ -989,7 +1053,7 @@ fn datasets_csv_round_trips_and_rejects_bad_input() {
assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2)));
assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name");
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title");
let out = dir.file("sets.csv");
s.execute("dataset.export", &json!({"path": out})).unwrap();