276 lines
9.0 KiB
Go
276 lines
9.0 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"errors"
|
||
"io"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/xuri/excelize/v2"
|
||
)
|
||
|
||
func TestParseMaterialImportFileStopsBeforeRowsBeyondLimit(t *testing.T) {
|
||
dataRows := make([][]string, MaterialImportMaxRows+1)
|
||
for i := range dataRows {
|
||
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
|
||
}
|
||
data := buildTestWorkbook(t, dataRows)
|
||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var changed bytes.Buffer
|
||
writer := zip.NewWriter(&changed)
|
||
for _, entry := range reader.File {
|
||
source, err := entry.Open()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
content, err := io.ReadAll(source)
|
||
source.Close()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if entry.Name == "xl/worksheets/sheet1.xml" {
|
||
// 超限行之后故意放非法 XML,证明解析器不会再读取后续行。
|
||
content = bytes.Replace(content, []byte("</sheetData>"),
|
||
[]byte(`<row r="20003"><c></row></sheetData>`), 1)
|
||
}
|
||
target, err := writer.Create(entry.Name)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := target.Write(content); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
|
||
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
|
||
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
|
||
dataRows := make([][]string, MaterialImportMaxRows)
|
||
for i := range dataRows {
|
||
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
|
||
}
|
||
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
|
||
if err != nil || len(rows) != MaterialImportMaxRows {
|
||
t.Fatalf("rows=%d, err=%v", len(rows), err)
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
|
||
data := buildTestWorkbook(t, [][]string{
|
||
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||
{"", "", "", "", ""}, // 整行空,应被跳过
|
||
{"急停按钮", "XB2", "个", "电气元器件类", "成品件"},
|
||
})
|
||
|
||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if len(rows) != 2 {
|
||
t.Fatalf("rows = %d, want 2", len(rows))
|
||
}
|
||
if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" {
|
||
t.Fatalf("first row = %+v", rows[0])
|
||
}
|
||
// 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行
|
||
if rows[1].Row != 4 || rows[1].Name != "急停按钮" {
|
||
t.Fatalf("second row = %+v", rows[1])
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) {
|
||
// 名称填了、其余为空:不是空行,必须保留交给校验去报错
|
||
data := buildTestWorkbook(t, [][]string{
|
||
{"只有名称", "", "", "", ""},
|
||
})
|
||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if len(rows) != 1 || rows[0].Name != "只有名称" {
|
||
t.Fatalf("rows = %+v", rows)
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) {
|
||
// 只有「物料名称」和「规格」,缺必填的大类、小类
|
||
data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{
|
||
{"内六角螺栓", "M8x30"},
|
||
})
|
||
|
||
_, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||
if !errors.Is(err, ErrImportTemplateInvalid) {
|
||
t.Fatalf("err = %v, want ErrImportTemplateInvalid", err)
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileAcceptsMissingOptionalColumnsAndReorderedColumns(t *testing.T) {
|
||
// 删掉「规格」,并把「单位」挪到「物料名称」前面
|
||
headers := []string{"单位", "物料名称", "大类", "小类"}
|
||
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
|
||
{"个", "内六角螺栓", "机械结构类", "标准件"},
|
||
})
|
||
|
||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if len(rows) != 1 {
|
||
t.Fatalf("rows = %d, want 1", len(rows))
|
||
}
|
||
if rows[0].Name != "内六角螺栓" || rows[0].Unit != "个" || rows[0].MinorName != "标准件" {
|
||
t.Fatalf("row = %+v", rows[0])
|
||
}
|
||
if rows[0].Spec != "" {
|
||
t.Fatalf("缺失的规格列应解析为空,得到 %q", rows[0].Spec)
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileIgnoresUnknownColumns(t *testing.T) {
|
||
headers := []string{"物料名称", "备注", "大类", "小类"}
|
||
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
|
||
{"内六角螺栓", "与导入无关", "机械结构类", "标准件"},
|
||
})
|
||
|
||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if len(rows) != 1 || rows[0].Name != "内六角螺栓" {
|
||
t.Fatalf("rows = %+v", rows)
|
||
}
|
||
}
|
||
|
||
func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) {
|
||
rows := make([][]string, 0, MaterialImportMaxRows+1)
|
||
for i := 0; i < MaterialImportMaxRows+1; i++ {
|
||
rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"})
|
||
}
|
||
data := buildTestWorkbook(t, rows)
|
||
|
||
_, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||
if !errors.Is(err, ErrImportTooManyRows) {
|
||
t.Fatalf("err = %v, want ErrImportTooManyRows", err)
|
||
}
|
||
}
|
||
|
||
func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
|
||
data, err := BuildMaterialImportTemplate()
|
||
if err != nil {
|
||
t.Fatalf("build template: %v", err)
|
||
}
|
||
f, err := excelize.OpenReader(bytes.NewReader(data))
|
||
if err != nil {
|
||
t.Fatalf("open template: %v", err)
|
||
}
|
||
defer f.Close()
|
||
sheets := f.GetSheetList()
|
||
if len(sheets) != 2 || sheets[0] != MaterialImportSheetGuide || sheets[1] != MaterialImportSheetData {
|
||
t.Fatalf("工作表顺序 = %v,期望先说明后填表", sheets)
|
||
}
|
||
if f.GetActiveSheetIndex() != 0 {
|
||
t.Fatal("打开模板时应先显示填写说明")
|
||
}
|
||
guide, err := f.GetRows(MaterialImportSheetGuide)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var guideText strings.Builder
|
||
for _, row := range guide {
|
||
guideText.WriteString(strings.Join(row, " "))
|
||
}
|
||
for _, rule := range []string{"编码 名称", "1MB", "10000", "整批", "重复", "库存", "第 2 行"} {
|
||
if !strings.Contains(guideText.String(), rule) {
|
||
t.Fatalf("填写说明缺少规则:%s", rule)
|
||
}
|
||
}
|
||
styleAt := func(sheet, cell string) *excelize.Style {
|
||
t.Helper()
|
||
styleID, err := f.GetCellStyle(sheet, cell)
|
||
if err != nil {
|
||
t.Fatalf("读取 %s!%s 样式: %v", sheet, cell, err)
|
||
}
|
||
style, err := f.GetStyle(styleID)
|
||
if err != nil {
|
||
t.Fatalf("解析 %s!%s 样式: %v", sheet, cell, err)
|
||
}
|
||
return style
|
||
}
|
||
intro := styleAt(MaterialImportSheetGuide, "A2")
|
||
if intro.Font == nil || !intro.Font.Bold || !intro.Font.Italic || intro.Fill.Color[0] != "FFC000" || intro.Alignment == nil || intro.Alignment.Horizontal != "" || intro.Alignment.Vertical != "center" {
|
||
t.Fatalf("说明提示样式不匹配附件模板: %+v", intro)
|
||
}
|
||
field := styleAt(MaterialImportSheetGuide, "A6")
|
||
if field.Font == nil || !field.Font.Bold {
|
||
t.Fatalf("字段名应加粗: %+v", field)
|
||
}
|
||
required := styleAt(MaterialImportSheetGuide, "B6")
|
||
if required.Font == nil || !required.Font.Bold || required.Font.Color != "FF0000" {
|
||
t.Fatalf("必填标记应为红色粗体: %+v", required)
|
||
}
|
||
lastColumnWidth, err := f.GetColWidth(MaterialImportSheetData, "E")
|
||
if err != nil || lastColumnWidth != 13 {
|
||
t.Fatalf("物料数据 E 列宽 = %v, err = %v, want 13", lastColumnWidth, err)
|
||
}
|
||
|
||
if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 {
|
||
t.Fatal("模板缺少「物料数据」工作表")
|
||
}
|
||
if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 {
|
||
t.Fatal("模板缺少「填写说明」工作表")
|
||
}
|
||
headers, err := f.GetRows(MaterialImportSheetData)
|
||
if err != nil {
|
||
t.Fatalf("read rows: %v", err)
|
||
}
|
||
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" {
|
||
t.Fatalf("表头 = %v", headers)
|
||
}
|
||
// 模板必须能被自己的解析器接受(表头一致)
|
||
if rows, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil {
|
||
t.Fatalf("模板无法被解析器接受: %v", err)
|
||
} else if len(rows) != 0 {
|
||
t.Fatal("数据页不应包含会被实际导入的示例物料")
|
||
}
|
||
}
|
||
|
||
// buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。
|
||
func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
|
||
t.Helper()
|
||
return buildTestWorkbookWithHeaders(t, materialImportHeaders, dataRows)
|
||
}
|
||
|
||
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
|
||
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
|
||
t.Helper()
|
||
f := excelize.NewFile()
|
||
defer f.Close()
|
||
f.SetSheetName("Sheet1", MaterialImportSheetData)
|
||
for i, header := range headers {
|
||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||
f.SetCellValue(MaterialImportSheetData, cell, header)
|
||
}
|
||
for r, row := range dataRows {
|
||
for c, value := range row {
|
||
cell, _ := excelize.CoordinatesToCellName(c+1, r+2)
|
||
f.SetCellValue(MaterialImportSheetData, cell, value)
|
||
}
|
||
}
|
||
buf, err := f.WriteToBuffer()
|
||
if err != nil {
|
||
t.Fatalf("write workbook: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|