Files
silo/internal/ods/ods_test.go
Forbes afb382b68d feat: LibreOffice Calc extension, ODS library, AI description, audit design
Calc extension (pkg/calc/):
- Python UNO ProtocolHandler with 8 toolbar commands
- SiloClient HTTP client adapted from FreeCAD workbench
- Pull BOM/Project: populates sheets with 28-col format, hidden property
  columns, row hash tracking, auto project tagging
- Push: row classification, create/update items, conflict detection
- Completion wizard: 3-step category/description/fields with PN conflict
  resolution dialog
- OpenRouter AI integration: generate standardized descriptions from seller
  text, configurable model/instructions, review dialog
- Settings: JSON persistence, env var fallbacks, OpenRouter fields
- 31 unit tests (no UNO/network required)

Go ODS library (internal/ods/):
- Pure Go ODS read/write (ZIP of XML, no headless LibreOffice)
- Writer, reader, 10 round-trip tests

Server ODS endpoints (internal/api/ods.go):
- GET /api/items/export.ods, template.ods, POST import.ods
- GET /api/items/{pn}/bom/export.ods
- GET /api/projects/{code}/sheet.ods
- POST /api/sheets/diff

Documentation:
- docs/CALC_EXTENSION.md: extension progress report
- docs/COMPONENT_AUDIT.md: web audit tool design with weighted scoring,
  assembly computed fields, batch AI assistance plan
2026-02-01 10:06:20 -06:00

572 lines
12 KiB
Go

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 <Weld> & 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 <Weld> & 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")
}
}
}
}