mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
374 lines
11 KiB
Go
374 lines
11 KiB
Go
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)
|
|
}
|
|
}
|