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
572 lines
12 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|
|
}
|