575 lines
16 KiB
Go
575 lines
16 KiB
Go
package docfill
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestRunResolvesConfigRelativePathsAndSanitizesOutputFileNames(t *testing.T) {
|
||
dir := t.TempDir()
|
||
taskDir := filepath.Join(dir, "task")
|
||
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
||
t.Fatalf("make task dir: %v", err)
|
||
}
|
||
|
||
writeWorkbook(t, filepath.Join(taskDir, "data.xlsx"), "Daily", [][]string{
|
||
{"name", "done"},
|
||
{"张/三:测试", "已完成"},
|
||
})
|
||
writeWorkbook(t, filepath.Join(taskDir, "template.xlsx"), "日报", [][]string{
|
||
{"{{name}}", "{{done}}"},
|
||
})
|
||
configPath := filepath.Join(taskDir, "task.json")
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": "./data.xlsx",
|
||
"sheet": "Daily",
|
||
},
|
||
"template": map[string]any{
|
||
"path": "./template.xlsx",
|
||
},
|
||
"output": map[string]any{
|
||
"path": "./out/日报_{{name}}.xlsx",
|
||
},
|
||
})
|
||
|
||
result, err := RunWithOptions(configPath, RunOptions{})
|
||
if err != nil {
|
||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||
}
|
||
|
||
wantPath := filepath.Join(taskDir, "out", "日报_张_三_测试.xlsx")
|
||
if len(result.Files) != 1 || result.Files[0] != wantPath {
|
||
t.Fatalf("result files = %#v, want %q", result.Files, wantPath)
|
||
}
|
||
assertWorkbookCell(t, wantPath, "日报", "A1", "张/三:测试")
|
||
}
|
||
|
||
func TestRunUsesVarsForSQLiteParamsAndTemplateDefaults(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "daily.db")
|
||
templatePath := filepath.Join(dir, "template.docx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
seedP0SQLite(t, dbPath, `
|
||
create table daily (name text, date text, done text);
|
||
insert into daily values
|
||
('张三', '2026-06-18', '完成旧日报'),
|
||
('李四', '2026-06-19', '完成新日报');
|
||
`)
|
||
writeMinimalDocx(t, templatePath, "批次:{{batch}}\n日期:{{date}}\n员工:{{name}}\n内容:{{done}}")
|
||
writeJSON(t, configPath, map[string]any{
|
||
"vars": map[string]any{
|
||
"date": "2026-06-18",
|
||
"batch": "默认批次",
|
||
},
|
||
"data": map[string]any{
|
||
"type": "sqlite",
|
||
"path": dbPath,
|
||
"query": "select name, date, done from daily where date = ?",
|
||
"params": []any{"{{date}}"},
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "日报_{{batch}}_{{date}}_{{name}}.docx"),
|
||
},
|
||
})
|
||
|
||
result, err := RunWithOptions(configPath, RunOptions{
|
||
Vars: map[string]string{
|
||
"date": "2026-06-19",
|
||
"batch": "命令行批次",
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||
}
|
||
|
||
if len(result.Files) != 1 {
|
||
t.Fatalf("expected 1 generated file, got %#v", result.Files)
|
||
}
|
||
got := readZipEntry(t, result.Files[0], "word/document.xml")
|
||
if !strings.Contains(got, "批次:命令行批次") || !strings.Contains(got, "员工:李四") {
|
||
t.Fatalf("unexpected rendered docx body: %q", got)
|
||
}
|
||
if strings.Contains(got, "张三") || strings.Contains(result.Files[0], "默认批次") {
|
||
t.Fatalf("CLI vars did not override config vars: file=%q body=%q", result.Files[0], got)
|
||
}
|
||
}
|
||
|
||
func TestRunSupportsExcelHeaderRow(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "data.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||
{"日报导出数据"},
|
||
{"name", "done"},
|
||
{"张三", "完成日报"},
|
||
})
|
||
writeWorkbook(t, templatePath, "日报", [][]string{
|
||
{"员工", "{{name}}"},
|
||
{"完成", "{{done}}"},
|
||
})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "Daily",
|
||
"header_row": 2,
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
if err := Run(configPath); err != nil {
|
||
t.Fatalf("Run returned error: %v", err)
|
||
}
|
||
|
||
assertWorkbookCell(t, filepath.Join(dir, "out", "日报_张三.xlsx"), "日报", "B1", "张三")
|
||
}
|
||
|
||
func TestRunRefusesToOverwriteUnlessConfigured(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "data.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
outputPath := filepath.Join(dir, "out", "日报_张三.xlsx")
|
||
|
||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||
{"name"},
|
||
{"张三"},
|
||
})
|
||
writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}"}})
|
||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||
t.Fatalf("make output dir: %v", err)
|
||
}
|
||
if err := os.WriteFile(outputPath, []byte("existing"), 0o644); err != nil {
|
||
t.Fatalf("write existing output: %v", err)
|
||
}
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "Daily",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": outputPath,
|
||
},
|
||
})
|
||
|
||
err := Run(configPath)
|
||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||
t.Fatalf("expected already exists error, got %v", err)
|
||
}
|
||
var appErr *AppError
|
||
if !errors.As(err, &appErr) {
|
||
t.Fatalf("expected AppError, got %T", err)
|
||
}
|
||
if appErr.Code != CodeOutputExists {
|
||
t.Fatalf("error code = %s, want %s", appErr.Code, CodeOutputExists)
|
||
}
|
||
if appErr.Hint == "" || appErr.Context["output"] != outputPath {
|
||
t.Fatalf("expected hint and output context, got %#v", appErr)
|
||
}
|
||
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "Daily",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": outputPath,
|
||
"overwrite": true,
|
||
},
|
||
})
|
||
if err := Run(configPath); err != nil {
|
||
t.Fatalf("Run with overwrite returned error: %v", err)
|
||
}
|
||
assertWorkbookCell(t, outputPath, "日报", "A1", "张三")
|
||
}
|
||
|
||
func TestRunFailsWhenTemplatePlaceholderIsMissing(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "data.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||
{"name"},
|
||
{"张三"},
|
||
})
|
||
writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}", "{{missing}}"}})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "Daily",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
err := Run(configPath)
|
||
if err == nil || !strings.Contains(err.Error(), "missing template field") {
|
||
t.Fatalf("expected missing template field error, got %v", err)
|
||
}
|
||
var appErr *AppError
|
||
if !errors.As(err, &appErr) {
|
||
t.Fatalf("expected AppError, got %T", err)
|
||
}
|
||
if appErr.Code != CodeTemplateFieldMissing {
|
||
t.Fatalf("error code = %s, want %s", appErr.Code, CodeTemplateFieldMissing)
|
||
}
|
||
if appErr.Context["field"] != "missing" || appErr.Context["template"] != templatePath {
|
||
t.Fatalf("unexpected context: %#v", appErr.Context)
|
||
}
|
||
if !strings.Contains(appErr.Hint, "missing") {
|
||
t.Fatalf("unexpected hint: %q", appErr.Hint)
|
||
}
|
||
}
|
||
|
||
func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "data.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||
{"name", "done"},
|
||
{"张三", "{{still_pending}}"},
|
||
})
|
||
writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}", "{{done}}"}})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "Daily",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
err := Run(configPath)
|
||
if err == nil || !strings.Contains(err.Error(), "unresolved placeholder") {
|
||
t.Fatalf("expected unresolved placeholder error, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRunFiltersExcelRowsByMultipleEqualsAndVars(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||
templatePath := filepath.Join(dir, "ticket-template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||
{"日期", "工单编号", "客户名称", "处理结果"},
|
||
{"2026-06-18", "GD-001", "A 公司", "已派单"},
|
||
{"2026-06-18", "GD-002", "B 公司", "已完成"},
|
||
{"2026-06-19", "GD-001", "C 公司", "待复核"},
|
||
})
|
||
writeWorkbook(t, templatePath, "工单", [][]string{
|
||
{"编号", "{{工单编号}}"},
|
||
{"客户", "{{客户名称}}"},
|
||
{"结果", "{{处理结果}}"},
|
||
})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"vars": map[string]any{
|
||
"date": "2026-06-17",
|
||
},
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "工单",
|
||
},
|
||
"filter": map[string]any{
|
||
"equals": map[string]any{
|
||
"日期": "{{date}}",
|
||
"工单编号": "{{ticket_no}}",
|
||
},
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "工单_{{日期}}_{{工单编号}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
result, err := RunWithOptions(configPath, RunOptions{
|
||
Vars: map[string]string{
|
||
"date": "2026-06-18",
|
||
"ticket_no": "GD-002",
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||
}
|
||
|
||
wantPath := filepath.Join(dir, "out", "工单_2026-06-18_GD-002.xlsx")
|
||
if len(result.Files) != 1 || result.Files[0] != wantPath {
|
||
t.Fatalf("result files = %#v, want only %q", result.Files, wantPath)
|
||
}
|
||
assertWorkbookCell(t, wantPath, "工单", "B2", "B 公司")
|
||
assertWorkbookCell(t, wantPath, "工单", "B3", "已完成")
|
||
}
|
||
|
||
func TestRunFilterAllowsManyRowsWhenConfigured(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "tickets.db")
|
||
templatePath := filepath.Join(dir, "ticket-template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
seedP0SQLite(t, dbPath, `
|
||
create table tickets (
|
||
report_date text,
|
||
department text,
|
||
ticket_no text,
|
||
result text
|
||
);
|
||
insert into tickets values
|
||
('2026-06-18', '营销部', 'GD-001', '已派单'),
|
||
('2026-06-18', '客服部', 'GD-002', '已完成'),
|
||
('2026-06-19', '营销部', 'GD-003', '待复核');
|
||
`)
|
||
writeWorkbook(t, templatePath, "工单", [][]string{
|
||
{"编号", "{{ticket_no}}"},
|
||
{"部门", "{{department}}"},
|
||
{"结果", "{{result}}"},
|
||
})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "sqlite",
|
||
"path": dbPath,
|
||
"query": "select report_date, department, ticket_no, result from tickets order by ticket_no",
|
||
},
|
||
"filter": map[string]any{
|
||
"equals": map[string]any{
|
||
"report_date": "{{date}}",
|
||
},
|
||
"expect": "many",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "工单_{{ticket_no}}_{{_row}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
result, err := RunWithOptions(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}})
|
||
if err != nil {
|
||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||
}
|
||
|
||
if len(result.Files) != 2 {
|
||
t.Fatalf("expected 2 filtered files, got %#v", result.Files)
|
||
}
|
||
assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-001_1.xlsx"), "工单", "B3", "已派单")
|
||
assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-002_2.xlsx"), "工单", "B2", "客服部")
|
||
}
|
||
|
||
func TestRunFilterFailsWhenNoRowsMatch(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||
{"日期", "工单编号", "处理结果"},
|
||
{"2026-06-18", "GD-001", "已派单"},
|
||
})
|
||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "工单",
|
||
},
|
||
"filter": map[string]any{
|
||
"equals": map[string]any{
|
||
"日期": "{{date}}",
|
||
"工单编号": "{{ticket_no}}",
|
||
},
|
||
"expect": "one",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{
|
||
"date": "2026-06-18",
|
||
"ticket_no": "GD-999",
|
||
}})
|
||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_NO_MATCH"))
|
||
}
|
||
|
||
func TestRunFilterFailsWhenOneRowExpectedButManyMatch(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||
{"日期", "工单编号", "处理结果"},
|
||
{"2026-06-18", "GD-001", "已派单"},
|
||
{"2026-06-18", "GD-002", "已完成"},
|
||
})
|
||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "工单",
|
||
},
|
||
"filter": map[string]any{
|
||
"equals": map[string]any{
|
||
"日期": "{{date}}",
|
||
},
|
||
"expect": "one",
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}})
|
||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_MULTI_MATCH"))
|
||
}
|
||
|
||
func TestRunFilterFailsWhenFieldIsMissing(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||
{"日期", "处理结果"},
|
||
{"2026-06-18", "已派单"},
|
||
})
|
||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{处理结果}}"}})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "工单",
|
||
},
|
||
"filter": map[string]any{
|
||
"equals": map[string]any{
|
||
"工单编号": "{{ticket_no}}",
|
||
},
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "工单.xlsx"),
|
||
},
|
||
})
|
||
|
||
err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"ticket_no": "GD-001"}})
|
||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_FIELD"))
|
||
}
|
||
|
||
func TestRunFilterFailsWhenVariableIsMissing(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dataPath := filepath.Join(dir, "tickets.xlsx")
|
||
templatePath := filepath.Join(dir, "template.xlsx")
|
||
configPath := filepath.Join(dir, "task.json")
|
||
|
||
writeWorkbook(t, dataPath, "工单", [][]string{
|
||
{"日期", "工单编号", "处理结果"},
|
||
{"2026-06-18", "GD-001", "已派单"},
|
||
})
|
||
writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}})
|
||
writeJSON(t, configPath, map[string]any{
|
||
"data": map[string]any{
|
||
"type": "excel",
|
||
"path": dataPath,
|
||
"sheet": "工单",
|
||
},
|
||
"filter": map[string]any{
|
||
"equals": map[string]any{
|
||
"工单编号": "{{ticket_no}}",
|
||
},
|
||
},
|
||
"template": map[string]any{
|
||
"path": templatePath,
|
||
},
|
||
"output": map[string]any{
|
||
"path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"),
|
||
},
|
||
})
|
||
|
||
err := RunWithOptionsError(configPath, RunOptions{})
|
||
assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_VALUE"))
|
||
}
|
||
|
||
func RunWithOptionsError(configPath string, opts RunOptions) error {
|
||
_, err := RunWithOptions(configPath, opts)
|
||
return err
|
||
}
|
||
|
||
func assertAppErrorCode(t *testing.T, err error, want ErrorCode) {
|
||
t.Helper()
|
||
|
||
if err == nil {
|
||
t.Fatalf("expected %s error, got nil", want)
|
||
}
|
||
var appErr *AppError
|
||
if !errors.As(err, &appErr) {
|
||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||
}
|
||
if appErr.Code != want {
|
||
t.Fatalf("error code = %s, want %s; error=%v", appErr.Code, want, err)
|
||
}
|
||
}
|
||
|
||
func seedP0SQLite(t *testing.T, path, statements string) {
|
||
t.Helper()
|
||
|
||
db, err := sql.Open("sqlite", path)
|
||
if err != nil {
|
||
t.Fatalf("open sqlite: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
if _, err := db.Exec(statements); err != nil {
|
||
t.Fatalf("seed sqlite: %v", err)
|
||
}
|
||
}
|