package ods import ( "archive/zip" "bytes" "strings" "testing" ) func TestWriteReadRoundTrip(t *testing.T) { wb := &Workbook{ Meta: map[string]string{ "project": "3DX10", "schema": "kindred-rd", }, Sheets: []Sheet{ { Name: "BOM", Columns: []Column{ {Width: "3cm"}, {Width: "1.5cm"}, {Width: "1.5cm"}, {Width: "2.5cm"}, {Width: "5cm"}, {Width: "5cm"}, {Width: "2.5cm"}, {Width: "1.5cm"}, {Width: "2.5cm"}, {Width: "5cm"}, {Width: "1.5cm"}, {Hidden: true}, // manufacturer {Hidden: true}, // manufacturer_pn }, Rows: []Row{ // Header row {Cells: []Cell{ HeaderCell("Item"), HeaderCell("Level"), HeaderCell("Source"), HeaderCell("PN"), HeaderCell("Description"), HeaderCell("Seller Description"), HeaderCell("Unit Cost"), HeaderCell("QTY"), HeaderCell("Ext Cost"), HeaderCell("Sourcing Link"), HeaderCell("Schema"), HeaderCell("Manufacturer"), HeaderCell("Manufacturer PN"), }}, // Top-level assembly {Cells: []Cell{ StringCell("3DX10 Line Assembly"), IntCell(0), StringCell("M"), StringCell("A01-0003"), EmptyCell(), EmptyCell(), CurrencyCell(7538.61), FloatCell(1), FormulaCell("of:=[.G2]*[.H2]"), EmptyCell(), StringCell("RD"), }}, // Blank separator {IsBlank: true}, // Sub-assembly {Cells: []Cell{ StringCell("Extruder Assy"), IntCell(1), StringCell("M"), StringCell("A01-0001"), EmptyCell(), EmptyCell(), CurrencyCell(900.00), FloatCell(1), FormulaCell("of:=[.G4]*[.H4]"), }}, // Child part {Cells: []Cell{ EmptyCell(), IntCell(2), StringCell("P"), StringCell("S09-0001"), EmptyCell(), StringCell("Smooth-Bore Seamless 316 Stainless"), CurrencyCell(134.15), FloatCell(1), FormulaCell("of:=[.G5]*[.H5]"), StringCell("https://www.mcmaster.com/product"), StringCell("RD"), }}, }, }, }, } // Write data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } // Verify it's a valid ZIP _, err = zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("Output is not valid ZIP: %v", err) } // Read back got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } // Verify metadata if got.Meta["project"] != "3DX10" { t.Errorf("meta project = %q, want %q", got.Meta["project"], "3DX10") } if got.Meta["schema"] != "kindred-rd" { t.Errorf("meta schema = %q, want %q", got.Meta["schema"], "kindred-rd") } // Verify sheet count if len(got.Sheets) != 1 { t.Fatalf("got %d sheets, want 1", len(got.Sheets)) } sheet := got.Sheets[0] if sheet.Name != "BOM" { t.Errorf("sheet name = %q, want %q", sheet.Name, "BOM") } // Verify row count (5 data rows; blank row preserved) if len(sheet.Rows) < 5 { t.Fatalf("got %d rows, want at least 5", len(sheet.Rows)) } // Verify header row headerRow := sheet.Rows[0] if len(headerRow.Cells) < 11 { t.Fatalf("header has %d cells, want at least 11", len(headerRow.Cells)) } if headerRow.Cells[0].Value != "Item" { t.Errorf("header[0] = %q, want %q", headerRow.Cells[0].Value, "Item") } if headerRow.Cells[3].Value != "PN" { t.Errorf("header[3] = %q, want %q", headerRow.Cells[3].Value, "PN") } // Verify top-level assembly row asmRow := sheet.Rows[1] if asmRow.Cells[0].Value != "3DX10 Line Assembly" { t.Errorf("asm item = %q, want %q", asmRow.Cells[0].Value, "3DX10 Line Assembly") } if asmRow.Cells[3].Value != "A01-0003" { t.Errorf("asm PN = %q, want %q", asmRow.Cells[3].Value, "A01-0003") } // Verify blank separator row exists blankFound := false for _, row := range sheet.Rows { if row.IsBlank || isRowEmpty(row) { blankFound = true break } } if !blankFound { t.Error("expected at least one blank separator row") } // Verify child part childRow := sheet.Rows[len(sheet.Rows)-1] if childRow.Cells[3].Value != "S09-0001" { t.Errorf("child PN = %q, want %q", childRow.Cells[3].Value, "S09-0001") } } func TestWriteReadMultiSheet(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ { Name: "BOM", Rows: []Row{ {Cells: []Cell{StringCell("Header1"), StringCell("Header2")}}, {Cells: []Cell{StringCell("val1"), StringCell("val2")}}, }, }, { Name: "Items", Rows: []Row{ {Cells: []Cell{StringCell("PN"), StringCell("Desc")}}, {Cells: []Cell{StringCell("F01-0001"), StringCell("M3 Screw")}}, }, }, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } if len(got.Sheets) != 2 { t.Fatalf("got %d sheets, want 2", len(got.Sheets)) } if got.Sheets[0].Name != "BOM" { t.Errorf("sheet 0 name = %q, want %q", got.Sheets[0].Name, "BOM") } if got.Sheets[1].Name != "Items" { t.Errorf("sheet 1 name = %q, want %q", got.Sheets[1].Name, "Items") } if got.Sheets[1].Rows[1].Cells[0].Value != "F01-0001" { t.Errorf("items row 1 cell 0 = %q, want %q", got.Sheets[1].Rows[1].Cells[0].Value, "F01-0001") } } func TestCellTypes(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ { Name: "Types", Rows: []Row{ {Cells: []Cell{ StringCell("hello"), FloatCell(3.14), CurrencyCell(99.99), IntCell(42), EmptyCell(), }}, }, }, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } row := got.Sheets[0].Rows[0] // String if row.Cells[0].Value != "hello" { t.Errorf("string cell = %q, want %q", row.Cells[0].Value, "hello") } if row.Cells[0].Type != CellString { t.Errorf("string cell type = %d, want %d", row.Cells[0].Type, CellString) } // Float if row.Cells[1].Value != "3.14" { t.Errorf("float cell = %q, want %q", row.Cells[1].Value, "3.14") } if row.Cells[1].Type != CellFloat { t.Errorf("float cell type = %d, want %d", row.Cells[1].Type, CellFloat) } // Currency if row.Cells[2].Type != CellCurrency { t.Errorf("currency cell type = %d, want %d", row.Cells[2].Type, CellCurrency) } if row.Cells[2].Value != "99.99" { t.Errorf("currency cell = %q, want %q", row.Cells[2].Value, "99.99") } // Int (stored as float) if row.Cells[3].Value != "42" { t.Errorf("int cell = %q, want %q", row.Cells[3].Value, "42") } } func TestHiddenColumns(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ { Name: "Test", Columns: []Column{ {Width: "3cm"}, // visible {Width: "2cm", Hidden: true}, // hidden {Width: "3cm"}, // visible }, Rows: []Row{ {Cells: []Cell{StringCell("A"), StringCell("B"), StringCell("C")}}, }, }, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } // Verify the content.xml contains visibility="collapse" content := string(data) _ = content // ZIP binary, check via read got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } sheet := got.Sheets[0] if len(sheet.Columns) < 3 { t.Fatalf("got %d columns, want 3", len(sheet.Columns)) } if sheet.Columns[0].Hidden { t.Error("column 0 should not be hidden") } if !sheet.Columns[1].Hidden { t.Error("column 1 should be hidden") } if sheet.Columns[2].Hidden { t.Error("column 2 should not be hidden") } // All cell data should be preserved regardless of column visibility if sheet.Rows[0].Cells[1].Value != "B" { t.Errorf("hidden column cell = %q, want %q", sheet.Rows[0].Cells[1].Value, "B") } } func TestFormulaCell(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ { Name: "Formulas", Rows: []Row{ {Cells: []Cell{FloatCell(10), FloatCell(5), FormulaCell("of:=[.A1]*[.B1]")}}, }, }, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } cell := got.Sheets[0].Rows[0].Cells[2] if cell.Type != CellFormula { t.Errorf("formula cell type = %d, want %d", cell.Type, CellFormula) } if cell.Formula != "of:=[.A1]*[.B1]" { t.Errorf("formula = %q, want %q", cell.Formula, "of:=[.A1]*[.B1]") } } func TestBlankRowPreservation(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ { Name: "Blanks", Rows: []Row{ {Cells: []Cell{StringCell("Row1")}}, {IsBlank: true}, {Cells: []Cell{StringCell("Row3")}}, {IsBlank: true}, {Cells: []Cell{StringCell("Row5")}}, }, }, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } rows := got.Sheets[0].Rows if len(rows) != 5 { t.Fatalf("got %d rows, want 5", len(rows)) } // Row 0: data if rows[0].Cells[0].Value != "Row1" { t.Errorf("row 0 = %q, want %q", rows[0].Cells[0].Value, "Row1") } // Row 1: blank if !rows[1].IsBlank && !isRowEmpty(rows[1]) { t.Error("row 1 should be blank") } // Row 2: data if rows[2].Cells[0].Value != "Row3" { t.Errorf("row 2 = %q, want %q", rows[2].Cells[0].Value, "Row3") } // Row 4: data (last, not trimmed) if rows[4].Cells[0].Value != "Row5" { t.Errorf("row 4 = %q, want %q", rows[4].Cells[0].Value, "Row5") } } func TestMetadataRoundTrip(t *testing.T) { meta := map[string]string{ "project": "3DX10", "schema": "kindred-rd", "exported_at": "2026-01-30T12:00:00Z", "parent_pn": "A01-0003", } wb := &Workbook{ Meta: meta, Sheets: []Sheet{ {Name: "Sheet1", Rows: []Row{{Cells: []Cell{StringCell("test")}}}}, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } for k, v := range meta { if got.Meta[k] != v { t.Errorf("meta[%q] = %q, want %q", k, got.Meta[k], v) } } } func TestXMLEscaping(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ { Name: "Escape Test", Rows: []Row{ {Cells: []Cell{ StringCell(`1/4" 150 Class & Flange`), StringCell("normal text"), }}, }, }, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } val := got.Sheets[0].Rows[0].Cells[0].Value expected := `1/4" 150 Class & Flange` if val != expected { t.Errorf("escaped cell = %q, want %q", val, expected) } } func TestEmptyWorkbook(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ {Name: "Empty"}, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } got, err := Read(data) if err != nil { t.Fatalf("Read failed: %v", err) } if len(got.Sheets) != 1 { t.Fatalf("got %d sheets, want 1", len(got.Sheets)) } if got.Sheets[0].Name != "Empty" { t.Errorf("sheet name = %q, want %q", got.Sheets[0].Name, "Empty") } } func TestWriteProducesValidODS(t *testing.T) { wb := &Workbook{ Sheets: []Sheet{ {Name: "Test", Rows: []Row{{Cells: []Cell{StringCell("hello")}}}}, }, } data, err := Write(wb) if err != nil { t.Fatalf("Write failed: %v", err) } // Verify ZIP structure r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("not valid ZIP: %v", err) } expectedFiles := map[string]bool{ "mimetype": false, "META-INF/manifest.xml": false, "meta.xml": false, "styles.xml": false, "content.xml": false, } for _, f := range r.File { if _, ok := expectedFiles[f.Name]; ok { expectedFiles[f.Name] = true } } for name, found := range expectedFiles { if !found { t.Errorf("missing required file: %s", name) } } // Verify mimetype is first entry and stored (not compressed) if r.File[0].Name != "mimetype" { t.Errorf("first entry = %q, want %q", r.File[0].Name, "mimetype") } if r.File[0].Method != zip.Store { t.Errorf("mimetype method = %d, want Store (%d)", r.File[0].Method, zip.Store) } // Verify content.xml contains our data for _, f := range r.File { if f.Name == "content.xml" { rc, err := f.Open() if err != nil { t.Fatalf("open content.xml: %v", err) } var buf bytes.Buffer buf.ReadFrom(rc) rc.Close() content := buf.String() if !strings.Contains(content, "hello") { t.Error("content.xml does not contain cell value 'hello'") } if !strings.Contains(content, `table:name="Test"`) { t.Error("content.xml does not contain sheet name") } } } }