1974 lines
47 KiB
Go
1974 lines
47 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"google.golang.org/api/docs/v1"
|
|
"google.golang.org/api/option"
|
|
|
|
"github.com/steipete/gogcli/internal/ui"
|
|
)
|
|
|
|
// --- Unit tests for parseSedExpr ---
|
|
|
|
func TestParseSedExpr_Basic(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantPattern string
|
|
wantReplace string
|
|
wantGlobal bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple replacement",
|
|
expr: "s/foo/bar/",
|
|
wantPattern: "foo",
|
|
wantReplace: "bar",
|
|
wantGlobal: false,
|
|
},
|
|
{
|
|
name: "global flag",
|
|
expr: "s/foo/bar/g",
|
|
wantPattern: "foo",
|
|
wantReplace: "bar",
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "empty replacement",
|
|
expr: "s/foo//",
|
|
wantPattern: "foo",
|
|
wantReplace: "",
|
|
wantGlobal: false,
|
|
},
|
|
{
|
|
name: "regex pattern",
|
|
expr: `s/\d+/NUM/g`,
|
|
wantPattern: `\d+`,
|
|
wantReplace: "NUM",
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "backreference conversion",
|
|
expr: `s/(foo)/\1bar/`,
|
|
wantPattern: "(foo)",
|
|
wantReplace: "${1}bar",
|
|
wantGlobal: false,
|
|
},
|
|
{
|
|
name: "alternate delimiter",
|
|
expr: "s#foo#bar#g",
|
|
wantPattern: "foo",
|
|
wantReplace: "bar",
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "pipe delimiter",
|
|
expr: "s|path/to/file|new/path|",
|
|
wantPattern: "path/to/file",
|
|
wantReplace: "new/path",
|
|
wantGlobal: false,
|
|
},
|
|
{
|
|
name: "invalid - not starting with s",
|
|
expr: "x/foo/bar/",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid - too short",
|
|
expr: "s/",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid - missing replacement",
|
|
expr: "s/foo",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pattern, replacement, global, err := parseSedExpr(tt.expr)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if pattern != tt.wantPattern {
|
|
t.Errorf("pattern = %q, want %q", pattern, tt.wantPattern)
|
|
}
|
|
if replacement != tt.wantReplace {
|
|
t.Errorf("replacement = %q, want %q", replacement, tt.wantReplace)
|
|
}
|
|
if global != tt.wantGlobal {
|
|
t.Errorf("global = %v, want %v", global, tt.wantGlobal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Unit tests for parseMarkdownReplacement ---
|
|
|
|
func TestParseMarkdownReplacement(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantText string
|
|
wantFormats []string
|
|
}{
|
|
{
|
|
name: "plain text",
|
|
input: "hello world",
|
|
wantText: "hello world",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "bold",
|
|
input: "**bold text**",
|
|
wantText: "bold text",
|
|
wantFormats: []string{"bold"},
|
|
},
|
|
{
|
|
name: "italic",
|
|
input: "*italic text*",
|
|
wantText: "italic text",
|
|
wantFormats: []string{"italic"},
|
|
},
|
|
{
|
|
name: "bold italic",
|
|
input: "***bold italic***",
|
|
wantText: "bold italic",
|
|
wantFormats: []string{"bold", "italic"},
|
|
},
|
|
{
|
|
name: "strikethrough",
|
|
input: "~~crossed out~~",
|
|
wantText: "crossed out",
|
|
wantFormats: []string{"strikethrough"},
|
|
},
|
|
{
|
|
name: "code",
|
|
input: "`inline code`",
|
|
wantText: "inline code",
|
|
wantFormats: []string{"code"},
|
|
},
|
|
{
|
|
name: "heading 1",
|
|
input: "# Title",
|
|
wantText: "Title",
|
|
wantFormats: []string{"heading1"},
|
|
},
|
|
{
|
|
name: "heading 2",
|
|
input: "## Subtitle",
|
|
wantText: "Subtitle",
|
|
wantFormats: []string{"heading2"},
|
|
},
|
|
{
|
|
name: "heading 3",
|
|
input: "### Section",
|
|
wantText: "Section",
|
|
wantFormats: []string{"heading3"},
|
|
},
|
|
{
|
|
name: "heading 6",
|
|
input: "###### Deep",
|
|
wantText: "Deep",
|
|
wantFormats: []string{"heading6"},
|
|
},
|
|
{
|
|
name: "heading no space",
|
|
input: "##NoSpace",
|
|
wantText: "NoSpace",
|
|
wantFormats: []string{"heading2"},
|
|
},
|
|
{
|
|
name: "bullet list dash",
|
|
input: "- list item",
|
|
wantText: "list item",
|
|
wantFormats: []string{"bullet"},
|
|
},
|
|
{
|
|
name: "bullet list asterisk",
|
|
input: "* list item",
|
|
wantText: "list item",
|
|
wantFormats: []string{"bullet"},
|
|
},
|
|
{
|
|
name: "numbered list",
|
|
input: "1. first item",
|
|
wantText: "first item",
|
|
wantFormats: []string{"numbered"},
|
|
},
|
|
{
|
|
name: "newline escape",
|
|
input: "line1\\nline2",
|
|
wantText: "line1\nline2",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "bullet with bold",
|
|
input: "- **bold item**",
|
|
wantText: "bold item",
|
|
wantFormats: []string{"bullet", "bold"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
text, formats := parseMarkdownReplacement(tt.input)
|
|
if text != tt.wantText {
|
|
t.Errorf("text = %q, want %q", text, tt.wantText)
|
|
}
|
|
if len(formats) != len(tt.wantFormats) {
|
|
t.Errorf("formats = %v, want %v", formats, tt.wantFormats)
|
|
return
|
|
}
|
|
for i, f := range formats {
|
|
if f != tt.wantFormats[i] {
|
|
t.Errorf("formats[%d] = %q, want %q", i, f, tt.wantFormats[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Integration tests for DocsEditCmd ---
|
|
|
|
// mockDocsServer creates a test server that simulates the Google Docs API
|
|
func mockDocsServer(t *testing.T, docContent string, onBatchUpdate func(reqs []*docs.Request)) *httptest.Server {
|
|
t.Helper()
|
|
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
// GET /v1/documents/{docId}
|
|
if r.Method == http.MethodGet && strings.Contains(path, "/documents/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
doc := &docs.Document{
|
|
DocumentId: "test-doc-id",
|
|
Title: "Test Document",
|
|
Body: &docs.Body{
|
|
Content: []*docs.StructuralElement{
|
|
{
|
|
StartIndex: 0,
|
|
EndIndex: int64(len(docContent)),
|
|
Paragraph: &docs.Paragraph{
|
|
Elements: []*docs.ParagraphElement{
|
|
{
|
|
StartIndex: 0,
|
|
EndIndex: int64(len(docContent)),
|
|
TextRun: &docs.TextRun{
|
|
Content: docContent,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
_ = json.NewEncoder(w).Encode(doc)
|
|
return
|
|
}
|
|
|
|
// POST /v1/documents/{docId}:batchUpdate
|
|
if r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate") {
|
|
var req docs.BatchUpdateDocumentRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if onBatchUpdate != nil {
|
|
onBatchUpdate(req.Requests)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(&docs.BatchUpdateDocumentResponse{
|
|
DocumentId: "test-doc-id",
|
|
})
|
|
return
|
|
}
|
|
|
|
http.NotFound(w, r)
|
|
}))
|
|
}
|
|
|
|
func TestDocsEditCmd_JSON(t *testing.T) {
|
|
var capturedReqs []*docs.Request
|
|
srv := mockDocsServer(t, "Hello world, hello universe!", func(reqs []*docs.Request) {
|
|
capturedReqs = reqs
|
|
})
|
|
defer srv.Close()
|
|
|
|
// Create docs service with test server
|
|
docsSvc, err := docs.NewService(context.Background(),
|
|
option.WithoutAuthentication(),
|
|
option.WithEndpoint(srv.URL+"/"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
|
|
// We need to inject the mock service - for now test the helper functions
|
|
// Full integration requires refactoring to use a mockable service variable
|
|
_ = docsSvc
|
|
|
|
// Test that parseSedExpr handles edit-style input correctly
|
|
// The edit command internally constructs a simple replacement
|
|
pattern := "hello"
|
|
replacement := "hi"
|
|
|
|
if pattern != "hello" || replacement != "hi" {
|
|
t.Errorf("unexpected values")
|
|
}
|
|
|
|
// Verify captured requests would have correct structure
|
|
if len(capturedReqs) > 0 {
|
|
for _, req := range capturedReqs {
|
|
if req.ReplaceAllText == nil {
|
|
t.Error("expected ReplaceAllText request")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocsSedCmd_RegexMatching(t *testing.T) {
|
|
// Test that regex patterns compile and match correctly
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
input string
|
|
want string
|
|
wantN int // expected number of matches with global
|
|
}{
|
|
{
|
|
name: "simple global replace",
|
|
expr: "s/foo/bar/g",
|
|
input: "foo foo foo", //nolint:dupword
|
|
want: "bar bar bar", //nolint:dupword
|
|
wantN: 3,
|
|
},
|
|
{
|
|
name: "first match only",
|
|
expr: "s/foo/bar/",
|
|
input: "foo foo foo", //nolint:dupword
|
|
want: "bar foo foo", //nolint:dupword
|
|
wantN: 1,
|
|
},
|
|
{
|
|
name: "digit replacement",
|
|
expr: `s/\d+/NUM/g`,
|
|
input: "item1 item2 item3",
|
|
want: "itemNUM itemNUM itemNUM", //nolint:dupword
|
|
wantN: 3,
|
|
},
|
|
{
|
|
name: "capture group",
|
|
expr: `s/(\w+)@(\w+)/\2:\1/g`,
|
|
input: "user@host",
|
|
want: "host:user",
|
|
wantN: 1,
|
|
},
|
|
{
|
|
name: "word boundary",
|
|
expr: `s/\bcat\b/dog/g`,
|
|
input: "cat catalog bobcat cat",
|
|
want: "dog catalog bobcat dog",
|
|
wantN: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pattern, replacement, global, err := parseSedExpr(tt.expr)
|
|
if err != nil {
|
|
t.Fatalf("parseSedExpr: %v", err)
|
|
}
|
|
|
|
re, err := compilePattern(pattern)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
|
|
var result string
|
|
var count int
|
|
if global {
|
|
result = re.ReplaceAllString(tt.input, replacement)
|
|
count = len(re.FindAllString(tt.input, -1))
|
|
} else {
|
|
loc := re.FindStringIndex(tt.input)
|
|
if loc != nil {
|
|
result = tt.input[:loc[0]] + re.ReplaceAllString(tt.input[loc[0]:loc[1]], replacement) + tt.input[loc[1]:]
|
|
count = 1
|
|
} else {
|
|
result = tt.input
|
|
count = 0
|
|
}
|
|
}
|
|
|
|
if result != tt.want {
|
|
t.Errorf("result = %q, want %q", result, tt.want)
|
|
}
|
|
if count != tt.wantN {
|
|
t.Errorf("match count = %d, want %d", count, tt.wantN)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// compilePattern is a helper for tests (mirrors internal logic)
|
|
func compilePattern(pattern string) (*regexp.Regexp, error) {
|
|
return regexp.Compile(pattern)
|
|
}
|
|
|
|
func TestDocsEditCmd_EmptyDocId(t *testing.T) {
|
|
u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
|
ctx := ui.WithUI(context.Background(), u)
|
|
|
|
cmd := &DocsEditCmd{
|
|
DocID: " ",
|
|
Find: "foo",
|
|
ReplaceStr: "bar",
|
|
}
|
|
|
|
flags := &RootFlags{Account: "test@example.com"}
|
|
err := cmd.Run(ctx, flags)
|
|
if err == nil {
|
|
t.Error("expected error for empty docId")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("error = %v, want error containing 'empty'", err)
|
|
}
|
|
}
|
|
|
|
func TestDocsEditCmd_EmptyFind(t *testing.T) {
|
|
u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
|
ctx := ui.WithUI(context.Background(), u)
|
|
|
|
cmd := &DocsEditCmd{
|
|
DocID: "doc123",
|
|
Find: "",
|
|
ReplaceStr: "bar",
|
|
}
|
|
|
|
flags := &RootFlags{Account: "test@example.com"}
|
|
err := cmd.Run(ctx, flags)
|
|
if err == nil {
|
|
t.Error("expected error for empty find")
|
|
}
|
|
}
|
|
|
|
func TestDocsSedCmd_InvalidExpression(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
}{
|
|
{"not starting with s", "x/foo/bar/"},
|
|
{"too short", "s/"},
|
|
{"missing replacement", "s/foo"},
|
|
{"empty", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, _, _, err := parseSedExpr(tt.expr)
|
|
if err == nil {
|
|
t.Errorf("expected error for expr %q", tt.expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsSedCmd_InvalidRegex(t *testing.T) {
|
|
// Valid sed syntax but invalid regex
|
|
pattern, _, _, err := parseSedExpr("s/[unclosed/replacement/")
|
|
if err != nil {
|
|
// parseSedExpr might not catch this - it's caught at compile time
|
|
return
|
|
}
|
|
|
|
_, err = regexp.Compile(pattern)
|
|
if err == nil {
|
|
t.Error("expected regex compile error for unclosed bracket")
|
|
}
|
|
}
|
|
|
|
// Test output formats
|
|
func TestDocsEditCmd_OutputFormat_JSON(t *testing.T) {
|
|
// This test verifies the JSON output structure
|
|
// Full integration would require mocking the Docs service
|
|
|
|
expectedFields := []string{"status", "docId", "replaced"}
|
|
output := map[string]any{
|
|
"status": "ok",
|
|
"docId": "test-doc",
|
|
"replaced": 5,
|
|
}
|
|
|
|
for _, field := range expectedFields {
|
|
if _, ok := output[field]; !ok {
|
|
t.Errorf("missing field %q in JSON output", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocsEditCmd_OutputFormat_Text(t *testing.T) {
|
|
// Verify text output format matches expected tab-separated format
|
|
lines := []string{
|
|
"status\tok",
|
|
"docId\ttest-doc",
|
|
"replaced\t5",
|
|
}
|
|
|
|
for _, line := range lines {
|
|
parts := strings.Split(line, "\t")
|
|
if len(parts) != 2 {
|
|
t.Errorf("line %q should have 2 tab-separated parts", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Additional edge case tests ---
|
|
|
|
func TestParseSedExpr_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantPattern string
|
|
wantReplace string
|
|
wantGlobal bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "multiple backrefs",
|
|
expr: `s/(\w+)\s+(\w+)/\2 \1/g`,
|
|
wantPattern: `(\w+)\s+(\w+)`,
|
|
wantReplace: "${2} ${1}",
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "replacement with slashes using alternate delim",
|
|
expr: `s#/usr/local#/opt/homebrew#g`,
|
|
wantPattern: "/usr/local",
|
|
wantReplace: "/opt/homebrew",
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "empty pattern",
|
|
expr: "s//replacement/",
|
|
wantPattern: "",
|
|
wantReplace: "replacement",
|
|
wantGlobal: false,
|
|
},
|
|
{
|
|
name: "special regex chars in pattern",
|
|
expr: `s/\$\d+\.\d{2}/PRICE/g`,
|
|
wantPattern: `\$\d+\.\d{2}`,
|
|
wantReplace: "PRICE",
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "newline escape preserved",
|
|
expr: `s/;/;\n/g`,
|
|
wantPattern: ";",
|
|
wantReplace: ";\\n", // \n preserved for parseMarkdownReplacement to handle
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "tab escape preserved",
|
|
expr: `s/,/\t/g`,
|
|
wantPattern: ",",
|
|
wantReplace: "\\t", // \t preserved as-is
|
|
wantGlobal: true,
|
|
},
|
|
{
|
|
name: "just s",
|
|
expr: "s",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "s with delimiter only",
|
|
expr: "s/",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pattern, replacement, global, err := parseSedExpr(tt.expr)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if pattern != tt.wantPattern {
|
|
t.Errorf("pattern = %q, want %q", pattern, tt.wantPattern)
|
|
}
|
|
if replacement != tt.wantReplace {
|
|
t.Errorf("replacement = %q, want %q", replacement, tt.wantReplace)
|
|
}
|
|
if global != tt.wantGlobal {
|
|
t.Errorf("global = %v, want %v", global, tt.wantGlobal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseMarkdownReplacement_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantText string
|
|
wantFormats []string
|
|
}{
|
|
{
|
|
name: "multiple newlines",
|
|
input: "line1\\nline2\\nline3",
|
|
wantText: "line1\nline2\nline3",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "heading with trailing space",
|
|
input: "## Title ",
|
|
wantText: "Title ",
|
|
wantFormats: []string{"heading2"},
|
|
},
|
|
{
|
|
name: "bold with spaces inside",
|
|
input: "**bold with spaces**",
|
|
wantText: "bold with spaces",
|
|
wantFormats: []string{"bold"},
|
|
},
|
|
{
|
|
name: "not bold - unmatched asterisks",
|
|
input: "**not bold",
|
|
wantText: "**not bold",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "not italic - single asterisk",
|
|
input: "*",
|
|
wantText: "*",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "numbered list double digit",
|
|
input: "12. twelfth item",
|
|
wantText: "12. twelfth item", // only single digit supported
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "code with special chars",
|
|
input: "`func main() {}`",
|
|
wantText: "func main() {}",
|
|
wantFormats: []string{"code"},
|
|
},
|
|
{
|
|
name: "strikethrough with emoji",
|
|
input: "~~old value 🎉~~",
|
|
wantText: "old value 🎉",
|
|
wantFormats: []string{"strikethrough"},
|
|
},
|
|
{
|
|
name: "heading 4",
|
|
input: "#### H4 Title",
|
|
wantText: "H4 Title",
|
|
wantFormats: []string{"heading4"},
|
|
},
|
|
{
|
|
name: "heading 5",
|
|
input: "##### H5 Title",
|
|
wantText: "H5 Title",
|
|
wantFormats: []string{"heading5"},
|
|
},
|
|
{
|
|
name: "four asterisks parsed as italic",
|
|
input: "****",
|
|
wantText: "**", // outer * stripped as italic markers
|
|
wantFormats: []string{"italic"},
|
|
},
|
|
{
|
|
name: "empty code",
|
|
input: "``",
|
|
wantText: "``",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "bullet then italic",
|
|
input: "- *italic item*",
|
|
wantText: "italic item",
|
|
wantFormats: []string{"bullet", "italic"},
|
|
},
|
|
{
|
|
name: "numbered then bold",
|
|
input: "1. **important**",
|
|
wantText: "important",
|
|
wantFormats: []string{"numbered", "bold"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
text, formats := parseMarkdownReplacement(tt.input)
|
|
if text != tt.wantText {
|
|
t.Errorf("text = %q, want %q", text, tt.wantText)
|
|
}
|
|
if len(formats) != len(tt.wantFormats) {
|
|
t.Errorf("formats = %v, want %v", formats, tt.wantFormats)
|
|
return
|
|
}
|
|
for i, f := range formats {
|
|
if f != tt.wantFormats[i] {
|
|
t.Errorf("formats[%d] = %q, want %q", i, f, tt.wantFormats[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsSedCmd_ComplexPatterns(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
input string
|
|
want string
|
|
wantN int
|
|
}{
|
|
{
|
|
name: "email obfuscation",
|
|
expr: `s/(\w+)@(\w+)\.(\w+)/\1[at]\2[dot]\3/g`,
|
|
input: "Contact: john@example.com or jane@test.org",
|
|
want: "Contact: john[at]example[dot]com or jane[at]test[dot]org",
|
|
wantN: 2,
|
|
},
|
|
{
|
|
name: "date format conversion",
|
|
expr: `s#(\d{4})-(\d{2})-(\d{2})#\2/\3/\1#g`, // use # delimiter since replacement has /
|
|
input: "Date: 2026-02-07",
|
|
want: "Date: 02/07/2026",
|
|
wantN: 1,
|
|
},
|
|
{
|
|
name: "remove html tags",
|
|
expr: `s/<[^>]+>//g`,
|
|
input: "<p>Hello <b>world</b></p>",
|
|
want: "Hello world",
|
|
wantN: 4,
|
|
},
|
|
{
|
|
name: "camelCase to snake_case",
|
|
expr: `s/([a-z])([A-Z])/\1_\2/g`,
|
|
input: "getUserName",
|
|
want: "get_User_Name",
|
|
wantN: 2,
|
|
},
|
|
{
|
|
name: "trim whitespace",
|
|
expr: `s/^\s+|\s+$//g`,
|
|
input: " hello world ",
|
|
want: "hello world",
|
|
wantN: 2,
|
|
},
|
|
{
|
|
name: "collapse whitespace",
|
|
expr: `s/\s+/ /g`,
|
|
input: "hello world\t\tfoo",
|
|
want: "hello world foo",
|
|
wantN: 2,
|
|
},
|
|
{
|
|
name: "quote words",
|
|
expr: `s/\b(\w+)\b/"\1"/g`,
|
|
input: "hello world",
|
|
want: `"hello" "world"`,
|
|
wantN: 2,
|
|
},
|
|
{
|
|
name: "version bump",
|
|
expr: `s/v(\d+)\.(\d+)\.(\d+)/v\1.\2.999/`,
|
|
input: "Current: v1.2.3",
|
|
want: "Current: v1.2.999",
|
|
wantN: 1,
|
|
},
|
|
{
|
|
name: "no match",
|
|
expr: `s/xyz/abc/g`,
|
|
input: "hello world",
|
|
want: "hello world",
|
|
wantN: 0,
|
|
},
|
|
{
|
|
name: "overlapping matches",
|
|
expr: `s/aa/a/g`,
|
|
input: "aaaa",
|
|
want: "aa", // regex replaces non-overlapping
|
|
wantN: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pattern, replacement, global, err := parseSedExpr(tt.expr)
|
|
if err != nil {
|
|
t.Fatalf("parseSedExpr: %v", err)
|
|
}
|
|
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
|
|
var result string
|
|
var count int
|
|
if global {
|
|
matches := re.FindAllStringIndex(tt.input, -1)
|
|
count = len(matches)
|
|
result = re.ReplaceAllString(tt.input, replacement)
|
|
} else {
|
|
if re.MatchString(tt.input) {
|
|
result = re.ReplaceAllStringFunc(tt.input, func(s string) string {
|
|
count++
|
|
if count > 1 {
|
|
return s
|
|
}
|
|
return re.ReplaceAllString(s, replacement)
|
|
})
|
|
} else {
|
|
result = tt.input
|
|
}
|
|
}
|
|
|
|
if result != tt.want {
|
|
t.Errorf("result = %q, want %q", result, tt.want)
|
|
}
|
|
if count != tt.wantN {
|
|
t.Errorf("match count = %d, want %d", count, tt.wantN)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsSedCmd_EmptyDocId(t *testing.T) {
|
|
u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
|
ctx := ui.WithUI(context.Background(), u)
|
|
|
|
cmd := &DocsSedCmd{
|
|
DocID: " ",
|
|
Expression: "s/foo/bar/",
|
|
}
|
|
|
|
flags := &RootFlags{Account: "test@example.com"}
|
|
err := cmd.Run(ctx, flags)
|
|
if err == nil {
|
|
t.Error("expected error for empty docId")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("error = %v, want error containing 'empty'", err)
|
|
}
|
|
}
|
|
|
|
func TestDocsEditCmd_MatchCase(t *testing.T) {
|
|
// Test that MatchCase flag is properly set.
|
|
cmd := DocsEditCmd{MatchCase: true}
|
|
if !cmd.MatchCase {
|
|
t.Error("MatchCase should be true")
|
|
}
|
|
|
|
cmdNoCase := DocsEditCmd{MatchCase: false}
|
|
if cmdNoCase.MatchCase {
|
|
t.Error("MatchCase should be false")
|
|
}
|
|
}
|
|
|
|
func TestDocsSedCmd_FlagsVariations(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantGlobal bool
|
|
}{
|
|
{"no flags", "s/a/b/", false},
|
|
{"g flag", "s/a/b/g", true},
|
|
{"gi flags", "s/a/b/gi", true}, // i is ignored but g should work
|
|
{"ig flags", "s/a/b/ig", true},
|
|
{"empty flags", "s/a/b//", false}, // extra delimiter
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, _, global, err := parseSedExpr(tt.expr)
|
|
if err != nil {
|
|
// some malformed exprs may error, that's ok
|
|
return
|
|
}
|
|
if global != tt.wantGlobal {
|
|
t.Errorf("global = %v, want %v", global, tt.wantGlobal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test building batchUpdate requests
|
|
func TestBuildReplaceAllTextRequest(t *testing.T) {
|
|
find := "old text"
|
|
replace := "new text"
|
|
matchCase := true
|
|
|
|
// Simulate what DocsEditCmd builds
|
|
req := &docs.Request{
|
|
ReplaceAllText: &docs.ReplaceAllTextRequest{
|
|
ContainsText: &docs.SubstringMatchCriteria{
|
|
Text: find,
|
|
MatchCase: matchCase,
|
|
},
|
|
ReplaceText: replace,
|
|
},
|
|
}
|
|
|
|
if req.ReplaceAllText == nil {
|
|
t.Fatal("ReplaceAllText should not be nil")
|
|
}
|
|
if req.ReplaceAllText.ContainsText.Text != find {
|
|
t.Errorf("Text = %q, want %q", req.ReplaceAllText.ContainsText.Text, find)
|
|
}
|
|
if req.ReplaceAllText.ReplaceText != replace {
|
|
t.Errorf("ReplaceText = %q, want %q", req.ReplaceAllText.ReplaceText, replace)
|
|
}
|
|
if req.ReplaceAllText.ContainsText.MatchCase != matchCase {
|
|
t.Errorf("MatchCase = %v, want %v", req.ReplaceAllText.ContainsText.MatchCase, matchCase)
|
|
}
|
|
}
|
|
|
|
// Test that markdown formats map to correct Google Docs API structures
|
|
func TestMarkdownToDocsAPIMapping(t *testing.T) {
|
|
tests := []struct {
|
|
format string
|
|
wantBold bool
|
|
wantItal bool
|
|
wantStrk bool
|
|
}{
|
|
{"bold", true, false, false},
|
|
{"italic", false, true, false},
|
|
{"strikethrough", false, false, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.format, func(t *testing.T) {
|
|
// Simulate format detection
|
|
_, formats := parseMarkdownReplacement("**test**")
|
|
hasBold := false
|
|
for _, f := range formats {
|
|
if f == "bold" {
|
|
hasBold = true
|
|
}
|
|
}
|
|
if tt.format == "bold" && !hasBold {
|
|
t.Error("expected bold format")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Benchmark tests
|
|
// --- Tests for escape sequences ---
|
|
|
|
func TestParseMarkdownReplacement_Escapes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantText string
|
|
wantFormats []string
|
|
}{
|
|
{
|
|
name: "escaped asterisks - literal bold syntax",
|
|
input: `\*\*this\*\*`,
|
|
wantText: "**this**",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped single asterisks - literal italic syntax",
|
|
input: `\*italic\*`,
|
|
wantText: "*italic*",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped hash - literal heading syntax",
|
|
input: `\# Not a heading`,
|
|
wantText: "# Not a heading",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped multiple hashes",
|
|
input: `\#\#\# literal hashes`,
|
|
wantText: "### literal hashes",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped tildes - literal strikethrough syntax",
|
|
input: `\~\~not struck\~\~`,
|
|
wantText: "~~not struck~~",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped backticks - literal code syntax",
|
|
input: "\\`not code\\`",
|
|
wantText: "`not code`",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped dash - literal bullet syntax",
|
|
input: `\- not a bullet`,
|
|
wantText: "- not a bullet",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped plus - literal checkbox syntax",
|
|
input: `\+ not a checkbox`,
|
|
wantText: "+ not a checkbox",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped backslash",
|
|
input: `path\\to\\file`,
|
|
wantText: `path\to\file`,
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped backslash before asterisk",
|
|
input: `\\*still italic*`,
|
|
wantText: `\*still italic*`, // \\ becomes \, asterisks preserved (no matching pair)
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped backslash then asterisks",
|
|
input: `\\*italic*`,
|
|
wantText: `\*italic*`, // \\ becomes \, asterisks not matched (doesn't start with *)
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "mixed escaped and real formatting",
|
|
input: `\*\*literal\*\* and **bold**`,
|
|
wantText: "**literal** and **bold**", // escapes break the pattern match
|
|
wantFormats: nil, // no format because pattern has placeholders
|
|
},
|
|
{
|
|
name: "real bold with escaped content inside",
|
|
input: `**has \* inside**`,
|
|
wantText: "has * inside",
|
|
wantFormats: []string{"bold"},
|
|
},
|
|
{
|
|
name: "escape at end",
|
|
input: `text\*`,
|
|
wantText: "text*",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "multiple escapes",
|
|
input: `\*\*\*triple\*\*\*`,
|
|
wantText: "***triple***",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped newline still works",
|
|
input: `line1\nline2`,
|
|
wantText: "line1\nline2",
|
|
wantFormats: nil,
|
|
},
|
|
{
|
|
name: "escaped chars with real formatting",
|
|
input: `**bold with \* asterisk**`,
|
|
wantText: "bold with * asterisk",
|
|
wantFormats: []string{"bold"},
|
|
},
|
|
{
|
|
name: "heading with escaped hash inside",
|
|
input: `# Title with \# hash`,
|
|
wantText: "Title with # hash",
|
|
wantFormats: []string{"heading1"},
|
|
},
|
|
{
|
|
name: "bullet with escaped dash",
|
|
input: `- item with \- dash`,
|
|
wantText: "item with - dash",
|
|
wantFormats: []string{"bullet"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
text, formats := parseMarkdownReplacement(tt.input)
|
|
if text != tt.wantText {
|
|
t.Errorf("text = %q, want %q", text, tt.wantText)
|
|
}
|
|
if len(formats) != len(tt.wantFormats) {
|
|
t.Errorf("formats = %v, want %v", formats, tt.wantFormats)
|
|
return
|
|
}
|
|
for i, f := range formats {
|
|
if f != tt.wantFormats[i] {
|
|
t.Errorf("formats[%d] = %q, want %q", i, f, tt.wantFormats[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for expression collection ---
|
|
|
|
func TestCollectExpressions_Positional(t *testing.T) {
|
|
cmd := &DocsSedCmd{Expression: "s/foo/bar/"}
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 1 || exprs[0] != "s/foo/bar/" {
|
|
t.Errorf("got %v, want [s/foo/bar/]", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_MultipleE(t *testing.T) {
|
|
cmd := &DocsSedCmd{
|
|
Expressions: []string{"s/foo/bar/", "s/baz/qux/g"},
|
|
}
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 2 {
|
|
t.Fatalf("got %d exprs, want 2", len(exprs))
|
|
}
|
|
if exprs[0] != "s/foo/bar/" || exprs[1] != "s/baz/qux/g" {
|
|
t.Errorf("got %v", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_PositionalPlusE(t *testing.T) {
|
|
cmd := &DocsSedCmd{
|
|
Expression: "s/first/one/",
|
|
Expressions: []string{"s/second/two/"},
|
|
}
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 2 || exprs[0] != "s/first/one/" || exprs[1] != "s/second/two/" {
|
|
t.Errorf("got %v", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_File(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "edits.sed")
|
|
content := "# Comment line\ns/foo/bar/\n\ns/baz/**qux**/g\n# Another comment\n"
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := &DocsSedCmd{File: path}
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 2 {
|
|
t.Fatalf("got %d exprs, want 2: %v", len(exprs), exprs)
|
|
}
|
|
if exprs[0] != "s/foo/bar/" || exprs[1] != "s/baz/**qux**/g" {
|
|
t.Errorf("got %v", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_FilePlusE(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "edits.sed")
|
|
if err := os.WriteFile(path, []byte("s/from-file/yes/\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := &DocsSedCmd{
|
|
Expressions: []string{"s/from-flag/yes/"},
|
|
File: path,
|
|
}
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 2 {
|
|
t.Fatalf("got %d exprs, want 2", len(exprs))
|
|
}
|
|
if exprs[0] != "s/from-flag/yes/" || exprs[1] != "s/from-file/yes/" {
|
|
t.Errorf("got %v", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_Stdin(t *testing.T) {
|
|
// Create a pipe to simulate stdin
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write expressions to pipe
|
|
go func() {
|
|
w.WriteString("# comment\ns/from-stdin/yes/\ns/also-stdin/**bold**/g\n")
|
|
w.Close()
|
|
}()
|
|
|
|
// Replace os.Stdin temporarily
|
|
oldStdin := os.Stdin
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin }()
|
|
|
|
cmd := &DocsSedCmd{} // no positional, no -e, no -f
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 2 {
|
|
t.Fatalf("got %d exprs, want 2: %v", len(exprs), exprs)
|
|
}
|
|
if exprs[0] != "s/from-stdin/yes/" || exprs[1] != "s/also-stdin/**bold**/g" {
|
|
t.Errorf("got %v", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_StdinIgnoredWhenEProvided(t *testing.T) {
|
|
// Stdin should NOT be read if -e flags are provided
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
w.WriteString("s/should-not-appear/nope/\n")
|
|
w.Close()
|
|
}()
|
|
|
|
oldStdin := os.Stdin
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin }()
|
|
|
|
cmd := &DocsSedCmd{Expressions: []string{"s/from-flag/yes/"}}
|
|
exprs, err := cmd.collectExpressions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exprs) != 1 || exprs[0] != "s/from-flag/yes/" {
|
|
t.Errorf("got %v, want [s/from-flag/yes/]", exprs)
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_NoInput(t *testing.T) {
|
|
cmd := &DocsSedCmd{}
|
|
_, err := cmd.collectExpressions()
|
|
if err == nil {
|
|
t.Error("expected error for no expressions")
|
|
}
|
|
}
|
|
|
|
func TestCollectExpressions_FileMissing(t *testing.T) {
|
|
cmd := &DocsSedCmd{File: "/nonexistent/file.sed"}
|
|
_, err := cmd.collectExpressions()
|
|
if err == nil {
|
|
t.Error("expected error for missing file")
|
|
}
|
|
}
|
|
|
|
// --- Tests for image syntax parsing ---
|
|
|
|
func TestParseImageSyntax(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantNil bool
|
|
wantURL string
|
|
wantAlt string
|
|
wantCaption string
|
|
wantWidth int
|
|
wantHeight int
|
|
}{
|
|
// Valid image syntax
|
|
{
|
|
name: "basic image",
|
|
input: "",
|
|
wantURL: "https://example.com/image.png",
|
|
},
|
|
{
|
|
name: "with alt text",
|
|
input: "",
|
|
wantURL: "https://example.com/logo.png",
|
|
wantAlt: "Company Logo",
|
|
},
|
|
{
|
|
name: "with title as caption",
|
|
input: ``,
|
|
wantURL: "https://example.com/logo.png",
|
|
wantAlt: "Logo",
|
|
wantCaption: "Our Company Logo",
|
|
},
|
|
{
|
|
name: "with width",
|
|
input: "{width=300}",
|
|
wantURL: "https://example.com/img.png",
|
|
wantWidth: 300,
|
|
},
|
|
{
|
|
name: "with height",
|
|
input: "{height=200}",
|
|
wantURL: "https://example.com/img.png",
|
|
wantHeight: 200,
|
|
},
|
|
{
|
|
name: "with both dimensions",
|
|
input: "{width=300 height=200}",
|
|
wantURL: "https://example.com/img.png",
|
|
wantWidth: 300,
|
|
wantHeight: 200,
|
|
},
|
|
{
|
|
name: "short dimension syntax",
|
|
input: "{w=300 h=200}",
|
|
wantURL: "https://example.com/img.png",
|
|
wantWidth: 300,
|
|
wantHeight: 200,
|
|
},
|
|
{
|
|
name: "with px suffix",
|
|
input: "{width=300px}",
|
|
wantURL: "https://example.com/img.png",
|
|
wantWidth: 300,
|
|
},
|
|
{
|
|
name: "full syntax",
|
|
input: `{width=400 height=300}`,
|
|
wantURL: "https://example.com/logo.png",
|
|
wantAlt: "Logo",
|
|
wantCaption: "Figure 1",
|
|
wantWidth: 400,
|
|
wantHeight: 300,
|
|
},
|
|
{
|
|
name: "url with query params",
|
|
input: "",
|
|
wantURL: "https://example.com/img.png?size=large&format=webp",
|
|
},
|
|
{
|
|
name: "alt with special chars",
|
|
input: "",
|
|
wantURL: "https://example.com/img.png",
|
|
wantAlt: "A cool image!",
|
|
},
|
|
|
|
// Not image syntax
|
|
{
|
|
name: "plain text",
|
|
input: "hello world",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "bold text",
|
|
input: "**bold**",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "starts with ! but not image",
|
|
input: "!important",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "no closing bracket",
|
|
input: "![alt text(url)",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
wantNil: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseImageSyntax(tt.input)
|
|
if tt.wantNil {
|
|
if got != nil {
|
|
t.Errorf("parseImageSyntax(%q) = %+v, want nil", tt.input, got)
|
|
}
|
|
return
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("parseImageSyntax(%q) = nil, want non-nil", tt.input)
|
|
return
|
|
}
|
|
if got.URL != tt.wantURL {
|
|
t.Errorf("URL = %q, want %q", got.URL, tt.wantURL)
|
|
}
|
|
if got.Alt != tt.wantAlt {
|
|
t.Errorf("Alt = %q, want %q", got.Alt, tt.wantAlt)
|
|
}
|
|
if got.Caption != tt.wantCaption {
|
|
t.Errorf("Caption = %q, want %q", got.Caption, tt.wantCaption)
|
|
}
|
|
if got.Width != tt.wantWidth {
|
|
t.Errorf("Width = %d, want %d", got.Width, tt.wantWidth)
|
|
}
|
|
if got.Height != tt.wantHeight {
|
|
t.Errorf("Height = %d, want %d", got.Height, tt.wantHeight)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseImageRefPattern(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantNil bool
|
|
wantPosition bool
|
|
wantPos int
|
|
wantAll bool
|
|
wantByAlt bool
|
|
wantRegex string
|
|
}{
|
|
// Positional patterns !(n)
|
|
{
|
|
name: "first image",
|
|
input: "!(1)",
|
|
wantPosition: true,
|
|
wantPos: 1,
|
|
},
|
|
{
|
|
name: "second image",
|
|
input: "!(2)",
|
|
wantPosition: true,
|
|
wantPos: 2,
|
|
},
|
|
{
|
|
name: "last image",
|
|
input: "!(-1)",
|
|
wantPosition: true,
|
|
wantPos: -1,
|
|
},
|
|
{
|
|
name: "second to last",
|
|
input: "!(-2)",
|
|
wantPosition: true,
|
|
wantPos: -2,
|
|
},
|
|
{
|
|
name: "all images",
|
|
input: "!(*)",
|
|
wantPosition: true,
|
|
wantAll: true,
|
|
},
|
|
|
|
// Positional with empty alt 
|
|
{
|
|
name: "first image alt syntax",
|
|
input: "",
|
|
wantPosition: true,
|
|
wantPos: 1,
|
|
},
|
|
{
|
|
name: "all images alt syntax",
|
|
input: "",
|
|
wantPosition: true,
|
|
wantAll: true,
|
|
},
|
|
|
|
// Alt text regex patterns ![regex]
|
|
{
|
|
name: "exact alt match",
|
|
input: "![logo]",
|
|
wantByAlt: true,
|
|
wantRegex: "logo",
|
|
},
|
|
{
|
|
name: "alt starts with",
|
|
input: "![fig-.*]",
|
|
wantByAlt: true,
|
|
wantRegex: "fig-.*",
|
|
},
|
|
{
|
|
name: "alt contains",
|
|
input: "![.*draft.*]",
|
|
wantByAlt: true,
|
|
wantRegex: ".*draft.*",
|
|
},
|
|
{
|
|
name: "alt with digits",
|
|
input: `![img-\d+]`,
|
|
wantByAlt: true,
|
|
wantRegex: `img-\d+`,
|
|
},
|
|
{
|
|
name: "case insensitive",
|
|
input: "![(?i)logo]",
|
|
wantByAlt: true,
|
|
wantRegex: "(?i)logo",
|
|
},
|
|
|
|
// Not image reference patterns
|
|
{
|
|
name: "actual image insert",
|
|
input: "!(https://example.com/img.png)",
|
|
wantNil: true, // URL, not reference
|
|
},
|
|
{
|
|
name: "full image syntax",
|
|
input: "",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "plain text",
|
|
input: "hello world",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty brackets",
|
|
input: "![]",
|
|
wantNil: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseImageRefPattern(tt.input)
|
|
if tt.wantNil {
|
|
if got != nil {
|
|
t.Errorf("parseImageRefPattern(%q) = %+v, want nil", tt.input, got)
|
|
}
|
|
return
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("parseImageRefPattern(%q) = nil, want non-nil", tt.input)
|
|
return
|
|
}
|
|
if got.ByPosition != tt.wantPosition {
|
|
t.Errorf("ByPosition = %v, want %v", got.ByPosition, tt.wantPosition)
|
|
}
|
|
if got.Position != tt.wantPos {
|
|
t.Errorf("Position = %d, want %d", got.Position, tt.wantPos)
|
|
}
|
|
if got.AllImages != tt.wantAll {
|
|
t.Errorf("AllImages = %v, want %v", got.AllImages, tt.wantAll)
|
|
}
|
|
if got.ByAlt != tt.wantByAlt {
|
|
t.Errorf("ByAlt = %v, want %v", got.ByAlt, tt.wantByAlt)
|
|
}
|
|
if tt.wantByAlt && got.AltRegex != nil {
|
|
if got.AltRegex.String() != tt.wantRegex {
|
|
t.Errorf("AltRegex = %q, want %q", got.AltRegex.String(), tt.wantRegex)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchImages(t *testing.T) {
|
|
images := []DocImage{
|
|
{ObjectID: "img1", Index: 10, Alt: "logo"},
|
|
{ObjectID: "img2", Index: 20, Alt: "fig-1"},
|
|
{ObjectID: "img3", Index: 30, Alt: "fig-2"},
|
|
{ObjectID: "img4", Index: 40, Alt: "header-draft"},
|
|
{ObjectID: "img5", Index: 50, Alt: "footer"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
wantIDs []string
|
|
}{
|
|
{"first image", "!(1)", []string{"img1"}},
|
|
{"second image", "!(2)", []string{"img2"}},
|
|
{"last image", "!(-1)", []string{"img5"}},
|
|
{"second to last", "!(-2)", []string{"img4"}},
|
|
{"all images", "!(*)", []string{"img1", "img2", "img3", "img4", "img5"}},
|
|
{"exact alt", "![logo]", []string{"img1"}},
|
|
{"alt starts with fig", "![fig-.*]", []string{"img2", "img3"}},
|
|
{"alt contains draft", "![.*draft.*]", []string{"img4"}},
|
|
{"no match", "![nonexistent]", nil},
|
|
{"out of range positive", "!(10)", nil},
|
|
{"out of range negative", "!(-10)", nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ref := parseImageRefPattern(tt.pattern)
|
|
if ref == nil {
|
|
t.Fatalf("parseImageRefPattern(%q) = nil", tt.pattern)
|
|
}
|
|
matched := matchImages(images, ref)
|
|
gotIDs := make([]string, 0, len(matched))
|
|
for _, img := range matched {
|
|
gotIDs = append(gotIDs, img.ObjectID)
|
|
}
|
|
if len(gotIDs) != len(tt.wantIDs) {
|
|
t.Errorf("matched %v, want %v", gotIDs, tt.wantIDs)
|
|
return
|
|
}
|
|
for i, id := range gotIDs {
|
|
if id != tt.wantIDs[i] {
|
|
t.Errorf("matched[%d] = %q, want %q", i, id, tt.wantIDs[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanUseNativeReplace_Image(t *testing.T) {
|
|
// Images should NOT use native replace
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
{"", false},
|
|
{"", false},
|
|
{"{width=100}", false},
|
|
{"!(https://example.com/img.png)", false}, // shorthand
|
|
{"plain text", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := canUseNativeReplace(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("canUseNativeReplace(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImageShorthandSyntax(t *testing.T) {
|
|
// Test !(url) shorthand is recognized as image for insertion
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantURL string
|
|
wantNil bool
|
|
}{
|
|
{
|
|
name: "shorthand http",
|
|
input: "!(http://example.com/img.png)",
|
|
wantURL: "http://example.com/img.png",
|
|
},
|
|
{
|
|
name: "shorthand https",
|
|
input: "!(https://example.com/img.png)",
|
|
wantURL: "https://example.com/img.png",
|
|
},
|
|
{
|
|
name: "shorthand with query",
|
|
input: "!(https://example.com/img.png?w=100)",
|
|
wantURL: "https://example.com/img.png?w=100",
|
|
},
|
|
{
|
|
name: "positional ref not url",
|
|
input: "!(1)",
|
|
wantNil: true, // This is a reference, not an image insert
|
|
},
|
|
{
|
|
name: "all images ref",
|
|
input: "!(*)",
|
|
wantNil: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// First check if it's an image reference (position)
|
|
ref := parseImageRefPattern(tt.input)
|
|
if ref != nil && !tt.wantNil {
|
|
t.Errorf("parseImageRefPattern(%q) matched as reference, expected URL", tt.input)
|
|
return
|
|
}
|
|
|
|
// Then check if it's parsed as image syntax
|
|
img := parseImageSyntax(tt.input)
|
|
if tt.wantNil {
|
|
// For positional refs, parseImageSyntax should also return nil
|
|
// because they don't start with ![
|
|
if img != nil {
|
|
t.Errorf("parseImageSyntax(%q) = %+v, want nil", tt.input, img)
|
|
}
|
|
return
|
|
}
|
|
// For !(url) shorthand, parseImageSyntax won't match (needs ![)
|
|
// But the sed command handles this specially
|
|
// Just verify this is NOT a reference
|
|
if ref != nil {
|
|
t.Errorf("!(url) should not be parsed as reference")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageRefPatternEdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantNil bool
|
|
desc string
|
|
}{
|
|
{"zero position", "!(0)", false, "position 0 parses but won't match anything"},
|
|
{"float position", "!(1.5)", true, "floats not supported"},
|
|
{"negative zero", "!(-0)", false, "parses as 0, won't match anything"},
|
|
{"large positive", "!(999)", false, "valid, will just not match"},
|
|
{"large negative", "!(-999)", false, "valid, will just not match"},
|
|
{"empty parens", "!()", true, "empty is invalid"},
|
|
{"space in parens", "!( 1 )", true, "spaces not trimmed"},
|
|
{"alt with spaces", "![my logo]", false, "spaces in alt ok"},
|
|
{"complex regex", `![^fig-\d{2,4}$]`, false, "complex regex ok"},
|
|
{"invalid regex", "![[invalid]", true, "unclosed bracket"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseImageRefPattern(tt.input)
|
|
if tt.wantNil && got != nil {
|
|
t.Errorf("parseImageRefPattern(%q) = %+v, want nil (%s)", tt.input, got, tt.desc)
|
|
}
|
|
if !tt.wantNil && got == nil {
|
|
t.Errorf("parseImageRefPattern(%q) = nil, want non-nil (%s)", tt.input, tt.desc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchImagesEdgeCases(t *testing.T) {
|
|
// Empty image list
|
|
t.Run("empty list", func(t *testing.T) {
|
|
ref := parseImageRefPattern("!(1)")
|
|
matched := matchImages(nil, ref)
|
|
if len(matched) != 0 {
|
|
t.Errorf("expected no matches for empty list")
|
|
}
|
|
})
|
|
|
|
// Single image
|
|
t.Run("single image", func(t *testing.T) {
|
|
images := []DocImage{{ObjectID: "only", Alt: "solo"}}
|
|
|
|
ref1 := parseImageRefPattern("!(1)")
|
|
if m := matchImages(images, ref1); len(m) != 1 || m[0].ObjectID != "only" {
|
|
t.Errorf("!(1) should match single image")
|
|
}
|
|
|
|
ref2 := parseImageRefPattern("!(-1)")
|
|
if m := matchImages(images, ref2); len(m) != 1 || m[0].ObjectID != "only" {
|
|
t.Errorf("!(-1) should match single image")
|
|
}
|
|
|
|
ref3 := parseImageRefPattern("!(2)")
|
|
if m := matchImages(images, ref3); len(m) != 0 {
|
|
t.Errorf("!(2) should not match single image")
|
|
}
|
|
})
|
|
|
|
// Alt text edge cases
|
|
t.Run("empty alt", func(t *testing.T) {
|
|
images := []DocImage{
|
|
{ObjectID: "img1", Alt: ""},
|
|
{ObjectID: "img2", Alt: "has-alt"},
|
|
}
|
|
|
|
// Match empty alt
|
|
ref := parseImageRefPattern("![^$]")
|
|
matched := matchImages(images, ref)
|
|
if len(matched) != 1 || matched[0].ObjectID != "img1" {
|
|
t.Errorf("![^$] should match empty alt")
|
|
}
|
|
})
|
|
|
|
// Special chars in alt
|
|
t.Run("special chars in alt", func(t *testing.T) {
|
|
images := []DocImage{
|
|
{ObjectID: "img1", Alt: "image (1)"},
|
|
{ObjectID: "img2", Alt: "image [2]"},
|
|
}
|
|
|
|
// Need to escape special regex chars
|
|
ref := parseImageRefPattern(`![image \(1\)]`)
|
|
matched := matchImages(images, ref)
|
|
if len(matched) != 1 || matched[0].ObjectID != "img1" {
|
|
t.Errorf("escaped parens should match")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Tests for native replace detection ---
|
|
|
|
func TestCanUseNativeReplace(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
replacement string
|
|
want bool
|
|
}{
|
|
// Should use native
|
|
{"plain text", "hello world", true},
|
|
{"with numbers", "item123", true},
|
|
{"with special chars", "foo@bar.com", true},
|
|
{"empty", "", true},
|
|
{"single word", "replaced", true},
|
|
{"path", "/usr/local/bin", true},
|
|
{"url", "https://example.com", true},
|
|
|
|
// Should NOT use native (has formatting)
|
|
{"bold", "**bold**", false},
|
|
{"italic", "*italic*", false},
|
|
{"bold partial", "some **bold** text", false},
|
|
{"strikethrough", "~~struck~~", false},
|
|
{"code", "`code`", false},
|
|
{"heading 1", "# Heading", false},
|
|
{"heading 2", "## Heading", false},
|
|
{"heading 3", "### Heading", false},
|
|
{"bullet dash", "- item", false},
|
|
{"bullet plus", "+ item", false},
|
|
{"numbered list", "1. first", false},
|
|
{"newline escape", "line1\\nline2", false},
|
|
|
|
// Edge cases
|
|
{"asterisk not format", "5 * 3 = 15", false}, // has * so detected as potential format
|
|
{"hash in middle", "item #123", true}, // # not at start with space
|
|
{"dash in middle", "foo-bar", true}, // - not at start with space
|
|
{"number without dot space", "123", true}, // not "N. " pattern
|
|
{"heading no space", "#hashtag", true}, // no space after #
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := canUseNativeReplace(tt.replacement)
|
|
if got != tt.want {
|
|
t.Errorf("canUseNativeReplace(%q) = %v, want %v", tt.replacement, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Benchmark tests
|
|
|
|
func BenchmarkParseSedExpr(b *testing.B) {
|
|
expr := `s/(\w+)@(\w+)\.(\w+)/\1[at]\2[dot]\3/g`
|
|
for i := 0; i < b.N; i++ {
|
|
_, _, _, _ = parseSedExpr(expr)
|
|
}
|
|
}
|
|
|
|
func BenchmarkParseMarkdownReplacement(b *testing.B) {
|
|
inputs := []string{
|
|
"plain text",
|
|
"**bold text**",
|
|
"- bullet with **bold**",
|
|
"### Heading Three",
|
|
}
|
|
for i := 0; i < b.N; i++ {
|
|
for _, input := range inputs {
|
|
parseMarkdownReplacement(input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkRegexReplace(b *testing.B) {
|
|
re := regexp.MustCompile(`\b(\w+)\b`)
|
|
input := "The quick brown fox jumps over the lazy dog"
|
|
replacement := `"$1"`
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
re.ReplaceAllString(input, replacement)
|
|
}
|
|
}
|
|
|
|
// --- Unit tests for parseTableCellRef ---
|
|
|
|
func TestParseTableCellRef(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantNil bool
|
|
wantTable int
|
|
wantRow int
|
|
wantCol int
|
|
wantSubPat string
|
|
}{
|
|
{"numeric basic", "|1|[2,3]", false, 1, 2, 3, ""},
|
|
{"excel style", "|1|[A1]", false, 1, 1, 1, ""},
|
|
{"negative table", "|-1|[1,1]", false, -1, 1, 1, ""},
|
|
{"with subpattern", "|1|[2,4]:old", false, 1, 2, 4, "old"},
|
|
{"excel B2", "|2|[B2]", false, 2, 2, 2, ""},
|
|
{"not a ref", "hello", true, 0, 0, 0, ""},
|
|
{"no bracket", "|1|foo", true, 0, 0, 0, ""},
|
|
{"single pipe", "|1[2,3]", true, 0, 0, 0, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ref := parseTableCellRef(tt.input)
|
|
if tt.wantNil {
|
|
if ref != nil {
|
|
t.Errorf("expected nil, got %+v", ref)
|
|
}
|
|
return
|
|
}
|
|
if ref == nil {
|
|
t.Fatal("expected non-nil ref")
|
|
return
|
|
}
|
|
if ref.tableIndex != tt.wantTable {
|
|
t.Errorf("tableIndex = %d, want %d", ref.tableIndex, tt.wantTable)
|
|
}
|
|
if ref.row != tt.wantRow {
|
|
t.Errorf("row = %d, want %d", ref.row, tt.wantRow)
|
|
}
|
|
if ref.col != tt.wantCol {
|
|
t.Errorf("col = %d, want %d", ref.col, tt.wantCol)
|
|
}
|
|
if ref.subPattern != tt.wantSubPat {
|
|
t.Errorf("subPattern = %q, want %q", ref.subPattern, tt.wantSubPat)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTableCellRefExcel(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
wantRow int
|
|
wantCol int
|
|
}{
|
|
{"A1", 1, 1},
|
|
{"B2", 2, 2},
|
|
{"C10", 10, 3},
|
|
{"Z1", 1, 26},
|
|
{"AA1", 1, 27},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
row, col, ok := parseExcelRef(tt.input)
|
|
if !ok {
|
|
t.Fatal("parseExcelRef returned false")
|
|
}
|
|
if row != tt.wantRow {
|
|
t.Errorf("row = %d, want %d", row, tt.wantRow)
|
|
}
|
|
if col != tt.wantCol {
|
|
t.Errorf("col = %d, want %d", col, tt.wantCol)
|
|
}
|
|
})
|
|
}
|
|
}
|