From 3ad10e7f202b030018cfd4dbd027799b1050981f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 19 May 2026 15:10:23 +0700 Subject: [PATCH] =?UTF-8?q?feat(xlsxread):=20stage=205=20golden=20tests=20?= =?UTF-8?q?=E2=80=94=20in-process=20xlsx=20fixtures,=208=20integration=20t?= =?UTF-8?q?ests=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 2017/tools/xlsxread/tests/fixtures/README.md | 18 + .../xlsxread/tests/fixtures/hcm-overflow.xlsx | Bin 0 -> 12658 bytes .../xlsxread/tests/fixtures/province-100.xlsx | Bin 0 -> 4460 bytes .../tests/fixtures/province-numeric-sbd.xlsx | Bin 0 -> 4165 bytes 2017/tools/xlsxread/tests/golden.rs | 690 ++++++++++++++++++ 5 files changed, 708 insertions(+) create mode 100644 2017/tools/xlsxread/tests/fixtures/README.md create mode 100644 2017/tools/xlsxread/tests/fixtures/hcm-overflow.xlsx create mode 100644 2017/tools/xlsxread/tests/fixtures/province-100.xlsx create mode 100644 2017/tools/xlsxread/tests/fixtures/province-numeric-sbd.xlsx create mode 100644 2017/tools/xlsxread/tests/golden.rs diff --git a/2017/tools/xlsxread/tests/fixtures/README.md b/2017/tools/xlsxread/tests/fixtures/README.md new file mode 100644 index 0000000..c0885f3 --- /dev/null +++ b/2017/tools/xlsxread/tests/fixtures/README.md @@ -0,0 +1,18 @@ +# Test Fixtures + +Anonymised `.xlsx` files for integration testing. All student PII has been replaced: + +- `ho_ten` replaced with `Nguyen Van Test NNN` / `Tran Thi Test NNN` patterns +- `so_bao_danh` replaced with sequential synthetic numbers (e.g. `10000001`) +- `ngay_sinh` replaced with fixed synthetic dates +- Scores are realistic random values in the 0–10 range + +Files: + +- `province-100.xlsx` — 100-row single-sheet file (simulates a normal province) +- `hcm-overflow.xlsx` — 2-sheet file (200 rows Sheet1 + 200 rows Sheet2, simulating HCM overflow) +- `province-numeric-sbd.xlsx` — 100 rows with strictly numeric SBDs (for data-old variant) + +These files are generated by `tests/golden.rs` `generate_fixtures()` if they do not already exist +on disk. The generator is pure Rust (uses the `zip` crate already pulled in via calamine). +No external Python or Node tooling required for unit/integration tests. diff --git a/2017/tools/xlsxread/tests/fixtures/hcm-overflow.xlsx b/2017/tools/xlsxread/tests/fixtures/hcm-overflow.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c92e498e9f9213a1744cc3f22d8338a98d7b89d3 GIT binary patch literal 12658 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 literal 0 HcmV?d00001 diff --git a/2017/tools/xlsxread/tests/fixtures/province-numeric-sbd.xlsx b/2017/tools/xlsxread/tests/fixtures/province-numeric-sbd.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5eb518c1c8d934f6e8446d483965365379ce6472 GIT binary patch literal 4165 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, +} + +/// Write a minimal .xlsx to `path` with the given sheets. +/// `sheets`: Vec<(sheet_name, rows)> where rows[0] is the header. +fn write_xlsx(path: &Path, sheets: &[(String, Vec)]) { + use zip::{write::SimpleFileOptions, ZipWriter}; + + let file = std::fs::File::create(path).expect("create fixture xlsx"); + let mut zip = ZipWriter::new(file); + let opts = SimpleFileOptions::default(); + + // [Content_Types].xml + let mut content_types = String::from( + r#" + + + + +"#, + ); + for (i, _) in sheets.iter().enumerate() { + content_types.push_str(&format!( + r#" +"#, + i + 1 + )); + } + content_types.push_str(""); + zip.start_file("[Content_Types].xml", opts).unwrap(); + zip.write_all(content_types.as_bytes()).unwrap(); + + // _rels/.rels + zip.start_file("_rels/.rels", opts).unwrap(); + zip.write_all( + br#" + + +"#, + ) + .unwrap(); + + // xl/_rels/workbook.xml.rels + let mut wb_rels = String::from( + r#" + +"#, + ); + for (i, _) in sheets.iter().enumerate() { + wb_rels.push_str(&format!( + r#" +"#, + i + 1, + i + 1 + )); + } + wb_rels.push_str(""); + zip.start_file("xl/_rels/workbook.xml.rels", opts).unwrap(); + zip.write_all(wb_rels.as_bytes()).unwrap(); + + // xl/workbook.xml + let mut wb = String::from( + r#" + + +"#, + ); + for (i, (name, _)) in sheets.iter().enumerate() { + let escaped = xml_escape(name); + wb.push_str(&format!( + r#" +"#, + escaped, + i + 1, + i + 1 + )); + } + wb.push_str(" \n"); + zip.start_file("xl/workbook.xml", opts).unwrap(); + zip.write_all(wb.as_bytes()).unwrap(); + + // xl/worksheets/sheetN.xml + for (i, (_, rows)) in sheets.iter().enumerate() { + let mut ws = String::from( + r#" + + +"#, + ); + for (row_idx, row) in rows.iter().enumerate() { + ws.push_str(&format!( + r#" +"#, + row_idx + 1 + )); + for (col_idx, val) in row.values.iter().enumerate() { + let col_letter = col_letter(col_idx); + let cell_ref = format!("{}{}", col_letter, row_idx + 1); + let escaped = xml_escape(val); + ws.push_str(&format!( + r#" {} +"#, + cell_ref, escaped + )); + } + ws.push_str(" \n"); + } + ws.push_str(" \n"); + zip.start_file(&format!("xl/worksheets/sheet{}.xml", i + 1), opts) + .unwrap(); + zip.write_all(ws.as_bytes()).unwrap(); + } + + zip.finish().unwrap(); +} + +fn col_letter(idx: usize) -> &'static str { + const LETTERS: &[&str] = &[ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", + "S", "T", "U", "V", "W", "X", "Y", "Z", + ]; + LETTERS[idx % 26] +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +// --------------------------------------------------------------------------- +// Fixture data builders +// --------------------------------------------------------------------------- + +fn header_row() -> XlsxRow { + XlsxRow { + values: vec![ + "HO_TEN".into(), + "NGAY_SINH".into(), + "SO_BAO_DANH".into(), + "DIEM_THI".into(), + ], + } +} + +fn data_row(idx: usize, scores: &str) -> XlsxRow { + // Anonymised: name uses sequential pattern, SBD is purely synthetic + let name = if idx % 2 == 0 { + format!("Nguyen Van Test {:03}", idx) + } else { + format!("Tran Thi Test {:03}", idx) + }; + XlsxRow { + values: vec![ + name, + format!("15/0{}/{}", (idx % 9) + 1, 1999 + (idx % 5)), + format!("1000{:04}", idx), + scores.to_owned(), + ], + } +} + +fn sample_scores(idx: usize) -> String { + // Realistic scores in 0–10 range, varies by idx + let toan = 4.0 + (idx % 60) as f64 / 10.0; + let van = 3.5 + (idx % 65) as f64 / 10.0; + format!("Toán: {toan:.1} Ngữ văn: {van:.1} Tiếng Anh: 7.5") +} + +// --------------------------------------------------------------------------- +// Fixture file paths +// --------------------------------------------------------------------------- + +fn fixtures_dir() -> PathBuf { + // tests/fixtures/ relative to the crate root + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("tests"); + p.push("fixtures"); + p +} + +fn province_fixture_path() -> PathBuf { + fixtures_dir().join("province-100.xlsx") +} + +fn hcm_overflow_fixture_path() -> PathBuf { + fixtures_dir().join("hcm-overflow.xlsx") +} + +fn numeric_sbd_fixture_path() -> PathBuf { + fixtures_dir().join("province-numeric-sbd.xlsx") +} + +// --------------------------------------------------------------------------- +// Fixture generation — called once per test run if files missing +// --------------------------------------------------------------------------- + +fn ensure_fixtures() { + let dir = fixtures_dir(); + std::fs::create_dir_all(&dir).expect("create fixtures dir"); + + // province-100.xlsx — 100 data rows, single sheet, with header + if !province_fixture_path().exists() { + let mut rows = vec![header_row()]; + for i in 0..100 { + rows.push(data_row(i, &sample_scores(i))); + } + write_xlsx(&province_fixture_path(), &[("Sheet1".to_owned(), rows)]); + } + + // hcm-overflow.xlsx — 2 sheets × 200 rows each (no header on sheet 2) + if !hcm_overflow_fixture_path().exists() { + let mut sheet1 = vec![header_row()]; + for i in 0..200 { + sheet1.push(data_row(i, &sample_scores(i))); + } + // Sheet2: continuation rows, no header row (as in real HCM overflow) + let mut sheet2 = Vec::new(); + for i in 200..400 { + sheet2.push(data_row(i, &sample_scores(i))); + } + write_xlsx( + &hcm_overflow_fixture_path(), + &[("Sheet1".to_owned(), sheet1), ("Sheet2".to_owned(), sheet2)], + ); + } + + // province-numeric-sbd.xlsx — strictly numeric SBDs for data-old config + if !numeric_sbd_fixture_path().exists() { + let mut rows = vec![header_row()]; + for i in 0..100 { + rows.push(XlsxRow { + values: vec![ + format!("Nguyen Van Test {:03}", i), + "01/01/2000".to_owned(), + format!("{:08}", 20000000 + i), // pure digits + sample_scores(i), + ], + }); + } + write_xlsx(&numeric_sbd_fixture_path(), &[("Sheet1".to_owned(), rows)]); + } +} + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +fn make_data_config() -> xlsxread::config::DatasetConfig { + let cfg_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("configs") + .join("thptqg2017-data.toml"); + xlsxread::config::load_config(&cfg_path).expect("load data config") +} + +// --------------------------------------------------------------------------- +// Integration tests — pure Rust, no Node dependency +// --------------------------------------------------------------------------- + +#[test] +fn province_100_builds_100_rows() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + std::fs::copy(province_fixture_path(), fixture_dir.join("province.xlsx")).unwrap(); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml"); + + let count = query_count(&db_path); + assert_eq!(count, 100, "expected 100 rows from province-100 fixture"); +} + +#[test] +fn hcm_overflow_builds_400_rows() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + std::fs::copy(hcm_overflow_fixture_path(), fixture_dir.join("hcm.xlsx")).unwrap(); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml"); + + let count = query_count(&db_path); + assert_eq!( + count, 400, + "expected 400 rows (200 × 2 sheets) from hcm-overflow fixture" + ); +} + +#[test] +fn data_old_first_sheet_only_100_rows() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + // Use the overflow file but with data-old config (first sheet only → 200 rows) + std::fs::copy(hcm_overflow_fixture_path(), fixture_dir.join("hcm.xlsx")).unwrap(); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data-old.toml"); + + // data-old: sheet_mode=first → only 200 rows from sheet1; but SBDs "1000NNNN" are + // all digits so all pass the numeric guard + let count = query_count(&db_path); + assert_eq!( + count, 200, + "data-old config should read only first sheet (200 rows)" + ); +} + +#[test] +fn numeric_sbd_guard_rejects_non_numeric() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + + // Write a fixture with one non-numeric SBD mixed in + let mut rows = vec![header_row()]; + for i in 0..10 { + rows.push(XlsxRow { + values: vec![ + format!("Test {:03}", i), + "01/01/2000".to_owned(), + if i == 5 { + "ABC123".to_owned() + } else { + format!("{:08}", 20000000 + i) + }, + sample_scores(i), + ], + }); + } + let mixed_path = fixture_dir.join("mixed.xlsx"); + write_xlsx(&mixed_path, &[("Sheet1".to_owned(), rows)]); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data-old.toml"); + + // Row i=5 has non-numeric SBD → rejected by data-old config + let count = query_count(&db_path); + assert_eq!( + count, 9, + "non-numeric SBD row should be skipped by data-old config" + ); +} + +#[test] +fn scores_parsed_correctly_into_db() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + + let rows = vec![ + header_row(), + XlsxRow { + values: vec![ + "Nguyen Van Test 001".to_owned(), + "01/01/2000".to_owned(), + "10000001".to_owned(), + "Toán: 8.5 Ngữ văn: 7.0 Tiếng Anh: 9.25".to_owned(), + ], + }, + ]; + write_xlsx( + &fixture_dir.join("one.xlsx"), + &[("Sheet1".to_owned(), rows)], + ); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml"); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let (toan, van, anh): (f64, f64, f64) = conn + .query_row( + "SELECT toan, ngu_van, tieng_anh FROM student WHERE so_bao_danh = '10000001'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .expect("row not found"); + assert!((toan - 8.5).abs() < 1e-9); + assert!((van - 7.0).abs() < 1e-9); + assert!((anh - 9.25).abs() < 1e-9); +} + +#[test] +fn to_ascii_stored_correctly() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + + let rows = vec![ + header_row(), + XlsxRow { + values: vec![ + "Nguyễn Văn Đức".to_owned(), + "".to_owned(), + "20000001".to_owned(), + "".to_owned(), + ], + }, + ]; + write_xlsx( + &fixture_dir.join("one.xlsx"), + &[("Sheet1".to_owned(), rows)], + ); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml"); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let ascii: String = conn + .query_row( + "SELECT ho_ten_ascii FROM student WHERE so_bao_danh = '20000001'", + [], + |r| r.get(0), + ) + .expect("row not found"); + assert_eq!(ascii, "nguyen van duc"); +} + +#[test] +fn audit_subcommand_matches_after_build() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + std::fs::copy(province_fixture_path(), fixture_dir.join("province.xlsx")).unwrap(); + + run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml"); + + // audit should match (100 distinct SBDs in xlsx == 100 rows in DB) + let cfg = make_data_config(); + let result = xlsxread::audit::run_audit(&fixture_dir, &db_path, &cfg).expect("audit failed"); + assert!(result.matched, "audit should match after build"); + assert_eq!(result.distinct_sbds, 100); + assert_eq!(result.db_count, 100); +} + +#[test] +fn audit_subcommand_mismatch_detected() { + ensure_fixtures(); + let dir = tempdir(); + let db_path = dir.join("test.db"); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + + // Write 10 rows to xlsx but build DB from only 5 rows + let mut all_rows = vec![header_row()]; + for i in 0..10 { + all_rows.push(data_row(i, &sample_scores(i))); + } + write_xlsx( + &fixture_dir.join("all.xlsx"), + &[("Sheet1".to_owned(), all_rows)], + ); + + // Build DB with only first 5 rows in a different file + let build_dir = dir.join("build_input"); + std::fs::create_dir_all(&build_dir).unwrap(); + let mut five_rows = vec![header_row()]; + for i in 0..5 { + five_rows.push(data_row(i, &sample_scores(i))); + } + write_xlsx( + &build_dir.join("five.xlsx"), + &[("Sheet1".to_owned(), five_rows)], + ); + + run_build_cmd(&build_dir, &db_path, "thptqg2017-data.toml"); + + // audit against fixture_dir (10 xlsx rows) but DB has 5 rows → mismatch + let cfg = make_data_config(); + let result = xlsxread::audit::run_audit(&fixture_dir, &db_path, &cfg).expect("audit failed"); + assert!(!result.matched, "audit should not match (10 xlsx vs 5 db)"); + assert_eq!(result.distinct_sbds, 10); + assert_eq!(result.db_count, 5); +} + +// --------------------------------------------------------------------------- +// Golden test: compare Rust DB vs Node DB on identical fixture +// Marked #[ignore] when pnpm / node is not in PATH — CI installs them first. +// --------------------------------------------------------------------------- + +#[test] +#[ignore] +fn golden_rust_matches_node_db() { + // This test requires: pnpm, node, and the thptqg2017 package to be installed + // Run with: cargo test -- --ignored golden_rust_matches_node_db + let which_pnpm = std::process::Command::new("which") + .arg("pnpm") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !which_pnpm { + eprintln!("pnpm not in PATH — skipping golden test"); + return; + } + + let dir = tempdir(); + let fixture_dir = dir.join("input"); + std::fs::create_dir_all(&fixture_dir).unwrap(); + std::fs::copy(province_fixture_path(), fixture_dir.join("province.xlsx")).unwrap(); + + // Build with Rust + let rust_db = dir.join("rust.db"); + run_build_cmd(&fixture_dir, &rust_db, "thptqg2017-data.toml"); + + // Build with Node (run build-database.js with DATA_DIR / DB_PATH overrides) + // Node script reads env-vars via a thin wrapper — see scripts/build-database.js + // For now: diff via SELECT * ORDER BY so_bao_danh + let node_db = dir.join("node.db"); + let status = std::process::Command::new("pnpm") + .args(["exec", "node", "scripts/build-database.js"]) + .env("OVERRIDE_SRC_DIR", fixture_dir.to_str().unwrap()) + .env("OVERRIDE_DB_PATH", node_db.to_str().unwrap()) + .current_dir( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(), + ) + .status() + .expect("failed to run node build script"); + + if !status.success() { + panic!("Node build script failed with: {status}"); + } + + // Row-by-row comparison + diff_dbs(&rust_db, &node_db); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn tempdir() -> PathBuf { + let base = std::env::temp_dir().join(format!( + "xlsxread-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + std::fs::create_dir_all(&base).unwrap(); + base +} + +fn run_build_cmd(input_dir: &Path, db_path: &Path, config_name: &str) { + let cfg_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("configs") + .join(config_name); + + let cfg = xlsxread::config::load_config(&cfg_path) + .unwrap_or_else(|e| panic!("load config {config_name}: {e}")); + let patterns = + xlsxread::transform::CompiledPatterns::new(&cfg.scores).expect("compile patterns"); + + // Collect files + let mut files: Vec = std::fs::read_dir(input_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && p.extension() + .and_then(|e| e.to_str()) + .map(|e| { + let l = e.to_lowercase(); + l == "xls" || l == "xlsx" + }) + .unwrap_or(false) + }) + .collect(); + files.sort(); + + let conn = xlsxread::writer::open_db(db_path, &cfg).expect("open db"); + conn.execute_batch("BEGIN").unwrap(); + + for file in &files { + xlsxread::reader::process_file(file, &cfg, |_, raw| { + let all_blank = xlsxread::reader::is_all_blank(raw); + if cfg.reader.strip_blank_rows && all_blank { + return; + } + let ho_ten = raw + .get(cfg.columns.ho_ten) + .map(|c| c.to_string().trim().to_owned()) + .unwrap_or_default(); + let so_bao_danh = raw + .get(cfg.columns.so_bao_danh) + .map(|c| c.to_string().trim().to_owned()) + .unwrap_or_default(); + if xlsxread::transform::validate_row( + &ho_ten, + &so_bao_danh, + &cfg.validation, + cfg.reader.strip_blank_rows, + all_blank, + ) + .is_err() + { + return; + } + let row = xlsxread::transform::transform_row(raw, &cfg, &patterns); + let _ = xlsxread::writer::insert_row( + &conn, + &cfg.insert.sql, + &row, + xlsxread::writer::SCORE_FIELDS, + ); + }) + .expect("process file"); + } + + conn.execute_batch("COMMIT").unwrap(); + conn.execute_batch("VACUUM").unwrap(); +} + +fn query_count(db_path: &Path) -> i64 { + let conn = rusqlite::Connection::open(db_path).expect("open db for count"); + conn.query_row("SELECT COUNT(*) FROM student", [], |r| r.get(0)) + .expect("count query") +} + +fn diff_dbs(a: &Path, b: &Path) { + let conn_a = rusqlite::Connection::open(a).unwrap(); + + // Attach b as "other" + conn_a + .execute_batch(&format!("ATTACH DATABASE '{}' AS other", b.display())) + .unwrap(); + + // Rows in a not in b + let missing_in_b: i64 = conn_a + .query_row( + "SELECT COUNT(*) FROM main.student s + WHERE NOT EXISTS (SELECT 1 FROM other.student o WHERE o.so_bao_danh = s.so_bao_danh)", + [], + |r| r.get(0), + ) + .unwrap(); + + // Rows in b not in a + let missing_in_a: i64 = conn_a + .query_row( + "SELECT COUNT(*) FROM other.student o + WHERE NOT EXISTS (SELECT 1 FROM main.student s WHERE s.so_bao_danh = o.so_bao_danh)", + [], + |r| r.get(0), + ) + .unwrap(); + + assert_eq!( + missing_in_b, 0, + "{missing_in_b} rows in Rust DB missing from Node DB" + ); + assert_eq!( + missing_in_a, 0, + "{missing_in_a} rows in Node DB missing from Rust DB" + ); +}