package main import ( "os" "strings" "testing" "time" "github.com/xuri/excelize/v2" ) // --- helper: build horizontal-format temp xlsx -------------------------------- // makeHorizXlsx creates a temp .xlsx with two horizontal rows: // // Row 1: "day" | dayValues... // Row 2: "multiplication_percentage" | multValues... func makeHorizXlsx(t *testing.T, dayValues, multValues []any) string { t.Helper() f := excelize.NewFile() defer f.Close() f.SetCellValue("Sheet1", "A1", "day") f.SetCellValue("Sheet1", "A2", "multiplication_percentage") for i, v := range dayValues { colName, _ := excelize.ColumnNumberToName(i + 2) switch val := v.(type) { case int: f.SetCellInt("Sheet1", colName+"1", val) case string: f.SetCellValue("Sheet1", colName+"1", val) } } for i, v := range multValues { colName, _ := excelize.ColumnNumberToName(i + 2) switch val := v.(type) { case float64: f.SetCellFloat("Sheet1", colName+"2", val, 15, 64) case string: f.SetCellValue("Sheet1", colName+"2", val) } } tmp, err := os.CreateTemp("", "test_curve_*.xlsx") if err != nil { t.Fatalf("CreateTemp: %v", err) } tmp.Close() t.Cleanup(func() { os.Remove(tmp.Name()) }) if err := f.SaveAs(tmp.Name()); err != nil { t.Fatalf("SaveAs: %v", err) } return tmp.Name() } // --- parseCurveFile tests ----------------------------------------------------- func TestParseCurveFile_HappyPath(t *testing.T) { path := makeHorizXlsx(t, []any{1, 2, 3}, []any{0.997742664, 0.997737557, 0.997732426}, ) sheetName, rows, issues, err := parseCurveFile(path, "") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(issues) != 0 { t.Fatalf("unexpected issues: %v", issues) } if sheetName != "Sheet1" { t.Fatalf("wrong sheet: %s", sheetName) } if len(rows) != 3 { t.Fatalf("want 3 rows, got %d", len(rows)) } if rows[0].Day != 1 || rows[1].Day != 2 || rows[2].Day != 3 { t.Fatalf("wrong days: %v", rows) } if rows[0].ColRef != "B" { t.Fatalf("wrong ColRef for day 1: %s", rows[0].ColRef) } } func TestParseCurveFile_RowOrderFlexible(t *testing.T) { f := excelize.NewFile() defer f.Close() f.SetCellValue("Sheet1", "A1", "multiplication_percentage") f.SetCellFloat("Sheet1", "B1", 0.997, 15, 64) f.SetCellValue("Sheet1", "A2", "day") f.SetCellInt("Sheet1", "B2", 5) tmp, _ := os.CreateTemp("", "*.xlsx") tmp.Close() t.Cleanup(func() { os.Remove(tmp.Name()) }) f.SaveAs(tmp.Name()) _, rows, issues, err := parseCurveFile(tmp.Name(), "") if err != nil || len(issues) != 0 { t.Fatalf("err=%v issues=%v", err, issues) } if len(rows) != 1 || rows[0].Day != 5 { t.Fatalf("unexpected rows: %v", rows) } } func TestParseCurveFile_MissingDayRow(t *testing.T) { f := excelize.NewFile() defer f.Close() f.SetCellValue("Sheet1", "A1", "multiplication_percentage") f.SetCellFloat("Sheet1", "B1", 0.997, 15, 64) tmp, _ := os.CreateTemp("", "*.xlsx") tmp.Close() t.Cleanup(func() { os.Remove(tmp.Name()) }) f.SaveAs(tmp.Name()) _, _, issues, err := parseCurveFile(tmp.Name(), "") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(issues) != 1 || issues[0].Field != headerDay { t.Fatalf("expected missing-day-row issue, got %v", issues) } } func TestParseCurveFile_DuplicateDay(t *testing.T) { path := makeHorizXlsx(t, []any{1, 2, 1}, []any{0.99, 0.98, 0.97}, ) _, _, issues, err := parseCurveFile(path, "") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(issues) != 1 { t.Fatalf("expected 1 duplicate-day issue, got %v", issues) } if !strings.Contains(issues[0].Message, "duplicate day 1") { t.Fatalf("wrong issue message: %s", issues[0].Message) } if !strings.Contains(issues[0].Message, "col=D") { t.Fatalf("issue should mention col=D: %s", issues[0].Message) } } func TestParseCurveFile_InvalidMultiplication(t *testing.T) { path := makeHorizXlsx(t, []any{1, 2}, []any{"bad", 1.5}, ) _, _, issues, err := parseCurveFile(path, "") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(issues) != 2 { t.Fatalf("expected 2 issues, got %v", issues) } } func TestParseCurveFile_SkipsEmptyColumns(t *testing.T) { f := excelize.NewFile() defer f.Close() f.SetCellValue("Sheet1", "A1", "day") f.SetCellInt("Sheet1", "B1", 1) f.SetCellInt("Sheet1", "D1", 3) f.SetCellValue("Sheet1", "A2", "multiplication_percentage") f.SetCellFloat("Sheet1", "B2", 0.997, 15, 64) f.SetCellFloat("Sheet1", "D2", 0.995, 15, 64) tmp, _ := os.CreateTemp("", "*.xlsx") tmp.Close() t.Cleanup(func() { os.Remove(tmp.Name()) }) f.SaveAs(tmp.Name()) _, rows, issues, err := parseCurveFile(tmp.Name(), "") if err != nil || len(issues) != 0 { t.Fatalf("err=%v issues=%v", err, issues) } if len(rows) != 2 { t.Fatalf("want 2 rows (col C skipped), got %d: %v", len(rows), rows) } } // --- pure-function tests ------------------------------------------------------ func TestParseFlockIDs(t *testing.T) { t.Run("single", func(t *testing.T) { ids, err := parseFlockIDs("52") if err != nil || len(ids) != 1 || ids[0] != 52 { t.Fatalf("got ids=%v err=%v", ids, err) } }) t.Run("multiple_sorted", func(t *testing.T) { ids, err := parseFlockIDs("54,52,53") if err != nil || len(ids) != 3 { t.Fatalf("got ids=%v err=%v", ids, err) } // should be sorted if ids[0] != 52 || ids[1] != 53 || ids[2] != 54 { t.Fatalf("expected sorted [52,53,54], got %v", ids) } }) t.Run("duplicate", func(t *testing.T) { if _, err := parseFlockIDs("52,52"); err == nil { t.Fatal("expected error for duplicate") } }) t.Run("zero", func(t *testing.T) { if _, err := parseFlockIDs("0"); err == nil { t.Fatal("expected error for zero") } }) t.Run("empty", func(t *testing.T) { if _, err := parseFlockIDs(""); err == nil { t.Fatal("expected error for empty") } }) } func TestFormatArrayLiteral(t *testing.T) { got := formatArrayLiteral([]uint{52, 53, 54}) want := "ARRAY[52,53,54]::bigint[]" if got != want { t.Fatalf("want %q, got %q", want, got) } } func TestFormatFlockIDsForFilename(t *testing.T) { if got := formatFlockIDsForFilename([]uint{52, 53, 54}); got != "52_53_54" { t.Fatalf("got %s", got) } // More than 4 IDs → truncate many := []uint{1, 2, 3, 4, 5} got := formatFlockIDsForFilename(many) if !strings.Contains(got, "1_2_3_4") || !strings.Contains(got, "1_more") { t.Fatalf("unexpected: %s", got) } } func TestParsePositiveInt(t *testing.T) { cases := map[string]bool{ "1": true, "532": true, "12.0": true, "0": false, "-3": false, "1.5": false, "": false, "x": false, } for raw, ok := range cases { _, err := parsePositiveInt(raw) if ok && err != nil { t.Errorf("%q: unexpected error %v", raw, err) } if !ok && err == nil { t.Errorf("%q: expected error", raw) } } } func TestParseMultiplication(t *testing.T) { cases := map[string]bool{ "0.997742664": true, "1": true, "9.11e-12": true, "0": true, "-0.1": false, "1.0001": false, "": false, "abc": false, } for raw, ok := range cases { _, err := parseMultiplication(raw) if ok && err != nil { t.Errorf("%q: unexpected error %v", raw, err) } if !ok && err == nil { t.Errorf("%q: expected error", raw) } } } func TestFormatValuesTuplesFirstNumericCast(t *testing.T) { out := formatValuesTuples([]curveRow{ {Day: 1, Mult: 0.997742664}, {Day: 2, Mult: 1}, }) if !strings.Contains(out, "(1, 0.997742664::numeric)") { t.Fatalf("first tuple must cast ::numeric: %s", out) } if strings.Contains(out, "(2, 1::numeric)") { t.Fatalf("only the first tuple should be cast: %s", out) } } func TestBuildUpSQL_SingleHouseType(t *testing.T) { opts := options{EffectiveDate: "2026-05-31"} curve := []curveRow{{Day: 1, Mult: 0.997742664}, {Day: 2, Mult: 0.5}} flockIDs := []uint{52, 53} sql := buildUpSQL(opts, []string{"close_house"}, curve, flockIDs) mustContain(t, sql, "INSERT INTO house_depreciation_standards") mustContain(t, sql, "project_flock_ids") mustContain(t, sql, "ARRAY[52,53]::bigint[]") mustContain(t, sql, "DATE '2026-05-31'") mustContain(t, sql, "v.mult, (1 - v.mult) * 100, g.standard_week") mustContain(t, sql, "project_flock_ids IS NULL") mustContain(t, sql, "house_type = 'close_house'::house_type_enum") mustContain(t, sql, "(1, 0.997742664::numeric)") mustContain(t, sql, "JOIN LATERAL") mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)") } func TestBuildUpSQL_MultipleHouseTypes(t *testing.T) { opts := options{EffectiveDate: "2026-05-31"} curve := []curveRow{{Day: 1, Mult: 0.5}} flockIDs := []uint{52, 53} sql := buildUpSQL(opts, []string{"close_house", "open_house"}, curve, flockIDs) // Both house_types get their own INSERT block mustContain(t, sql, "house_type = 'close_house'::house_type_enum") mustContain(t, sql, "house_type = 'open_house'::house_type_enum") // VALUES tuple appears only once (reused by both blocks) mustContain(t, sql, "(1, 0.5::numeric)") // Snapshot invalidation appears once at the end mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)") } func TestBuildDownSQL(t *testing.T) { opts := options{EffectiveDate: "2026-05-31"} flockIDs := []uint{52, 53} sql := buildDownSQL(opts, flockIDs) // Delete by exact array match mustContain(t, sql, "DELETE FROM house_depreciation_standards") mustContain(t, sql, "project_flock_ids = ARRAY[52,53]::bigint[]") mustContain(t, sql, "effective_date = DATE '2026-05-31'") mustContain(t, sql, "DELETE FROM farm_depreciation_snapshots WHERE project_flock_id IN (52, 53)") } func TestResolveEffectiveDate(t *testing.T) { loc := time.UTC if _, err := resolveEffectiveDate("2026-05-31", loc); err != nil { t.Fatalf("valid date errored: %v", err) } if _, err := resolveEffectiveDate("31-05-2026", loc); err == nil { t.Fatalf("expected error for wrong format") } got, err := resolveEffectiveDate("", loc) if err != nil { t.Fatalf("default date errored: %v", err) } if got.Hour() != 0 || got.Minute() != 0 { t.Fatalf("default date should be midnight, got %v", got) } } func TestNormalizeHouseType(t *testing.T) { for _, ok := range []string{"open_house", "CLOSE_HOUSE", " close_house "} { if _, err := normalizeHouseType(ok); err != nil { t.Errorf("%q should be valid: %v", ok, err) } } if _, err := normalizeHouseType("barn"); err == nil { t.Errorf("barn should be invalid") } } func TestInClause(t *testing.T) { if got := inClause([]uint{52, 53, 54}); got != "52, 53, 54" { t.Fatalf("wrong inClause: %s", got) } } // --- helpers ------------------------------------------------------------------ func mustContain(t *testing.T, haystack, needle string) { t.Helper() if !strings.Contains(haystack, needle) { t.Fatalf("expected to find %q in:\n%s", needle, haystack) } }