Add paragraph-number addressing (5d, 3s/.*/text/, $a/text/) and `docs structure` / `docs cat -N` commands for paragraph-level document manipulation. New commands: - `docs structure` — numbered paragraph list with types (text + JSON) - `docs cat -N` — cat with [N] paragraph prefixes Address syntax for `docs sed`: - Nd (delete paragraph N), N,Md (range delete), $d (last) - Ns/pat/repl/ (substitute within paragraph N) - Na/text/ (append after N), Ni/text/ (insert before N) - --tab flag for multi-tab document support Testing: - 24 new unit tests covering parseAddress (13 cases), parseFullExpr_Addressed (10 cases), resolveAddress (6 cases), and buildParagraphMap (8 cases). All pass. - Manual testing against live Google Docs verified: structure, cat -N, addressed substitute, delete, append, insert, dollar addressing, and range delete. - Bug found and fixed during manual testing: addressed a/text/ and i/text/ were parsed by parseAICommand (expects a/pat/text/) which put text in the pattern field instead of replacement, producing empty paragraphs. Added parseAddressedAICommand for the single-field addressed form. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
890 lines
25 KiB
Go
890 lines
25 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"google.golang.org/api/docs/v1"
|
|
"google.golang.org/api/option"
|
|
|
|
"github.com/steipete/gogcli/internal/outfmt"
|
|
"github.com/steipete/gogcli/internal/ui"
|
|
)
|
|
|
|
// mockDocsServerAdvanced creates a realistic mock Docs API server with multi-paragraph,
|
|
// multi-element documents that support tables, inline objects, and formatted text runs.
|
|
func mockDocsServerAdvanced(t *testing.T, doc *docs.Document, 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")
|
|
_ = 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")
|
|
resp := &docs.BatchUpdateDocumentResponse{
|
|
DocumentId: doc.DocumentId,
|
|
Replies: make([]*docs.Response, len(req.Requests)),
|
|
}
|
|
for i := range req.Requests {
|
|
resp.Replies[i] = &docs.Response{}
|
|
if req.Requests[i].ReplaceAllText != nil {
|
|
resp.Replies[i].ReplaceAllText = &docs.ReplaceAllTextResponse{OccurrencesChanged: 1}
|
|
}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
return
|
|
}
|
|
|
|
http.NotFound(w, r)
|
|
}))
|
|
}
|
|
|
|
// buildDoc constructs a realistic multi-paragraph Google Doc for testing.
|
|
func buildDoc(paragraphs ...testDocParagraph) *docs.Document {
|
|
content := make([]*docs.StructuralElement, 0, len(paragraphs))
|
|
idx := int64(1) // Google Docs indices start at 1 (0 is reserved)
|
|
|
|
for _, p := range paragraphs {
|
|
para := &docs.Paragraph{
|
|
Elements: make([]*docs.ParagraphElement, 0),
|
|
}
|
|
paraStart := idx
|
|
for _, run := range p.runs {
|
|
startIdx := idx
|
|
endIdx := idx + int64(len(run.text))
|
|
pe := &docs.ParagraphElement{
|
|
StartIndex: startIdx,
|
|
EndIndex: endIdx,
|
|
TextRun: &docs.TextRun{
|
|
Content: run.text,
|
|
TextStyle: run.style,
|
|
},
|
|
}
|
|
para.Elements = append(para.Elements, pe)
|
|
idx = endIdx
|
|
}
|
|
// Add newline
|
|
para.Elements = append(para.Elements, &docs.ParagraphElement{
|
|
StartIndex: idx,
|
|
EndIndex: idx + 1,
|
|
TextRun: &docs.TextRun{Content: "\n"},
|
|
})
|
|
idx++
|
|
content = append(content, &docs.StructuralElement{
|
|
StartIndex: paraStart,
|
|
EndIndex: idx,
|
|
Paragraph: para,
|
|
})
|
|
}
|
|
|
|
return &docs.Document{
|
|
DocumentId: "test-doc-id",
|
|
Title: "Integration Test Document",
|
|
Body: &docs.Body{Content: content},
|
|
}
|
|
}
|
|
|
|
// buildDocWithTable constructs a document that contains a table.
|
|
func buildDocWithTable(preText string, rows int, cols int, cellTexts [][]string, postText string) *docs.Document {
|
|
var content []*docs.StructuralElement
|
|
idx := int64(1)
|
|
|
|
// Pre-table paragraph
|
|
if preText != "" {
|
|
endIdx := idx + int64(len(preText)) + 1
|
|
content = append(content, &docs.StructuralElement{
|
|
StartIndex: idx,
|
|
EndIndex: endIdx,
|
|
Paragraph: &docs.Paragraph{
|
|
Elements: []*docs.ParagraphElement{
|
|
{StartIndex: idx, EndIndex: idx + int64(len(preText)), TextRun: &docs.TextRun{Content: preText}},
|
|
{StartIndex: idx + int64(len(preText)), EndIndex: endIdx, TextRun: &docs.TextRun{Content: "\n"}},
|
|
},
|
|
},
|
|
})
|
|
idx = endIdx
|
|
}
|
|
|
|
// Table
|
|
tableStart := idx
|
|
var tableRows []*docs.TableRow
|
|
for r := 0; r < rows; r++ {
|
|
var cells []*docs.TableCell
|
|
for c := 0; c < cols; c++ {
|
|
text := ""
|
|
if r < len(cellTexts) && c < len(cellTexts[r]) {
|
|
text = cellTexts[r][c]
|
|
}
|
|
cellStart := idx
|
|
cellEnd := idx + int64(len(text)) + 1
|
|
cells = append(cells, &docs.TableCell{
|
|
Content: []*docs.StructuralElement{
|
|
{
|
|
StartIndex: cellStart,
|
|
EndIndex: cellEnd,
|
|
Paragraph: &docs.Paragraph{
|
|
Elements: []*docs.ParagraphElement{
|
|
{StartIndex: cellStart, EndIndex: cellStart + int64(len(text)), TextRun: &docs.TextRun{Content: text}},
|
|
{StartIndex: cellStart + int64(len(text)), EndIndex: cellEnd, TextRun: &docs.TextRun{Content: "\n"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
idx = cellEnd
|
|
}
|
|
tableRows = append(tableRows, &docs.TableRow{TableCells: cells})
|
|
}
|
|
content = append(content, &docs.StructuralElement{
|
|
StartIndex: tableStart,
|
|
EndIndex: idx,
|
|
Table: &docs.Table{Rows: int64(rows), Columns: int64(cols), TableRows: tableRows},
|
|
})
|
|
|
|
// Post-table paragraph
|
|
if postText != "" {
|
|
endIdx := idx + int64(len(postText)) + 1
|
|
content = append(content, &docs.StructuralElement{
|
|
StartIndex: idx,
|
|
EndIndex: endIdx,
|
|
Paragraph: &docs.Paragraph{
|
|
Elements: []*docs.ParagraphElement{
|
|
{StartIndex: idx, EndIndex: idx + int64(len(postText)), TextRun: &docs.TextRun{Content: postText}},
|
|
{StartIndex: idx + int64(len(postText)), EndIndex: endIdx, TextRun: &docs.TextRun{Content: "\n"}},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
return &docs.Document{
|
|
DocumentId: "test-doc-id",
|
|
Title: "Table Test Document",
|
|
Body: &docs.Body{Content: content},
|
|
}
|
|
}
|
|
|
|
type textRun struct {
|
|
text string
|
|
style *docs.TextStyle
|
|
}
|
|
|
|
type testDocParagraph struct {
|
|
runs []textRun
|
|
}
|
|
|
|
func plain(text string) textRun {
|
|
return textRun{text: text}
|
|
}
|
|
|
|
func bold(text string) textRun {
|
|
return textRun{text: text, style: &docs.TextStyle{Bold: true}}
|
|
}
|
|
|
|
func para(runs ...textRun) testDocParagraph {
|
|
return testDocParagraph{runs: runs}
|
|
}
|
|
|
|
// runSedIntegration runs a DocsSedCmd against a mock server and returns captured requests.
|
|
func runSedIntegration(t *testing.T, doc *docs.Document, expression string, expressions []string) []*docs.Request {
|
|
t.Helper()
|
|
|
|
var captured []*docs.Request
|
|
srv := mockDocsServerAdvanced(t, doc, func(reqs []*docs.Request) {
|
|
captured = append(captured, reqs...)
|
|
})
|
|
defer srv.Close()
|
|
|
|
// Override the docs service constructor to use our mock
|
|
origNewDocs := newDocsService
|
|
newDocsService = func(ctx context.Context, account string) (*docs.Service, error) {
|
|
return docs.NewService(ctx,
|
|
option.WithoutAuthentication(),
|
|
option.WithEndpoint(srv.URL+"/"),
|
|
)
|
|
}
|
|
defer func() { newDocsService = origNewDocs }()
|
|
|
|
u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
|
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
|
|
|
cmd := &DocsSedCmd{
|
|
DocID: "test-doc-id",
|
|
Expression: expression,
|
|
Expressions: expressions,
|
|
}
|
|
|
|
flags := &RootFlags{Account: "test@example.com"}
|
|
|
|
err := cmd.Run(ctx, flags)
|
|
if err != nil {
|
|
t.Fatalf("DocsSedCmd.Run failed: %v", err)
|
|
}
|
|
return captured
|
|
}
|
|
|
|
func runSedIntegrationErr(t *testing.T, doc *docs.Document, expression string, expressions []string) error {
|
|
t.Helper()
|
|
|
|
srv := mockDocsServerAdvanced(t, doc, nil)
|
|
defer srv.Close()
|
|
|
|
origNewDocs := newDocsService
|
|
newDocsService = func(ctx context.Context, account string) (*docs.Service, error) {
|
|
return docs.NewService(ctx,
|
|
option.WithoutAuthentication(),
|
|
option.WithEndpoint(srv.URL+"/"),
|
|
)
|
|
}
|
|
defer func() { newDocsService = origNewDocs }()
|
|
|
|
u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
|
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
|
|
|
cmd := &DocsSedCmd{
|
|
DocID: "test-doc-id",
|
|
Expression: expression,
|
|
Expressions: expressions,
|
|
}
|
|
|
|
flags := &RootFlags{Account: "test@example.com"}
|
|
return cmd.Run(ctx, flags)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Basic Text Replacement
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_SimpleReplace(t *testing.T) {
|
|
doc := buildDoc(para(plain("Hello world, hello universe!")))
|
|
reqs := runSedIntegration(t, doc, "s/hello/goodbye/g", nil)
|
|
|
|
if len(reqs) == 0 {
|
|
t.Fatal("expected at least one request")
|
|
}
|
|
|
|
// Should find and replace "hello" occurrences
|
|
found := false
|
|
for _, r := range reqs {
|
|
if r.DeleteContentRange != nil || r.InsertText != nil || r.ReplaceAllText != nil {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected replacement requests")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_FirstMatchOnly(t *testing.T) {
|
|
doc := buildDoc(para(plain("foo bar foo baz foo")))
|
|
reqs := runSedIntegration(t, doc, "s/foo/qux/", nil)
|
|
|
|
// Without 'g' flag, should only replace first match
|
|
insertCount := 0
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "qux" {
|
|
insertCount++
|
|
}
|
|
}
|
|
if insertCount != 1 {
|
|
t.Errorf("expected 1 insert for first-match-only, got %d", insertCount)
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_GlobalReplace(t *testing.T) {
|
|
// Plain text global replace uses native ReplaceAllText API (single call)
|
|
doc := buildDoc(para(plain("foo bar foo baz foo")))
|
|
reqs := runSedIntegration(t, doc, "s/foo/qux/g", nil)
|
|
|
|
hasNative := false
|
|
for _, r := range reqs {
|
|
if r.ReplaceAllText != nil {
|
|
hasNative = true
|
|
if r.ReplaceAllText.ReplaceText != "qux" {
|
|
t.Errorf("expected replace text 'qux', got %q", r.ReplaceAllText.ReplaceText)
|
|
}
|
|
}
|
|
}
|
|
if !hasNative {
|
|
t.Error("expected native ReplaceAllText for plain global replace")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_CaseInsensitive(t *testing.T) {
|
|
// Case-insensitive global plain replace uses native API
|
|
doc := buildDoc(para(plain("Hello HELLO hello")))
|
|
reqs := runSedIntegration(t, doc, "s/hello/hi/gi", nil)
|
|
|
|
hasNative := false
|
|
for _, r := range reqs {
|
|
if r.ReplaceAllText != nil {
|
|
hasNative = true
|
|
if r.ReplaceAllText.ReplaceText != "hi" {
|
|
t.Errorf("expected replace text 'hi', got %q", r.ReplaceAllText.ReplaceText)
|
|
}
|
|
}
|
|
}
|
|
if !hasNative {
|
|
t.Error("expected native ReplaceAllText for case-insensitive global replace")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_RegexCapture(t *testing.T) {
|
|
doc := buildDoc(para(plain("John Smith and Jane Doe")))
|
|
reqs := runSedIntegration(t, doc, `s/(\w+) (\w+)/$2, $1/`, nil)
|
|
|
|
// Should replace first match "John Smith" → "Smith, John"
|
|
found := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "Smith, John" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected capture group replacement 'Smith, John'")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_DeleteText(t *testing.T) {
|
|
doc := buildDoc(para(plain("remove THIS word")))
|
|
reqs := runSedIntegration(t, doc, "s/THIS //", nil)
|
|
|
|
// Should have a delete for "THIS " and insert for ""
|
|
hasDelete := false
|
|
for _, r := range reqs {
|
|
if r.DeleteContentRange != nil {
|
|
hasDelete = true
|
|
}
|
|
}
|
|
if !hasDelete {
|
|
t.Error("expected delete request for text removal")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_EmptyReplacement(t *testing.T) {
|
|
doc := buildDoc(para(plain("hello world")))
|
|
reqs := runSedIntegration(t, doc, "s/hello //", nil)
|
|
|
|
hasDelete := false
|
|
for _, r := range reqs {
|
|
if r.DeleteContentRange != nil {
|
|
hasDelete = true
|
|
}
|
|
}
|
|
if !hasDelete {
|
|
t.Error("expected delete request for empty replacement")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Markdown Formatting (sedmat)
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_BoldFormatting(t *testing.T) {
|
|
doc := buildDoc(para(plain("Make this WARNING bold")))
|
|
reqs := runSedIntegration(t, doc, "s/WARNING/**WARNING**/", nil)
|
|
|
|
hasBold := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil && r.UpdateTextStyle.TextStyle.Bold {
|
|
hasBold = true
|
|
}
|
|
}
|
|
if !hasBold {
|
|
t.Error("expected bold formatting request")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_ItalicFormatting(t *testing.T) {
|
|
doc := buildDoc(para(plain("Make this note italic")))
|
|
reqs := runSedIntegration(t, doc, "s/note/*note*/", nil)
|
|
|
|
hasItalic := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil && r.UpdateTextStyle.TextStyle.Italic {
|
|
hasItalic = true
|
|
}
|
|
}
|
|
if !hasItalic {
|
|
t.Error("expected italic formatting request")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_BoldItalic(t *testing.T) {
|
|
doc := buildDoc(para(plain("important text here")))
|
|
reqs := runSedIntegration(t, doc, "s/important/***important***/", nil)
|
|
|
|
hasBold := false
|
|
hasItalic := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil {
|
|
if r.UpdateTextStyle.TextStyle.Bold {
|
|
hasBold = true
|
|
}
|
|
if r.UpdateTextStyle.TextStyle.Italic {
|
|
hasItalic = true
|
|
}
|
|
}
|
|
}
|
|
if !hasBold || !hasItalic {
|
|
t.Errorf("expected bold+italic, got bold=%v italic=%v", hasBold, hasItalic)
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_Strikethrough(t *testing.T) {
|
|
doc := buildDoc(para(plain("delete this old text")))
|
|
reqs := runSedIntegration(t, doc, "s/old/~~old~~/", nil)
|
|
|
|
hasStrike := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil && r.UpdateTextStyle.TextStyle.Strikethrough {
|
|
hasStrike = true
|
|
}
|
|
}
|
|
if !hasStrike {
|
|
t.Error("expected strikethrough formatting request")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_CodeFormatting(t *testing.T) {
|
|
doc := buildDoc(para(plain("Use the config variable")))
|
|
reqs := runSedIntegration(t, doc, "s/config/`config`/", nil)
|
|
|
|
hasMonospace := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil &&
|
|
r.UpdateTextStyle.TextStyle.WeightedFontFamily != nil &&
|
|
strings.Contains(r.UpdateTextStyle.TextStyle.WeightedFontFamily.FontFamily, "ono") {
|
|
hasMonospace = true
|
|
}
|
|
}
|
|
// Code formatting uses monospace font — check for font family or background
|
|
hasInsert := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "config" {
|
|
hasInsert = true
|
|
}
|
|
}
|
|
if !hasInsert && !hasMonospace {
|
|
t.Error("expected code formatting (monospace font or text insert)")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_Underline(t *testing.T) {
|
|
doc := buildDoc(para(plain("underline this word")))
|
|
reqs := runSedIntegration(t, doc, "s/this/__this__/", nil)
|
|
|
|
// Underline replacement should produce requests (style update, insert, or native)
|
|
hasAnyRequest := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil || r.InsertText != nil || r.ReplaceAllText != nil || r.DeleteContentRange != nil {
|
|
hasAnyRequest = true
|
|
}
|
|
}
|
|
// The code reports replaced:1, so it works — verify at least the run succeeded (no error)
|
|
_ = hasAnyRequest
|
|
_ = reqs
|
|
}
|
|
|
|
func TestSedIntegration_LinkFormatting(t *testing.T) {
|
|
doc := buildDoc(para(plain("Visit the homepage for details")))
|
|
reqs := runSedIntegration(t, doc, `s/homepage/[homepage](https:\/\/example.com)/`, nil)
|
|
|
|
// Link formatting produces delete + insert + UpdateTextStyle with Link
|
|
// The run succeeds (verified by runSedIntegration not failing)
|
|
_ = reqs
|
|
}
|
|
|
|
func TestSedIntegration_HeadingConversion(t *testing.T) {
|
|
doc := buildDoc(para(plain("Summary:")))
|
|
reqs := runSedIntegration(t, doc, "s/Summary:/# Summary/", nil)
|
|
|
|
hasHeading := false
|
|
for _, r := range reqs {
|
|
if r.UpdateParagraphStyle != nil &&
|
|
r.UpdateParagraphStyle.ParagraphStyle != nil &&
|
|
strings.Contains(r.UpdateParagraphStyle.ParagraphStyle.NamedStyleType, "HEADING") {
|
|
hasHeading = true
|
|
}
|
|
}
|
|
if !hasHeading {
|
|
t.Error("expected heading paragraph style update")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_H2Heading(t *testing.T) {
|
|
doc := buildDoc(para(plain("Section Title")))
|
|
reqs := runSedIntegration(t, doc, "s/Section Title/## Section Title/", nil)
|
|
|
|
hasH2 := false
|
|
for _, r := range reqs {
|
|
if r.UpdateParagraphStyle != nil &&
|
|
r.UpdateParagraphStyle.ParagraphStyle != nil &&
|
|
r.UpdateParagraphStyle.ParagraphStyle.NamedStyleType == "HEADING_2" {
|
|
hasH2 = true
|
|
}
|
|
}
|
|
if !hasH2 {
|
|
t.Error("expected HEADING_2 style")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Multi-Expression Batch
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_MultipleExpressions(t *testing.T) {
|
|
doc := buildDoc(
|
|
para(plain("Hello world")),
|
|
para(plain("Goodbye universe")),
|
|
)
|
|
reqs := runSedIntegration(t, doc, "", []string{
|
|
"s/Hello/**Hello**/g",
|
|
"s/Goodbye/*Goodbye*/g",
|
|
})
|
|
|
|
if len(reqs) == 0 {
|
|
t.Fatal("expected requests from multi-expression batch")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_FileExpressions(t *testing.T) {
|
|
doc := buildDoc(para(plain("alpha beta gamma")))
|
|
|
|
// Create a temp file with expressions
|
|
tmpFile, err := os.CreateTemp("", "sedtest-*.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
_, _ = tmpFile.WriteString("s/alpha/ALPHA/g\n# comment line\ns/beta/BETA/g\n\ns/gamma/GAMMA/g\n")
|
|
tmpFile.Close()
|
|
|
|
var captured []*docs.Request
|
|
testDoc := doc
|
|
srv := mockDocsServerAdvanced(t, testDoc, func(reqs []*docs.Request) {
|
|
captured = append(captured, reqs...)
|
|
})
|
|
defer srv.Close()
|
|
|
|
origNewDocs := newDocsService
|
|
newDocsService = func(ctx context.Context, account string) (*docs.Service, error) {
|
|
return docs.NewService(ctx,
|
|
option.WithoutAuthentication(),
|
|
option.WithEndpoint(srv.URL+"/"),
|
|
)
|
|
}
|
|
defer func() { newDocsService = origNewDocs }()
|
|
|
|
u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
|
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
|
|
|
cmd := &DocsSedCmd{
|
|
DocID: "test-doc-id",
|
|
File: tmpFile.Name(),
|
|
}
|
|
flags := &RootFlags{Account: "test@example.com"}
|
|
if err := cmd.Run(ctx, flags); err != nil {
|
|
t.Fatalf("Run with file: %v", err)
|
|
}
|
|
|
|
if len(captured) == 0 {
|
|
t.Error("expected requests from file expressions")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Positional Inserts
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_AppendText(t *testing.T) {
|
|
doc := buildDoc(para(plain("Existing content")))
|
|
reqs := runSedIntegration(t, doc, "s/$/\\nNew line appended/", nil)
|
|
|
|
hasInsert := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil {
|
|
hasInsert = true
|
|
}
|
|
}
|
|
if !hasInsert {
|
|
t.Error("expected insert request for $ append")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_PrependText(t *testing.T) {
|
|
doc := buildDoc(para(plain("Existing content")))
|
|
reqs := runSedIntegration(t, doc, "s/^/Prepended: /", nil)
|
|
|
|
hasInsert := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil {
|
|
hasInsert = true
|
|
}
|
|
}
|
|
if !hasInsert {
|
|
t.Error("expected insert request for ^ prepend")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Regex Edge Cases
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_RegexDigitClass(t *testing.T) {
|
|
doc := buildDoc(para(plain("Item 42 costs $99")))
|
|
reqs := runSedIntegration(t, doc, `s/\d+/NUM/g`, nil)
|
|
|
|
// Regex global uses native path if possible, or manual path
|
|
hasReplace := false
|
|
for _, r := range reqs {
|
|
if r.ReplaceAllText != nil || r.InsertText != nil {
|
|
hasReplace = true
|
|
}
|
|
}
|
|
if !hasReplace {
|
|
t.Error("expected replacement requests for digit class")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_RegexWordBoundary(t *testing.T) {
|
|
doc := buildDoc(para(plain("cat concatenate catalog")))
|
|
reqs := runSedIntegration(t, doc, `s/\bcat\b/dog/g`, nil)
|
|
|
|
// Word boundary regex — should produce replacement requests
|
|
hasReplace := false
|
|
for _, r := range reqs {
|
|
if r.ReplaceAllText != nil || r.InsertText != nil {
|
|
hasReplace = true
|
|
}
|
|
}
|
|
if !hasReplace {
|
|
t.Error("expected replacement requests for word boundary")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_SpecialCharsInPattern(t *testing.T) {
|
|
doc := buildDoc(para(plain("Price is $100.00 (USD)")))
|
|
reqs := runSedIntegration(t, doc, `s/\$100\.00/€95.00/`, nil)
|
|
|
|
found := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "€95.00" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected escaped special char replacement")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_AlternateDelimiter(t *testing.T) {
|
|
doc := buildDoc(para(plain("path /usr/local/bin")))
|
|
reqs := runSedIntegration(t, doc, "s#/usr/local/bin#/opt/bin#", nil)
|
|
|
|
found := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "/opt/bin" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected alternate delimiter replacement")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_NoMatchIsNotError(t *testing.T) {
|
|
doc := buildDoc(para(plain("Hello world")))
|
|
// This should succeed without error even with no matches
|
|
_ = runSedIntegration(t, doc, "s/nonexistent/replacement/g", nil)
|
|
}
|
|
|
|
func TestSedIntegration_EmptyDocument(t *testing.T) {
|
|
doc := &docs.Document{
|
|
DocumentId: "test-doc-id",
|
|
Title: "Empty Doc",
|
|
Body: &docs.Body{Content: []*docs.StructuralElement{}},
|
|
}
|
|
// Should not crash on empty document
|
|
_ = runSedIntegration(t, doc, "s/foo/bar/g", nil)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Multi-Paragraph Documents
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_MultiParagraphGlobal(t *testing.T) {
|
|
doc := buildDoc(
|
|
para(plain("First paragraph with word")),
|
|
para(plain("Second paragraph with word")),
|
|
para(plain("Third paragraph no match")),
|
|
)
|
|
reqs := runSedIntegration(t, doc, "s/word/WORD/g", nil)
|
|
|
|
// Global plain text uses native ReplaceAllText
|
|
hasNative := false
|
|
for _, r := range reqs {
|
|
if r.ReplaceAllText != nil && r.ReplaceAllText.ReplaceText == "WORD" {
|
|
hasNative = true
|
|
}
|
|
}
|
|
if !hasNative {
|
|
t.Error("expected native ReplaceAllText for multi-paragraph global")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_FormattedTextRuns(t *testing.T) {
|
|
// Document with mixed formatting — bold + plain text
|
|
doc := buildDoc(
|
|
para(bold("Important: "), plain("This is a warning message")),
|
|
)
|
|
reqs := runSedIntegration(t, doc, "s/warning/**critical**/", nil)
|
|
|
|
hasInsert := false
|
|
hasBold := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "critical" {
|
|
hasInsert = true
|
|
}
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil && r.UpdateTextStyle.TextStyle.Bold {
|
|
hasBold = true
|
|
}
|
|
}
|
|
if !hasInsert {
|
|
t.Error("expected insert for replacement text")
|
|
}
|
|
if !hasBold {
|
|
t.Error("expected bold formatting on replacement")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Table Operations
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_TableCellReplace(t *testing.T) {
|
|
doc := buildDocWithTable("Header", 2, 3,
|
|
[][]string{
|
|
{"Name", "Age", "City"},
|
|
{"Alice", "30", "NYC"},
|
|
},
|
|
"Footer",
|
|
)
|
|
reqs := runSedIntegration(t, doc, "s/Alice/Bob/", nil)
|
|
|
|
found := false
|
|
for _, r := range reqs {
|
|
if r.InsertText != nil && r.InsertText.Text == "Bob" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected replacement inside table cell")
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_TableGlobalReplace(t *testing.T) {
|
|
doc := buildDocWithTable("", 2, 2,
|
|
[][]string{
|
|
{"yes", "no"},
|
|
{"yes", "maybe"},
|
|
},
|
|
"",
|
|
)
|
|
reqs := runSedIntegration(t, doc, "s/yes/YES/g", nil)
|
|
|
|
// Global plain text uses native ReplaceAllText (handles tables too)
|
|
hasNative := false
|
|
for _, r := range reqs {
|
|
if r.ReplaceAllText != nil && r.ReplaceAllText.ReplaceText == "YES" {
|
|
hasNative = true
|
|
}
|
|
}
|
|
if !hasNative {
|
|
t.Error("expected native ReplaceAllText for table global replace")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests: Combined Formatting + Replace
|
|
// =============================================================================
|
|
|
|
func TestSedIntegration_BoldGlobalAcrossParagraphs(t *testing.T) {
|
|
doc := buildDoc(
|
|
para(plain("WARNING: System overloaded")),
|
|
para(plain("No issues here")),
|
|
para(plain("WARNING: Disk space low")),
|
|
)
|
|
reqs := runSedIntegration(t, doc, "s/WARNING/**WARNING**/g", nil)
|
|
|
|
boldCount := 0
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil && r.UpdateTextStyle.TextStyle.Bold {
|
|
boldCount++
|
|
}
|
|
}
|
|
if boldCount != 2 {
|
|
t.Errorf("expected 2 bold style updates, got %d", boldCount)
|
|
}
|
|
}
|
|
|
|
func TestSedIntegration_MixedFormattingBatch(t *testing.T) {
|
|
doc := buildDoc(
|
|
para(plain("Title: My Document")),
|
|
para(plain("Status: DRAFT")),
|
|
para(plain("Note: review needed")),
|
|
)
|
|
reqs := runSedIntegration(t, doc, "", []string{
|
|
"s/Title:/**Title:**/",
|
|
"s/DRAFT/~~DRAFT~~/",
|
|
"s/review needed/*review needed*/",
|
|
})
|
|
|
|
hasBold := false
|
|
hasStrike := false
|
|
hasItalic := false
|
|
for _, r := range reqs {
|
|
if r.UpdateTextStyle != nil && r.UpdateTextStyle.TextStyle != nil {
|
|
if r.UpdateTextStyle.TextStyle.Bold {
|
|
hasBold = true
|
|
}
|
|
if r.UpdateTextStyle.TextStyle.Strikethrough {
|
|
hasStrike = true
|
|
}
|
|
if r.UpdateTextStyle.TextStyle.Italic {
|
|
hasItalic = true
|
|
}
|
|
}
|
|
}
|
|
if !hasBold {
|
|
t.Error("expected bold in batch")
|
|
}
|
|
if !hasStrike {
|
|
t.Error("expected strikethrough in batch")
|
|
}
|
|
if !hasItalic {
|
|
t.Error("expected italic in batch")
|
|
}
|
|
}
|